57dc91585d
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.
147 lines
4.9 KiB
JavaScript
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 };
|