Files
AgentHub/server/lib/stage-detect.js
T
nntrivi2001 57dc91585d 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.
2026-07-30 14:39:03 +07:00

147 lines
4.9 KiB
JavaScript

/**
* @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 };