feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file Matches hook events against pipeline-defined stage-detection rules.
|
||||
* Inputs and templates are untrusted, so each exported function is total and
|
||||
* compiled regular expressions are cached once for each pipeline object.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const compiledPipelines = new WeakMap();
|
||||
|
||||
// Only fields that identify WHAT a tool did, never editor payload like
|
||||
// old_string/new_string, which can be whole code blocks.
|
||||
const FLATTEN_KEYS = new Set([
|
||||
"command",
|
||||
"file_path",
|
||||
"skill",
|
||||
"prompt",
|
||||
"pattern",
|
||||
"description",
|
||||
"subagent_type",
|
||||
]);
|
||||
|
||||
const SIGNAL_MAX = 120;
|
||||
|
||||
/** Collapse whitespace and cap to a bounded, readable length. */
|
||||
function capSignal(text) {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
return collapsed.length > SIGNAL_MAX ? `${collapsed.slice(0, SIGNAL_MAX)}…` : collapsed;
|
||||
}
|
||||
|
||||
/** Characters of surrounding context kept on each side of a matched span. */
|
||||
const SIGNAL_CONTEXT = 24;
|
||||
|
||||
/**
|
||||
* The span the rule actually matched, plus a little context — the whole
|
||||
* flattened input is usually a long shell line whose interesting part is a few
|
||||
* words in the middle (`cd /very/long/path && npm run test:server 2>&1 | tail`).
|
||||
* A rule with no regex matched on the tool name alone and has no span, so the
|
||||
* caller keeps the flattened input.
|
||||
*/
|
||||
function matchedSpan(regex, input) {
|
||||
if (!regex) return null;
|
||||
// `regex` may carry /g from a user template; lastIndex would make exec()
|
||||
// stateful across calls, so search from a known position every time.
|
||||
regex.lastIndex = 0;
|
||||
const m = regex.exec(input);
|
||||
if (!m || typeof m.index !== "number") return null;
|
||||
const start = Math.max(0, m.index - SIGNAL_CONTEXT);
|
||||
const end = Math.min(input.length, m.index + m[0].length + SIGNAL_CONTEXT);
|
||||
const prefix = start > 0 ? "…" : "";
|
||||
const suffix = end < input.length ? "…" : "";
|
||||
return `${prefix}${input.slice(start, end)}${suffix}`;
|
||||
}
|
||||
|
||||
/** Return known identifying string fields from a tool input without recursively walking it. */
|
||||
function flattenInput(toolInput) {
|
||||
try {
|
||||
if (typeof toolInput === "string") return toolInput;
|
||||
if (toolInput === null || typeof toolInput !== "object") return "";
|
||||
|
||||
const strings = [];
|
||||
if (Array.isArray(toolInput)) {
|
||||
for (const value of toolInput) {
|
||||
if (typeof value === "string") strings.push(value);
|
||||
else if (value && typeof value === "object") {
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
if (FLATTEN_KEYS.has(key) && typeof nestedValue === "string") strings.push(nestedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(toolInput)) {
|
||||
if (FLATTEN_KEYS.has(key) && typeof value === "string") strings.push(value);
|
||||
}
|
||||
}
|
||||
return strings.join(" ");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Compile valid node rules once, omitting malformed nodes and regex patterns. */
|
||||
function compileRules(pipeline) {
|
||||
if (!pipeline || typeof pipeline !== "object") return [];
|
||||
const cached = compiledPipelines.get(pipeline);
|
||||
if (cached) return cached;
|
||||
|
||||
let nodes = [];
|
||||
try {
|
||||
nodes = Array.isArray(pipeline.nodes) ? pipeline.nodes : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const compiled = nodes.map((node) => {
|
||||
const rules = [];
|
||||
try {
|
||||
const detectRules = Array.isArray(node && node.detect) ? node.detect : [];
|
||||
for (const rule of detectRules) {
|
||||
if (!rule || typeof rule !== "object" || typeof rule.tool !== "string") continue;
|
||||
if (rule.match === undefined) {
|
||||
rules.push({ tool: rule.tool, regex: null });
|
||||
continue;
|
||||
}
|
||||
if (typeof rule.match !== "string") continue;
|
||||
try {
|
||||
rules.push({ tool: rule.tool, regex: new RegExp(rule.match) });
|
||||
} catch {
|
||||
// A user-supplied invalid regex must never block hook ingestion.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore a malformed node while preserving the remaining template.
|
||||
}
|
||||
return { nodeId: node && node.id, rules };
|
||||
});
|
||||
|
||||
compiledPipelines.set(pipeline, compiled);
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/** Infer the last matching pipeline stage from one hook event, or return null. */
|
||||
function detect(pipeline, event) {
|
||||
try {
|
||||
if (!event || typeof event.tool_name !== "string" || !event.tool_name) return null;
|
||||
const input = flattenInput(event.tool_input);
|
||||
const fallback = input || event.tool_name;
|
||||
let match = null;
|
||||
|
||||
for (const node of compileRules(pipeline)) {
|
||||
if (typeof node.nodeId !== "string" || !node.nodeId) continue;
|
||||
for (const rule of node.rules) {
|
||||
if (rule.tool !== event.tool_name) continue;
|
||||
if (!rule.regex || rule.regex.test(input)) {
|
||||
const span = matchedSpan(rule.regex, input);
|
||||
match = { nodeId: node.nodeId, signal: `\`${capSignal(span || fallback)}\`` };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return match;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { flattenInput, compileRules, detect };
|
||||
Reference in New Issue
Block a user