From bf4260d1a153425886c955e01eb0a3918f295acf Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 14:42:02 -0400 Subject: [PATCH 01/72] feat(graph): add graph types and cycle analysis --- src/graphLoops.ts | 145 ++++++++++++++++++++++++++++++++++++++++++++++ src/graphTypes.ts | 78 +++++++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 src/graphLoops.ts create mode 100644 src/graphTypes.ts diff --git a/src/graphLoops.ts b/src/graphLoops.ts new file mode 100644 index 0000000..b10bb35 --- /dev/null +++ b/src/graphLoops.ts @@ -0,0 +1,145 @@ +import type { AgentState } from "./types.js"; +import type { + AgentGraphEdge, + AgentGraphLoop, + AgentGraphNode, +} from "./graphTypes.js"; + +function stateRank(state: AgentState): number { + if (state === "error") return 2; + if (state === "active") return 1; + return 0; +} + +function strongestState(states: Iterable): AgentState { + let strongest: AgentState = "idle"; + for (const state of states) { + if (stateRank(state) > stateRank(strongest)) strongest = state; + } + return strongest; +} + +function maxDefined(values: Array): number | undefined { + let max: number | undefined; + for (const value of values) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + max = typeof max === "number" ? Math.max(max, value) : value; + } + return max; +} + +function findStronglyConnectedComponents( + nodeIds: string[], + edges: AgentGraphEdge[] +): string[][] { + const adjacency = new Map(); + for (const nodeId of nodeIds) adjacency.set(nodeId, []); + for (const edge of edges) { + if (edge.kind !== "transition") continue; + if (!adjacency.has(edge.source) || !adjacency.has(edge.target)) continue; + adjacency.get(edge.source)?.push(edge.target); + } + for (const targets of adjacency.values()) targets.sort(); + + let nextIndex = 0; + const indexByNode = new Map(); + const lowLinkByNode = new Map(); + const stack: string[] = []; + const onStack = new Set(); + const components: string[][] = []; + + const visit = (nodeId: string): void => { + indexByNode.set(nodeId, nextIndex); + lowLinkByNode.set(nodeId, nextIndex); + nextIndex += 1; + stack.push(nodeId); + onStack.add(nodeId); + + for (const target of adjacency.get(nodeId) ?? []) { + if (!indexByNode.has(target)) { + visit(target); + lowLinkByNode.set( + nodeId, + Math.min(lowLinkByNode.get(nodeId) ?? 0, lowLinkByNode.get(target) ?? 0) + ); + } else if (onStack.has(target)) { + lowLinkByNode.set( + nodeId, + Math.min(lowLinkByNode.get(nodeId) ?? 0, indexByNode.get(target) ?? 0) + ); + } + } + + if (lowLinkByNode.get(nodeId) !== indexByNode.get(nodeId)) return; + + const component: string[] = []; + while (stack.length > 0) { + const member = stack.pop(); + if (!member) break; + onStack.delete(member); + component.push(member); + if (member === nodeId) break; + } + component.sort(); + components.push(component); + }; + + for (const nodeId of [...nodeIds].sort()) { + if (!indexByNode.has(nodeId)) visit(nodeId); + } + + return components; +} + +export function detectGraphLoops( + nodes: AgentGraphNode[], + edges: AgentGraphEdge[] +): AgentGraphLoop[] { + const stepNodes = nodes.filter((node) => node.kind === "step"); + const stepNodeById = new Map(stepNodes.map((node) => [node.id, node])); + const transitionEdges = edges.filter((edge) => edge.kind === "transition"); + const selfLoopNodeIds = new Set( + transitionEdges + .filter((edge) => edge.source === edge.target) + .map((edge) => edge.source) + ); + const components = findStronglyConnectedComponents( + stepNodes.map((node) => node.id), + transitionEdges + ); + + const loops: AgentGraphLoop[] = []; + for (const component of components) { + const isSelfLoop = component.length === 1 && selfLoopNodeIds.has(component[0]); + if (component.length < 2 && !isSelfLoop) continue; + + const memberIds = new Set(component); + const loopEdges = transitionEdges.filter( + (edge) => memberIds.has(edge.source) && memberIds.has(edge.target) + ); + const memberNodes = component + .map((nodeId) => stepNodeById.get(nodeId)) + .filter((node): node is AgentGraphNode => !!node); + + loops.push({ + id: `loop:${component.join("|")}`, + kind: isSelfLoop ? "self" : "cycle", + nodeIds: component, + edgeIds: loopEdges.map((edge) => edge.id).sort(), + agentIds: Array.from( + new Set(memberNodes.map((node) => node.agentIdentity)) + ).sort(), + providers: Array.from( + new Set(memberNodes.map((node) => node.provider)) + ).sort(), + state: strongestState(memberNodes.map((node) => node.state)), + observations: loopEdges.reduce( + (total, edge) => total + edge.observations, + 0 + ), + lastSeenAt: maxDefined(loopEdges.map((edge) => edge.lastSeenAt)), + }); + } + + return loops.sort((a, b) => a.id.localeCompare(b.id)); +} diff --git a/src/graphTypes.ts b/src/graphTypes.ts new file mode 100644 index 0000000..01900c8 --- /dev/null +++ b/src/graphTypes.ts @@ -0,0 +1,78 @@ +import type { AgentState } from "./types.js"; + +export type GraphNodeKind = "agent" | "step"; +export type GraphEdgeKind = "contains" | "transition"; +export type GraphLoopKind = "self" | "cycle"; + +export interface AgentGraphNode { + id: string; + kind: GraphNodeKind; + label: string; + state: AgentState; + agentId: string; + agentIdentity: string; + provider: string; + repo?: string; + phase?: string; + eventTypes?: string[]; + firstSeenAt?: number; + lastSeenAt?: number; + observations?: number; +} + +export interface AgentGraphEdge { + id: string; + source: string; + target: string; + kind: GraphEdgeKind; + observations: number; + lastSeenAt?: number; +} + +export interface AgentGraphLoop { + id: string; + kind: GraphLoopKind; + nodeIds: string[]; + edgeIds: string[]; + agentIds: string[]; + providers: string[]; + state: AgentState; + observations: number; + lastSeenAt?: number; +} + +export interface AgentGraphWindow { + retainedEvents: number; + oldestEventAt?: number; + newestEventAt?: number; +} + +export interface AgentGraphStats { + agents: number; + steps: number; + edges: number; + transitionEdges: number; + transitions: number; + loops: number; + activeLoops: number; + errorLoops: number; +} + +export interface AgentGraphSnapshot { + version: 1; + source: "observed-events"; + ts: number; + window: AgentGraphWindow; + nodes: AgentGraphNode[]; + edges: AgentGraphEdge[]; + loops: AgentGraphLoop[]; + stats: AgentGraphStats; +} + +export interface AgentGraphInput { + source: AgentGraphSnapshot["source"]; + ts: number; + window: AgentGraphWindow; + nodes: AgentGraphNode[]; + edges: AgentGraphEdge[]; +} From 4175be6034b30d19c5e4d9bc83060414bde01097 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 14:43:01 -0400 Subject: [PATCH 02/72] feat(graph): build observed phase graphs --- src/graph.ts | 351 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 src/graph.ts diff --git a/src/graph.ts b/src/graph.ts new file mode 100644 index 0000000..cd72f47 --- /dev/null +++ b/src/graph.ts @@ -0,0 +1,351 @@ +import type { + AgentKind, + AgentSnapshot, + AgentState, + EventSummary, + SnapshotPayload, +} from "./types.js"; +import { detectGraphLoops } from "./graphLoops.js"; +import type { + AgentGraphEdge, + AgentGraphInput, + AgentGraphLoop, + AgentGraphNode, + AgentGraphSnapshot, + GraphEdgeKind, +} from "./graphTypes.js"; + +export type { + AgentGraphEdge, + AgentGraphInput, + AgentGraphLoop, + AgentGraphNode, + AgentGraphSnapshot, + AgentGraphStats, + AgentGraphWindow, + GraphEdgeKind, + GraphLoopKind, + GraphNodeKind, +} from "./graphTypes.js"; + +interface IndexedEvent { + event: EventSummary; + index: number; +} + +function identityForAgent(agent: AgentSnapshot): string { + return agent.identity || agent.id; +} + +function providerForKind(kind: AgentKind): string { + if (kind.startsWith("opencode")) return "opencode"; + if (kind.startsWith("claude")) return "claude"; + if (kind === "app-server") return "server"; + if (kind === "unknown") return "other"; + return "codex"; +} + +function idPart(value: string): string { + return encodeURIComponent(value); +} + +function agentNodeId(identity: string): string { + return `agent:${idPart(identity)}`; +} + +function stepNodeId(identity: string, phase: string): string { + return `step:${idPart(identity)}:${idPart(phase)}`; +} + +function edgeId(kind: GraphEdgeKind, source: string, target: string): string { + return `${kind}:${source}->${target}`; +} + +function normalizedEventType(event: EventSummary): string { + const type = event.type?.trim(); + return type || "event"; +} + +function phaseForEvent(event: EventSummary): string { + const summary = event.summary?.trim().toLowerCase() || ""; + const eventType = normalizedEventType(event); + const lowerType = eventType.toLowerCase(); + + if (summary.startsWith("cmd:")) return "command"; + if (summary.startsWith("edit:")) return "edit"; + if (summary.startsWith("tool:")) return "tool"; + if (summary.startsWith("prompt:")) return "prompt"; + if (/file_(change|edit|write)|patch/.test(lowerType)) return "edit"; + if (/tool|function_call|mcp/.test(lowerType)) return "tool"; + if (/command|(?:^|[._-])exec(?:ute|ution)?(?:[._-]|$)/.test(lowerType)) { + return "command"; + } + if (/prompt|user_message/.test(lowerType)) return "prompt"; + if ( + summary === "thinking" || + summary === "message" || + /reasoning|assistant|agent_message|response/.test(lowerType) + ) { + return "model"; + } + if (summary && !summary.startsWith("event:") && !summary.startsWith("compaction:")) { + return "model"; + } + return eventType; +} + +function maxDefined(values: Array): number | undefined { + let max: number | undefined; + for (const value of values) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + max = typeof max === "number" ? Math.max(max, value) : value; + } + return max; +} + +function minDefined(values: Array): number | undefined { + let min: number | undefined; + for (const value of values) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + min = typeof min === "number" ? Math.min(min, value) : value; + } + return min; +} + +function sortedEvents(events: EventSummary[] | undefined): EventSummary[] { + if (!events?.length) return []; + return events + .map((event, index): IndexedEvent => ({ event, index })) + .filter(({ event }) => typeof event.ts === "number" && Number.isFinite(event.ts)) + .sort((a, b) => a.event.ts - b.event.ts || a.index - b.index) + .map(({ event }) => event); +} + +function addContainsEdge( + edges: Map, + source: string, + target: string, + lastSeenAt: number | undefined +): void { + const id = edgeId("contains", source, target); + const current = edges.get(id); + if (current) { + current.lastSeenAt = maxDefined([current.lastSeenAt, lastSeenAt]); + return; + } + edges.set(id, { + id, + source, + target, + kind: "contains", + observations: 1, + lastSeenAt, + }); +} + +function addTransitionEdge( + edges: Map, + source: string, + target: string, + lastSeenAt: number +): void { + const id = edgeId("transition", source, target); + const current = edges.get(id); + if (current) { + current.observations += 1; + current.lastSeenAt = Math.max(current.lastSeenAt ?? lastSeenAt, lastSeenAt); + return; + } + edges.set(id, { + id, + source, + target, + kind: "transition", + observations: 1, + lastSeenAt, + }); +} + +export function analyzeAgentGraph(input: AgentGraphInput): AgentGraphSnapshot { + const nodes = [...input.nodes].sort((a, b) => a.id.localeCompare(b.id)); + const edges = [...input.edges].sort((a, b) => a.id.localeCompare(b.id)); + const loops = detectGraphLoops(nodes, edges); + const transitionEdges = edges.filter((edge) => edge.kind === "transition"); + + return { + version: 1, + source: input.source, + ts: input.ts, + window: input.window, + nodes, + edges, + loops, + stats: { + agents: nodes.filter((node) => node.kind === "agent").length, + steps: nodes.filter((node) => node.kind === "step").length, + edges: edges.length, + transitionEdges: transitionEdges.length, + transitions: transitionEdges.reduce( + (total, edge) => total + edge.observations, + 0 + ), + loops: loops.length, + activeLoops: loops.filter((loop) => loop.state === "active").length, + errorLoops: loops.filter((loop) => loop.state === "error").length, + }, + }; +} + +export function buildAgentGraph(snapshot: SnapshotPayload): AgentGraphSnapshot { + const nodes = new Map(); + const edges = new Map(); + const retainedEventTimes: number[] = []; + const agents = [...snapshot.agents].sort((a, b) => + identityForAgent(a).localeCompare(identityForAgent(b)) + ); + + for (const agent of agents) { + const identity = identityForAgent(agent); + const provider = providerForKind(agent.kind); + const rootNodeId = agentNodeId(identity); + nodes.set(rootNodeId, { + id: rootNodeId, + kind: "agent", + label: agent.title || agent.doing || `${provider}:${agent.id}`, + state: agent.state, + agentId: agent.id, + agentIdentity: identity, + provider, + repo: agent.repo, + firstSeenAt: + typeof agent.startedAt === "number" ? agent.startedAt * 1000 : undefined, + lastSeenAt: maxDefined([agent.lastActivityAt, agent.lastEventAt, snapshot.ts]), + observations: 1, + }); + + let previousStepNodeId: string | undefined; + for (const event of sortedEvents(agent.events)) { + retainedEventTimes.push(event.ts); + const eventType = normalizedEventType(event); + const phase = phaseForEvent(event); + const currentStepNodeId = stepNodeId(identity, phase); + const current = nodes.get(currentStepNodeId); + + if (current) { + current.observations = (current.observations ?? 0) + 1; + current.firstSeenAt = Math.min(current.firstSeenAt ?? event.ts, event.ts); + current.lastSeenAt = Math.max(current.lastSeenAt ?? event.ts, event.ts); + current.eventTypes = Array.from( + new Set([...(current.eventTypes ?? []), eventType]) + ).sort(); + if (event.isError) current.state = "error"; + } else { + nodes.set(currentStepNodeId, { + id: currentStepNodeId, + kind: "step", + label: phase, + state: event.isError ? "error" : agent.state, + agentId: agent.id, + agentIdentity: identity, + provider, + repo: agent.repo, + phase, + eventTypes: [eventType], + firstSeenAt: event.ts, + lastSeenAt: event.ts, + observations: 1, + }); + } + + addContainsEdge(edges, rootNodeId, currentStepNodeId, event.ts); + if (previousStepNodeId && previousStepNodeId !== currentStepNodeId) { + addTransitionEdge(edges, previousStepNodeId, currentStepNodeId, event.ts); + } + previousStepNodeId = currentStepNodeId; + } + } + + return analyzeAgentGraph({ + source: "observed-events", + ts: snapshot.ts, + window: { + retainedEvents: retainedEventTimes.length, + oldestEventAt: minDefined(retainedEventTimes), + newestEventAt: maxDefined(retainedEventTimes), + }, + nodes: Array.from(nodes.values()), + edges: Array.from(edges.values()), + }); +} + +function graphStateLabel(state: AgentState): string { + return state.toUpperCase().padEnd(6, " "); +} + +function formatLoop(loop: AgentGraphLoop, nodeById: Map): string { + const labels = loop.nodeIds.map( + (nodeId) => nodeById.get(nodeId)?.label || nodeId + ); + if (loop.kind === "self") return `${labels[0]} -> ${labels[0]}`; + if (labels.length === 2) return `${labels[0]} -> ${labels[1]} -> ${labels[0]}`; + return `cycle{${labels.join(", ")}}`; +} + +export function formatAgentGraph(graph: AgentGraphSnapshot): string { + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); + const agentNodes = graph.nodes.filter((node) => node.kind === "agent"); + const stepCountByAgent = new Map(); + const loopCountByAgent = new Map(); + + for (const node of graph.nodes) { + if (node.kind !== "step") continue; + stepCountByAgent.set( + node.agentIdentity, + (stepCountByAgent.get(node.agentIdentity) ?? 0) + 1 + ); + } + for (const loop of graph.loops) { + for (const agentIdentity of loop.agentIds) { + loopCountByAgent.set( + agentIdentity, + (loopCountByAgent.get(agentIdentity) ?? 0) + 1 + ); + } + } + + const lines = [ + "consensus graph", + `agents=${graph.stats.agents} steps=${graph.stats.steps} ` + + `transition_edges=${graph.stats.transitionEdges} transitions=${graph.stats.transitions} ` + + `loops=${graph.stats.loops} window_events=${graph.window.retainedEvents}`, + "", + "AGENTS", + ]; + + if (agentNodes.length === 0) { + lines.push(" none observed"); + } else { + for (const node of agentNodes) { + const steps = stepCountByAgent.get(node.agentIdentity) ?? 0; + const loops = loopCountByAgent.get(node.agentIdentity) ?? 0; + lines.push( + ` ${graphStateLabel(node.state)} ${node.label} ` + + `[${node.provider}] steps=${steps} loops=${loops}` + ); + } + } + + lines.push("", "LOOPS"); + if (graph.loops.length === 0) { + lines.push(" none detected in the retained event window"); + } else { + for (const loop of graph.loops) { + lines.push( + ` ${graphStateLabel(loop.state)} ${formatLoop(loop, nodeById)} ` + + `observations=${loop.observations}` + ); + } + } + + return `${lines.join("\n")}\n`; +} From 6a7ca74540ce42570f567de22a2742b495bfaa01 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 14:43:52 -0400 Subject: [PATCH 03/72] feat(graph): add graph command and tests --- src/cli/graph.ts | 92 ++++++++++++++++++ tests/unit/graph.test.ts | 196 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 src/cli/graph.ts create mode 100644 tests/unit/graph.test.ts diff --git a/src/cli/graph.ts b/src/cli/graph.ts new file mode 100644 index 0000000..b965f99 --- /dev/null +++ b/src/cli/graph.ts @@ -0,0 +1,92 @@ +import { readFile } from "fs/promises"; +import { buildAgentGraph, formatAgentGraph } from "../graph.js"; +import { scanCodexProcesses } from "../scan.js"; +import type { SnapshotPayload } from "../types.js"; + +function graphHelp(): string { + return [ + "consensus graph", + "", + "Inspect live agent transitions as a graph and report detected loops.", + "", + "Usage:", + " consensus graph [--json] [--snapshot ]", + "", + "Options:", + " --json Print the full graph snapshot as JSON", + " --snapshot Read a saved snapshot file, or '-' for stdin", + " -h, --help Show graph command help", + "", + ].join("\n"); +} + +async function readStdin(): Promise { + let input = ""; + for await (const chunk of process.stdin) { + input += String(chunk); + } + return input; +} + +function parseSnapshot(input: string, source: string): SnapshotPayload { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch (error) { + throw new Error(`invalid snapshot JSON from ${source}: ${String(error)}`); + } + + if ( + !parsed || + typeof parsed !== "object" || + typeof (parsed as SnapshotPayload).ts !== "number" || + !Array.isArray((parsed as SnapshotPayload).agents) + ) { + throw new Error(`invalid snapshot payload from ${source}`); + } + + return parsed as SnapshotPayload; +} + +async function loadSnapshot(snapshotPath: string | undefined): Promise { + if (!snapshotPath) { + return scanCodexProcesses({ mode: "full" }); + } + + const input = + snapshotPath === "-" ? await readStdin() : await readFile(snapshotPath, "utf8"); + return parseSnapshot(input, snapshotPath === "-" ? "stdin" : snapshotPath); +} + +export async function runGraph(args: string[]): Promise { + let json = false; + let snapshotPath: string | undefined; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "-h" || arg === "--help") { + process.stdout.write(graphHelp()); + return; + } + if (arg === "--json") { + json = true; + continue; + } + if (arg === "--snapshot") { + snapshotPath = args[index + 1]; + if (!snapshotPath) throw new Error("--snapshot requires a path or '-'"); + index += 1; + continue; + } + if (arg.startsWith("--snapshot=")) { + snapshotPath = arg.slice("--snapshot=".length); + if (!snapshotPath) throw new Error("--snapshot requires a path or '-'"); + continue; + } + throw new Error(`unknown graph option: ${arg}`); + } + + const snapshot = await loadSnapshot(snapshotPath); + const graph = buildAgentGraph(snapshot); + process.stdout.write(json ? `${JSON.stringify(graph, null, 2)}\n` : formatAgentGraph(graph)); +} diff --git a/tests/unit/graph.test.ts b/tests/unit/graph.test.ts new file mode 100644 index 0000000..e2adcf9 --- /dev/null +++ b/tests/unit/graph.test.ts @@ -0,0 +1,196 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + analyzeAgentGraph, + buildAgentGraph, + formatAgentGraph, + type AgentGraphNode, +} from "../../src/graph.js"; +import type { AgentSnapshot, EventSummary, SnapshotPayload } from "../../src/types.js"; + +function event( + ts: number, + type: string, + summary: string = type, + isError = false +): EventSummary { + return { ts, type, summary, ...(isError ? { isError } : {}) }; +} + +function agent( + events: EventSummary[], + state: AgentSnapshot["state"] = "active", + identity = "codex:session-1" +): AgentSnapshot { + const second = identity.endsWith("2"); + return { + identity, + id: second ? "102" : "101", + pid: second ? 102 : 101, + title: second ? "second worker" : "graph worker", + cmd: "codex", + cmdShort: "codex", + kind: "tui", + cpu: 1, + mem: 100, + state, + events, + }; +} + +function snapshot(events: EventSummary[]): SnapshotPayload { + return { ts: 1_000, agents: [agent(events)] }; +} + +describe("agent graph", () => { + it("keeps live agents when no step events are retained", () => { + const graph = buildAgentGraph(snapshot([])); + + assert.equal(graph.stats.agents, 1); + assert.equal(graph.stats.steps, 0); + assert.equal(graph.stats.loops, 0); + assert.equal(graph.window.retainedEvents, 0); + }); + + it("normalizes provider events into model and tool phases", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "event_msg", "thinking"), + event(2, "response_item", "tool: shell"), + event(3, "event_msg", "message"), + event(4, "response_item", "tool: shell"), + ]) + ); + + assert.equal(graph.stats.steps, 2); + assert.equal(graph.stats.transitionEdges, 2); + assert.equal(graph.stats.transitions, 3); + assert.equal(graph.loops.length, 1); + assert.equal(graph.loops[0].kind, "cycle"); + assert.equal(graph.loops[0].observations, 3); + assert.deepEqual( + graph.nodes + .filter((node) => node.kind === "step") + .map((node) => node.label) + .sort(), + ["model", "tool"] + ); + }); + + it("keeps tool execution events in the tool phase", () => { + const graph = buildAgentGraph( + snapshot([event(1, "tool_execution", "event: tool_execution")]) + ); + + const step = graph.nodes.find((node) => node.kind === "step"); + assert.equal(step?.phase, "tool"); + }); + + it("collapses adjacent stream fragments instead of reporting a false self loop", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "response.delta", "thinking"), + event(2, "response.delta", "thinking"), + event(3, "response.delta", "message"), + ]) + ); + + const step = graph.nodes.find((node) => node.kind === "step"); + assert.equal(graph.stats.steps, 1); + assert.equal(graph.stats.transitionEdges, 0); + assert.equal(graph.stats.loops, 0); + assert.equal(step?.observations, 3); + }); + + it("detects an explicit self edge", () => { + const node: AgentGraphNode = { + id: "step:x:tool", + kind: "step", + label: "tool", + state: "active", + agentId: "1", + agentIdentity: "x", + provider: "codex", + phase: "tool", + }; + const graph = analyzeAgentGraph({ + source: "observed-events", + ts: 1, + window: { retainedEvents: 1 }, + nodes: [node], + edges: [ + { + id: "transition:self", + source: node.id, + target: node.id, + kind: "transition", + observations: 2, + }, + ], + }); + + assert.equal(graph.loops.length, 1); + assert.equal(graph.loops[0].kind, "self"); + assert.equal(graph.loops[0].observations, 2); + }); + + it("does not report a one-way phase chain as a loop", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "prompt", "prompt: build"), + event(2, "reasoning", "thinking"), + event(3, "assistant", "message"), + ]) + ); + + assert.equal(graph.stats.transitionEdges, 1); + assert.equal(graph.stats.loops, 0); + }); + + it("isolates equal phase names by agent identity", () => { + const graph = buildAgentGraph({ + ts: 1_000, + agents: [ + agent([ + event(1, "tool", "tool: shell"), + event(2, "message", "message"), + event(3, "tool", "tool: shell"), + ]), + agent([event(1, "tool", "tool: shell")], "idle", "codex:session-2"), + ], + }); + + assert.equal(graph.stats.agents, 2); + assert.equal(graph.stats.steps, 3); + assert.equal(graph.stats.loops, 1); + assert.deepEqual(graph.loops[0].agentIds, ["codex:session-1"]); + }); + + it("marks a loop as error when one phase has an error event", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "event_msg", "thinking"), + event(2, "tool", "tool: shell", true), + event(3, "event_msg", "message"), + ]) + ); + + assert.equal(graph.loops[0].state, "error"); + assert.equal(graph.stats.errorLoops, 1); + }); + + it("renders a compact human-readable summary", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "event_msg", "thinking"), + event(2, "tool", "tool: shell"), + event(3, "event_msg", "message"), + ]) + ); + const output = formatAgentGraph(graph); + + assert.match(output, /consensus graph/); + assert.match(output, /agents=1 steps=2/); + assert.match(output, /model -> tool -> model/); + }); +}); From 15f3097f0b2c8397f3f181661b0fff930718941b Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 14:44:50 -0400 Subject: [PATCH 04/72] feat(cli): expose consensus graph --- package.json | 1 + src/cli.ts | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) mode change 100644 => 100755 src/cli.ts diff --git a/package.json b/package.json index 03cb885..e21ab4e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "build:client": "cd public && vite build", "start": "node dist/server.js", "scan": "node dist/scan.js", + "graph": "tsx src/cli.ts graph", "tail": "node dist/tail.js", "test": "npm run test:unit && npm run test:integration", "test:unit": "node --test --import tsx tests/unit/**/*.test.ts", diff --git a/src/cli.ts b/src/cli.ts old mode 100644 new mode 100755 index 9c056fe..0df31c9 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { runPromise, withSpan, } from "./observability/index.js"; +import { runGraph } from "./cli/graph.js"; import { runSetup } from "./cli/setup.js"; const __filename = fileURLToPath(import.meta.url); @@ -18,7 +19,7 @@ const __dirname = path.dirname(__filename); const args = process.argv.slice(2); -// Handle setup command +// Handle one-shot commands before starting the server. if (args[0] === "setup") { runSetup() .then(() => { @@ -27,7 +28,17 @@ if (args[0] === "setup") { .catch(() => { process.exit(1); }); - // Exit early - don't start server +} else if (args[0] === "graph") { + runGraph(args.slice(1)) + .then(async () => { + await disposeObservability().catch(() => undefined); + process.exit(0); + }) + .catch(async (err) => { + process.stderr.write(`[consensus] graph error: ${String(err)}\n`); + await disposeObservability().catch(() => undefined); + process.exit(1); + }); } else { function readArg(name: string): string | undefined { @@ -49,6 +60,7 @@ function printHelp(): void { process.stdout.write(`Usage:\n consensus [command] [options]\n\n`); process.stdout.write(`Commands:\n`); process.stdout.write(` setup Configure Codex notify hook (recommended first step)\n`); + process.stdout.write(` graph Inspect agent transitions and detect loops\n`); process.stdout.write(` (default) Start the consensus server\n\n`); process.stdout.write(`Options:\n`); process.stdout.write(` --host Bind address (default 127.0.0.1)\n`); From 059799fca160a3a3ee7ddafbba37a9541741992d Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 14:45:40 -0400 Subject: [PATCH 05/72] docs: define graph and loop direction --- CHANGELOG.md | 1 + ROADMAP.md | 31 ++++++++++++-- docs/cli.md | 45 +++++++++++++++++++- docs/graphs-and-loops.md | 91 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 docs/graphs-and-loops.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7853515..1bb73af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. This project follows Semantic Versioning. ## Unreleased +- Add: `consensus graph` builds an observed execution graph, counts phase transitions, and detects loops from retained session events. - Fix: normalize OpenCode detection for mixed-case binary paths to keep servers in the correct lane. - Fix: OpenCode activity now uses work-only timestamps (not heartbeat events) and decays in-flight after idle. - Fix: Claude CLI prompts use a short pulse instead of sticking active indefinitely. diff --git a/ROADMAP.md b/ROADMAP.md index 4688658..6fcc72b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,30 @@ # Roadmap -Near-term goals: +## Current direction: graphs and loops + +Consensus is moving from a process atlas toward a local-first view of agent execution graphs and loops. The product remains read-only: it observes runs but does not control agent processes. + +### Now + +- Build a provider-neutral execution graph from retained Codex, OpenCode, and Claude Code events. +- Expose graph nodes, transition counts, and detected cycles through `consensus graph`. +- Keep graph output versioned and explicit about its observed event window. + +### Next + +- Add parent, subagent, delegation, approval, retry, and handoff edges when providers expose them. +- Stream graph deltas through the server and render graph structure in the isometric UI. +- Add loop health signals: duration, retry count, stop-rule status, budget use, and stuck-loop warnings. +- Compare declared workflow graphs with the task graph created at runtime. + +### Later + +- Compact mini-map and graph grouping controls. +- Multi-device aggregation through an optional cloud relay with one graph and timeline. +- Run comparison, graph replay, and export for audit and evaluation. +- Basic performance profiling and render budget notes. + +## Existing reliability work + - Improve session-to-process matching with PID metadata when available. -- Add compact mini-map and grouping controls in the UI. -- Add basic performance profiling and render budget notes. -- Multi-device aggregation: optional cloud relay for agents across machines with a unified timeline. +- Keep provider activity parsing stable across Codex, OpenCode, and Claude Code. diff --git a/docs/cli.md b/docs/cli.md index 05c213e..1148e35 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,47 @@ # CLI -Run: +## Start the local server + +```bash +npx consensus-cli +``` + +The server prints the local browser URL. Use `npx consensus-cli --help` for server flags. + +## Configure hooks + +```bash +npx consensus-cli setup +``` + +This configures the recommended Codex notify hook. + +## Inspect graphs and loops + +Build a provider-neutral execution graph from live local sessions: + +```bash +npx consensus-cli graph +``` + +Print the full versioned graph payload: + +```bash +npx consensus-cli graph --json +``` + +Analyze a saved Consensus snapshot: + ```bash -npx consensus-cli --help +npx consensus-cli graph --snapshot snapshot.json ``` + +Read a snapshot from stdin: + +```bash +cat snapshot.json | npx consensus-cli graph --snapshot - +``` + +The graph command is read-only. It detects observed phase cycles but does not start, stop, retry, or route agent work. + +See `docs/graphs-and-loops.md` for the graph model and current limits. diff --git a/docs/graphs-and-loops.md b/docs/graphs-and-loops.md new file mode 100644 index 0000000..7ac7c7e --- /dev/null +++ b/docs/graphs-and-loops.md @@ -0,0 +1,91 @@ +# Graphs and loops + +Consensus treats an agent loop as a cycle inside an execution graph. + +The graph answers two different questions: + +- **Graph:** What agents and work phases exist, and which transitions have been observed? +- **Loop:** Which observed transitions return to an earlier phase? + +This keeps the runtime model useful for both fixed workflows and agent-created task paths. Consensus remains an observability tool: it reports what ran but does not start, stop, retry, or route work. + +## Preview command + +Build a graph from the current local snapshot: + +```bash +npx consensus-cli graph +``` + +Print the full graph payload: + +```bash +npx consensus-cli graph --json +``` + +Analyze a saved snapshot: + +```bash +npm run scan > snapshot.json +npx consensus-cli graph --snapshot snapshot.json +``` + +Read a snapshot from stdin: + +```bash +cat snapshot.json | npx consensus-cli graph --snapshot - +``` + +## Graph model + +The version 1 graph uses the retained events already present in a Consensus snapshot. + +### Nodes + +- `agent`: one node for each observed Codex, OpenCode, or Claude Code session or process. +- `step`: one provider-neutral work phase for an agent. + +The first normalized phases are: + +- `prompt` +- `model` +- `tool` +- `command` +- `edit` + +Unknown event types remain visible as their own phases instead of being dropped. + +### Edges + +- `contains`: connects an agent to its observed phases. +- `transition`: connects one phase to the next phase seen for the same agent. + +Repeated adjacent stream fragments collapse into one phase visit. This prevents token or message deltas from creating false self-loops. Repeated transitions still increase the edge observation count. + +### Loops + +Consensus finds strongly connected components in the transition graph: + +- A self-edge is a `self` loop. +- Two or more mutually reachable phases form a `cycle` loop. + +Loop state follows the strongest current signal from its member phases: `error`, then `active`, then `idle`. + +## Output limits + +Version 1 has explicit limits: + +- It analyzes only the events retained in the current snapshot. +- Provider event normalization is best effort. +- It does not infer causal links between separate agents. +- It does not yet represent parent, subagent, delegation, approval, or retry edges. +- It reports an observed phase-transition graph, not a complete workflow definition. + +These limits appear in the data model through `source: "observed-events"` and the retained event window. + +## Next graph slices + +1. Add explicit parent, subagent, delegation, approval, and retry edges from provider and harness data. +2. Stream graph deltas through the server and render them in the isometric UI. +3. Support declared workflow graphs alongside runtime-created task graphs. +4. Add loop budgets, stop-rule signals, stuck-loop detection, and run comparisons. From 3bbe0401bb8c511ae6ae270b272f9109d45772fa Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 14:47:12 -0400 Subject: [PATCH 06/72] docs: refocus readme on graphs and loops --- README.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 852f3e4..70a39ef 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,17 @@ [![GitHub release](https://img.shields.io/github/v/release/integrate-your-mind/consensus-cli?display_name=tag&color=2563eb)](https://github.com/integrate-your-mind/consensus-cli/releases) [![License](https://img.shields.io/npm/l/consensus-cli.svg?color=6b7280)](LICENSE) -Live isometric atlas for Codex, OpenCode, and Claude Code sessions, rendered in a local browser. +Local-first observability for coding-agent graphs, loops, and live activity across Codex, OpenCode, and Claude Code. ## Status Beta. Local-only, no hosted service. ## Who it's for -Developers running multiple Codex, OpenCode, or Claude Code sessions who want a visual, at-a-glance view of activity. +Developers running multiple Codex, OpenCode, or Claude Code sessions who need a clear view of live activity and the execution paths inside each run. ## Core use cases +- Inspect observed model, tool, command, edit, and prompt transitions. +- Detect cycles in the retained event window. - Track which agents are active right now. - Spot errors or idle processes quickly. - Inspect recent activity without digging through logs. @@ -52,6 +54,25 @@ consensus dev server running on http://127.0.0.1:8787 - Best-effort "doing" summary from Codex session JSONL, OpenCode events, or Claude CLI flags. - Click a tile for details and recent events. - Active lane for agents plus a dedicated lane for servers. +- A versioned observed execution graph with transition counts and detected loops. + +## Graphs and loops (preview) + +Run a one-shot graph inspection against live local sessions: + +```bash +npx consensus-cli graph +``` + +Print the full graph payload for scripts, storage, or later visualization: + +```bash +npx consensus-cli graph --json +``` + +Consensus normalizes retained provider events into `prompt`, `model`, `tool`, `command`, and `edit` phases. It creates transition edges for each observed phase change and reports cycles in that graph. Adjacent stream fragments collapse so token and message deltas do not create false self-loops. + +The preview is read-only and bounded by the retained event window. It does not yet infer parent, subagent, delegation, approval, retry, or cross-agent causal edges. See `docs/graphs-and-loops.md`. ## How it works 1) Scan OS process list for Codex + OpenCode + Claude Code. @@ -59,7 +80,8 @@ consensus dev server running on http://127.0.0.1:8787 3) Query the OpenCode local server API and event stream (with storage fallback). 4) Ingest Claude Code hook events to infer activity (CLI flags only for "doing"). 5) Poll and push snapshots over WebSocket. -6) Render tiles on a canvas with isometric projection. +6) Derive a provider-neutral phase graph and detect cycles from retained snapshot events. +7) Render live agent tiles on a canvas with isometric projection. ## Install options - Local dev: `npm install` + `npm run dev` @@ -114,7 +136,7 @@ consensus dev server running on http://127.0.0.1:8787 - `CONSENSUS_IDLE_HOLD_MS`: hold idle state briefly after spans end (default `200`). - `CONSENSUS_SPAN_STALE_MS`: span stale timeout for event progress (default `15000`). -Full config details: `docs/configuration.md` +Full config details: `docs/configuration.md` ## Claude hooks (required for activity) Claude Code hooks are configured in `~/.claude/settings.json`, `.claude/settings.json`, or @@ -182,6 +204,7 @@ Recommended events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `Permissio ## Utilities - `npm run scan` prints a one-shot JSON snapshot. +- `npm run graph` prints the observed execution graph and detected loops. - `npm run tail -- ` tails a session file. ## Tests @@ -202,6 +225,7 @@ More: `docs/troubleshooting.md` - `docs/install.md` - `docs/examples.md` - `docs/cli.md` +- `docs/graphs-and-loops.md` - `docs/decisions/` - `docs/audience.md` - `docs/promises.md` From a319e5aca650770685830da74aded0bcdc7b7b28 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 16:02:31 -0400 Subject: [PATCH 07/72] fix(graph): correct segment, state, and privacy semantics --- public/src/types.ts | 11 +- src/cli/graph.ts | 149 +++++++++++-- src/graph.ts | 422 +++++++++++++++++++++++++++++++----- src/graphLoops.ts | 16 +- src/graphTypes.ts | 27 ++- src/types.ts | 1 + tests/unit/graph.test.ts | 173 +++++++++++++-- tests/unit/graphCli.test.ts | 118 ++++++++++ 8 files changed, 811 insertions(+), 106 deletions(-) create mode 100644 tests/unit/graphCli.test.ts diff --git a/public/src/types.ts b/public/src/types.ts index 8e6ddf7..306368f 100644 --- a/public/src/types.ts +++ b/public/src/types.ts @@ -12,7 +12,10 @@ export interface AgentSummary { export interface AgentEvent { ts: number; + type?: string; summary: string; + isError?: boolean; + turnId?: string | number; } export interface AgentSnapshot { @@ -118,10 +121,10 @@ export interface WsPongMessage { } export type WsClientMessage = WsHelloMessage | WsPongMessage; -export type WsServerMessage = - | WsWelcomeMessage - | WsSnapshotMessage - | WsDeltaMessage +export type WsServerMessage = + | WsWelcomeMessage + | WsSnapshotMessage + | WsDeltaMessage | WsPingMessage; export interface TileColors { diff --git a/src/cli/graph.ts b/src/cli/graph.ts index b965f99..ac573a2 100644 --- a/src/cli/graph.ts +++ b/src/cli/graph.ts @@ -1,13 +1,40 @@ -import { readFile } from "fs/promises"; +import { readFile } from "node:fs/promises"; import { buildAgentGraph, formatAgentGraph } from "../graph.js"; -import { scanCodexProcesses } from "../scan.js"; -import type { SnapshotPayload } from "../types.js"; +import type { + AgentKind, + AgentSnapshot, + AgentState, + EventSummary, + SnapshotPayload, +} from "../types.js"; + +type SnapshotScanner = (options?: { + mode?: "fast" | "full"; + includeActivity?: boolean; +}) => Promise; + +export type GraphScanLoader = () => Promise<{ + scanCodexProcesses: SnapshotScanner; +}>; + +const agentKinds = new Set([ + "tui", + "exec", + "app-server", + "opencode-tui", + "opencode-cli", + "opencode-server", + "claude-tui", + "claude-cli", + "unknown", +]); +const agentStates = new Set(["active", "idle", "error"]); function graphHelp(): string { return [ "consensus graph", "", - "Inspect live agent transitions as a graph and report detected loops.", + "Inspect observed agent transitions as a graph and report detected loops.", "", "Usage:", " consensus graph [--json] [--snapshot ]", @@ -21,6 +48,9 @@ function graphHelp(): string { } async function readStdin(): Promise { + if (process.stdin.isTTY) { + throw new Error("--snapshot - requires JSON piped on stdin"); + } let input = ""; for await (const chunk of process.stdin) { input += String(chunk); @@ -28,7 +58,73 @@ async function readStdin(): Promise { return input; } -function parseSnapshot(input: string, source: string): SnapshotPayload { +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function isOptionalTurnId(value: unknown): boolean { + return ( + value === undefined || + typeof value === "string" || + (typeof value === "number" && Number.isFinite(value)) + ); +} + +function isEventSummary(value: unknown): value is EventSummary { + if (!isRecord(value)) return false; + return ( + isFiniteNumber(value.ts) && + typeof value.type === "string" && + typeof value.summary === "string" && + (value.isError === undefined || typeof value.isError === "boolean") && + isOptionalTurnId(value.turnId) + ); +} + +function isAgentSnapshot(value: unknown): value is AgentSnapshot { + if (!isRecord(value)) return false; + if ( + typeof value.id !== "string" || + !isFiniteNumber(value.pid) || + typeof value.cmd !== "string" || + typeof value.cmdShort !== "string" || + typeof value.kind !== "string" || + !agentKinds.has(value.kind as AgentKind) || + typeof value.state !== "string" || + !agentStates.has(value.state as AgentState) || + !isFiniteNumber(value.cpu) || + !isFiniteNumber(value.mem) + ) { + return false; + } + if ( + !isOptionalString(value.identity) || + !isOptionalString(value.title) || + !isOptionalString(value.doing) || + !isOptionalString(value.sessionPath) || + !isOptionalString(value.repo) || + !isOptionalString(value.cwd) || + !isOptionalString(value.model) + ) { + return false; + } + if (value.events !== undefined) { + if (!Array.isArray(value.events) || !value.events.every(isEventSummary)) { + return false; + } + } + return true; +} + +export function parseSnapshot(input: string, source: string): SnapshotPayload { let parsed: unknown; try { parsed = JSON.parse(input); @@ -37,20 +133,45 @@ function parseSnapshot(input: string, source: string): SnapshotPayload { } if ( - !parsed || - typeof parsed !== "object" || - typeof (parsed as SnapshotPayload).ts !== "number" || - !Array.isArray((parsed as SnapshotPayload).agents) + !isRecord(parsed) || + !isFiniteNumber(parsed.ts) || + !Array.isArray(parsed.agents) || + !parsed.agents.every(isAgentSnapshot) ) { throw new Error(`invalid snapshot payload from ${source}`); } - return parsed as SnapshotPayload; + return parsed as unknown as SnapshotPayload; +} + +const defaultScanLoader: GraphScanLoader = async () => + (await import("../scan.js")) as { + scanCodexProcesses: SnapshotScanner; + }; + +async function loadLiveSnapshot( + loadScan: GraphScanLoader = defaultScanLoader +): Promise { + const previousAutostart = process.env.CONSENSUS_OPENCODE_AUTOSTART; + process.env.CONSENSUS_OPENCODE_AUTOSTART = "0"; + try { + const { scanCodexProcesses } = await loadScan(); + return await scanCodexProcesses({ mode: "full" }); + } finally { + if (previousAutostart === undefined) { + delete process.env.CONSENSUS_OPENCODE_AUTOSTART; + } else { + process.env.CONSENSUS_OPENCODE_AUTOSTART = previousAutostart; + } + } } -async function loadSnapshot(snapshotPath: string | undefined): Promise { +export async function loadSnapshot( + snapshotPath: string | undefined, + loadScan: GraphScanLoader = defaultScanLoader +): Promise { if (!snapshotPath) { - return scanCodexProcesses({ mode: "full" }); + return loadLiveSnapshot(loadScan); } const input = @@ -88,5 +209,7 @@ export async function runGraph(args: string[]): Promise { const snapshot = await loadSnapshot(snapshotPath); const graph = buildAgentGraph(snapshot); - process.stdout.write(json ? `${JSON.stringify(graph, null, 2)}\n` : formatAgentGraph(graph)); + process.stdout.write( + json ? `${JSON.stringify(graph, null, 2)}\n` : formatAgentGraph(graph) + ); } diff --git a/src/graph.ts b/src/graph.ts index cd72f47..3cdfb56 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { AgentKind, AgentSnapshot, @@ -7,23 +8,29 @@ import type { } from "./types.js"; import { detectGraphLoops } from "./graphLoops.js"; import type { + AgentGraphCoverage, AgentGraphEdge, AgentGraphInput, AgentGraphLoop, AgentGraphNode, + AgentGraphProviderCoverage, AgentGraphSnapshot, GraphEdgeKind, + GraphHistoryStatus, } from "./graphTypes.js"; export type { + AgentGraphCoverage, AgentGraphEdge, AgentGraphInput, AgentGraphLoop, AgentGraphNode, + AgentGraphProviderCoverage, AgentGraphSnapshot, AgentGraphStats, AgentGraphWindow, GraphEdgeKind, + GraphHistoryStatus, GraphLoopKind, GraphNodeKind, } from "./graphTypes.js"; @@ -45,16 +52,26 @@ function providerForKind(kind: AgentKind): string { return "codex"; } +function opaqueAgentKey(provider: string, identity: string): string { + const digest = createHash("sha256") + .update(provider) + .update("\0") + .update(identity) + .digest("hex") + .slice(0, 20); + return `${provider}:${digest}`; +} + function idPart(value: string): string { return encodeURIComponent(value); } -function agentNodeId(identity: string): string { - return `agent:${idPart(identity)}`; +function agentNodeId(agentKey: string): string { + return `agent:${agentKey}`; } -function stepNodeId(identity: string, phase: string): string { - return `step:${idPart(identity)}:${idPart(phase)}`; +function stepNodeId(agentKey: string, segment: number, phase: string): string { + return `step:${agentKey}:s${segment}:${idPart(phase)}`; } function edgeId(kind: GraphEdgeKind, source: string, target: string): string { @@ -66,34 +83,129 @@ function normalizedEventType(event: EventSummary): string { return type || "event"; } -function phaseForEvent(event: EventSummary): string { - const summary = event.summary?.trim().toLowerCase() || ""; +function normalizedSummary(event: EventSummary): string { + return event.summary?.trim().toLowerCase() || ""; +} + +function compactEventName(value: string): string { + return value.replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function isLifecycleOrMetaEvent(event: EventSummary): boolean { + const eventType = normalizedEventType(event).toLowerCase(); + const compact = compactEventName(eventType); + const summary = normalizedSummary(event); + + if ( + /^(thread|turn|response|run|session)\.(started|start|in_progress|running|completed|complete|failed|failure|errored|error|canceled|cancelled|aborted|interrupted|stopped|stop|idle|status|created|updated|ended|end)$/.test( + eventType + ) + ) { + return true; + } + + if ( + new Set([ + "agentturncomplete", + "sessionstart", + "sessionend", + "setup", + "stop", + "stopfailure", + "precompact", + "postcompact", + "notification", + "instructionsloaded", + "configchange", + "cwdchanged", + "filechanged", + "worktreecreate", + "worktreeremove", + "teammateidle", + "serverconnected", + "serverdisconnected", + "heartbeat", + "connected", + "ready", + "ping", + "pong", + "snapshot", + "history", + "tokencount", + ]).has(compact) + ) { + return true; + } + + return ( + summary.startsWith("event:") && + /(heartbeat|connected|ready|snapshot|history|token_count|compaction)/.test( + `${eventType} ${summary}` + ) + ); +} + +function phaseForEvent(event: EventSummary): string | undefined { + const summary = normalizedSummary(event); const eventType = normalizedEventType(event); const lowerType = eventType.toLowerCase(); + const compact = compactEventName(eventType); if (summary.startsWith("cmd:")) return "command"; if (summary.startsWith("edit:")) return "edit"; if (summary.startsWith("tool:")) return "tool"; if (summary.startsWith("prompt:")) return "prompt"; if (/file_(change|edit|write)|patch/.test(lowerType)) return "edit"; - if (/tool|function_call|mcp/.test(lowerType)) return "tool"; + if (/tool|function_call|mcp|permission|subagent|task/.test(lowerType)) { + return "tool"; + } if (/command|(?:^|[._-])exec(?:ute|ution)?(?:[._-]|$)/.test(lowerType)) { return "command"; } - if (/prompt|user_message/.test(lowerType)) return "prompt"; + if (/prompt|user_message/.test(lowerType) || compact === "userpromptsubmit") { + return "prompt"; + } if ( summary === "thinking" || summary === "message" || - /reasoning|assistant|agent_message|response/.test(lowerType) + compact === "messagedisplay" || + /reasoning|assistant|agent_message|message\.part\.updated|response\..*delta/.test( + lowerType + ) ) { return "model"; } + if (isLifecycleOrMetaEvent(event)) return undefined; if (summary && !summary.startsWith("event:") && !summary.startsWith("compaction:")) { return "model"; } return eventType; } +function isTurnStartEvent(event: EventSummary): boolean { + const type = normalizedEventType(event).toLowerCase(); + const compact = compactEventName(type); + return ( + /^(turn|run)\.(started|start)$/.test(type) || + compact === "userpromptsubmit" + ); +} + +function isTurnEndEvent(event: EventSummary): boolean { + const type = normalizedEventType(event).toLowerCase(); + const compact = compactEventName(type); + return ( + /^(turn|response|run)\.(completed|complete|failed|failure|errored|error|canceled|cancelled|aborted|interrupted|stopped|stop|ended|end)$/.test( + type + ) || + type === "session.idle" || + compact === "agentturncomplete" || + compact === "stop" || + compact === "stopfailure" || + compact === "sessionend" + ); +} + function maxDefined(values: Array): number | undefined { let max: number | undefined; for (const value of values) { @@ -166,6 +278,92 @@ function addTransitionEdge( }); } +function historyStatusForProvider( + provider: string, + agents: number, + agentsWithEvents: number +): { history: GraphHistoryStatus; note?: string } { + if (agentsWithEvents === agents && agents > 0) { + return { history: "available" }; + } + if (agentsWithEvents > 0) { + return { + history: "partial", + note: "Only some observed agents included retained events.", + }; + } + if (provider === "claude") { + return { + history: "unavailable", + note: + "Claude activity is visible, but Claude hook history is not yet retained in SnapshotPayload.events.", + }; + } + if (provider === "codex" || provider === "opencode") { + return { + history: "available", + note: "No retained graph events were present in this snapshot window.", + }; + } + return { + history: "unavailable", + note: "This provider does not expose retained graph events in the current adapter.", + }; +} + +function normalizeCoverage( + coverage: Map< + string, + { agents: number; agentsWithEvents: number; retainedEvents: number } + > +): AgentGraphCoverage { + const providers: Record = {}; + for (const provider of [...coverage.keys()].sort()) { + const current = coverage.get(provider); + if (!current) continue; + const status = historyStatusForProvider( + provider, + current.agents, + current.agentsWithEvents + ); + providers[provider] = { + ...current, + ...status, + }; + } + return { providers }; +} + +function deriveCoverageFromNodes(nodes: AgentGraphNode[]): AgentGraphCoverage { + const coverage = new Map< + string, + { agents: number; agentsWithEvents: number; retainedEvents: number } + >(); + const agentKeysByProvider = new Map>(); + const eventAgentsByProvider = new Map>(); + for (const node of nodes) { + const agentSet = agentKeysByProvider.get(node.provider) ?? new Set(); + agentSet.add(node.agentKey); + agentKeysByProvider.set(node.provider, agentSet); + if (node.kind === "step") { + const eventSet = + eventAgentsByProvider.get(node.provider) ?? new Set(); + eventSet.add(node.agentKey); + eventAgentsByProvider.set(node.provider, eventSet); + } + } + for (const [provider, agentKeys] of agentKeysByProvider) { + coverage.set(provider, { + agents: agentKeys.size, + agentsWithEvents: eventAgentsByProvider.get(provider)?.size ?? 0, + retainedEvents: nodes + .filter((node) => node.provider === provider && node.kind === "step") + .reduce((total, node) => total + (node.observations ?? 0), 0), + }); + } + return normalizeCoverage(coverage); +} + export function analyzeAgentGraph(input: AgentGraphInput): AgentGraphSnapshot { const nodes = [...input.nodes].sort((a, b) => a.id.localeCompare(b.id)); const edges = [...input.edges].sort((a, b) => a.id.localeCompare(b.id)); @@ -177,6 +375,7 @@ export function analyzeAgentGraph(input: AgentGraphInput): AgentGraphSnapshot { source: input.source, ts: input.ts, window: input.window, + coverage: input.coverage ?? deriveCoverageFromNodes(nodes), nodes, edges, loops, @@ -200,6 +399,11 @@ export function buildAgentGraph(snapshot: SnapshotPayload): AgentGraphSnapshot { const nodes = new Map(); const edges = new Map(); const retainedEventTimes: number[] = []; + let graphEvents = 0; + const coverage = new Map< + string, + { agents: number; agentsWithEvents: number; retainedEvents: number } + >(); const agents = [...snapshot.agents].sort((a, b) => identityForAgent(a).localeCompare(identityForAgent(b)) ); @@ -207,14 +411,25 @@ export function buildAgentGraph(snapshot: SnapshotPayload): AgentGraphSnapshot { for (const agent of agents) { const identity = identityForAgent(agent); const provider = providerForKind(agent.kind); - const rootNodeId = agentNodeId(identity); + const agentKey = opaqueAgentKey(provider, identity); + const rootNodeId = agentNodeId(agentKey); + const events = sortedEvents(agent.events); + const providerCoverage = coverage.get(provider) ?? { + agents: 0, + agentsWithEvents: 0, + retainedEvents: 0, + }; + providerCoverage.agents += 1; + providerCoverage.retainedEvents += events.length; + if (events.length > 0) providerCoverage.agentsWithEvents += 1; + coverage.set(provider, providerCoverage); + nodes.set(rootNodeId, { id: rootNodeId, kind: "agent", - label: agent.title || agent.doing || `${provider}:${agent.id}`, + label: agent.title || agent.doing || `${provider} agent`, state: agent.state, - agentId: agent.id, - agentIdentity: identity, + agentKey, provider, repo: agent.repo, firstSeenAt: @@ -223,45 +438,104 @@ export function buildAgentGraph(snapshot: SnapshotPayload): AgentGraphSnapshot { observations: 1, }); + let segmentCounter = 0; + let activeSegment: number | undefined; + let activeTurnId: string | undefined; let previousStepNodeId: string | undefined; - for (const event of sortedEvents(agent.events)) { + let segmentHasPhase = false; + let latestStepNodeId: string | undefined; + + const startSegment = (turnId?: string): number => { + segmentCounter += 1; + activeSegment = segmentCounter; + activeTurnId = turnId; + previousStepNodeId = undefined; + segmentHasPhase = false; + return activeSegment; + }; + + const closeSegment = (): void => { + activeSegment = undefined; + activeTurnId = undefined; + previousStepNodeId = undefined; + segmentHasPhase = false; + }; + + for (const event of events) { retainedEventTimes.push(event.ts); - const eventType = normalizedEventType(event); const phase = phaseForEvent(event); - const currentStepNodeId = stepNodeId(identity, phase); - const current = nodes.get(currentStepNodeId); - - if (current) { - current.observations = (current.observations ?? 0) + 1; - current.firstSeenAt = Math.min(current.firstSeenAt ?? event.ts, event.ts); - current.lastSeenAt = Math.max(current.lastSeenAt ?? event.ts, event.ts); - current.eventTypes = Array.from( - new Set([...(current.eventTypes ?? []), eventType]) - ).sort(); - if (event.isError) current.state = "error"; - } else { - nodes.set(currentStepNodeId, { - id: currentStepNodeId, - kind: "step", - label: phase, - state: event.isError ? "error" : agent.state, - agentId: agent.id, - agentIdentity: identity, - provider, - repo: agent.repo, - phase, - eventTypes: [eventType], - firstSeenAt: event.ts, - lastSeenAt: event.ts, - observations: 1, - }); + const turnId = + typeof event.turnId === "number" + ? String(event.turnId) + : event.turnId?.trim() || undefined; + const turnStart = isTurnStartEvent(event); + const turnEnd = isTurnEndEvent(event); + + if (turnId) { + if (activeSegment === undefined || activeTurnId !== turnId) { + startSegment(turnId); + } + } else if ( + phase === "prompt" && + (activeSegment === undefined || segmentHasPhase) + ) { + startSegment(); + } else if (turnStart && activeSegment === undefined) { + startSegment(); + } else if (phase && activeSegment === undefined) { + startSegment(); } - addContainsEdge(edges, rootNodeId, currentStepNodeId, event.ts); - if (previousStepNodeId && previousStepNodeId !== currentStepNodeId) { - addTransitionEdge(edges, previousStepNodeId, currentStepNodeId, event.ts); + if (phase && activeSegment !== undefined) { + graphEvents += 1; + const currentStepNodeId = stepNodeId(agentKey, activeSegment, phase); + const current = nodes.get(currentStepNodeId); + + if (current) { + current.observations = (current.observations ?? 0) + 1; + current.firstSeenAt = Math.min(current.firstSeenAt ?? event.ts, event.ts); + current.lastSeenAt = Math.max(current.lastSeenAt ?? event.ts, event.ts); + current.eventTypes = Array.from( + new Set([...(current.eventTypes ?? []), normalizedEventType(event)]) + ).sort(); + if (event.isError) current.hadError = true; + } else { + nodes.set(currentStepNodeId, { + id: currentStepNodeId, + kind: "step", + label: phase, + state: "idle", + agentKey, + provider, + repo: agent.repo, + phase, + segment: activeSegment, + hadError: !!event.isError, + eventTypes: [normalizedEventType(event)], + firstSeenAt: event.ts, + lastSeenAt: event.ts, + observations: 1, + }); + } + + addContainsEdge(edges, rootNodeId, currentStepNodeId, event.ts); + if (previousStepNodeId && previousStepNodeId !== currentStepNodeId) { + addTransitionEdge(edges, previousStepNodeId, currentStepNodeId, event.ts); + } + previousStepNodeId = currentStepNodeId; + latestStepNodeId = currentStepNodeId; + segmentHasPhase = true; + } + + if (turnEnd) closeSegment(); + } + + if (latestStepNodeId) { + const latest = nodes.get(latestStepNodeId); + if (latest) { + latest.current = true; + latest.state = agent.state; } - previousStepNodeId = currentStepNodeId; } } @@ -270,9 +544,11 @@ export function buildAgentGraph(snapshot: SnapshotPayload): AgentGraphSnapshot { ts: snapshot.ts, window: { retainedEvents: retainedEventTimes.length, + graphEvents, oldestEventAt: minDefined(retainedEventTimes), newestEventAt: maxDefined(retainedEventTimes), }, + coverage: normalizeCoverage(coverage), nodes: Array.from(nodes.values()), edges: Array.from(edges.values()), }); @@ -282,13 +558,24 @@ function graphStateLabel(state: AgentState): string { return state.toUpperCase().padEnd(6, " "); } -function formatLoop(loop: AgentGraphLoop, nodeById: Map): string { +function formatLoop( + loop: AgentGraphLoop, + nodeById: Map +): string { const labels = loop.nodeIds.map( (nodeId) => nodeById.get(nodeId)?.label || nodeId ); - if (loop.kind === "self") return `${labels[0]} -> ${labels[0]}`; - if (labels.length === 2) return `${labels[0]} -> ${labels[1]} -> ${labels[0]}`; - return `cycle{${labels.join(", ")}}`; + const path = + loop.kind === "self" + ? `${labels[0]} -> ${labels[0]}` + : labels.length === 2 + ? `${labels[0]} -> ${labels[1]} -> ${labels[0]}` + : `cycle{${labels.join(", ")}}`; + const segmentLabel = + loop.segments.length === 1 + ? `segment=${loop.segments[0]}` + : `segments=${loop.segments.join(",")}`; + return `${path} ${segmentLabel}`; } export function formatAgentGraph(graph: AgentGraphSnapshot): string { @@ -300,15 +587,15 @@ export function formatAgentGraph(graph: AgentGraphSnapshot): string { for (const node of graph.nodes) { if (node.kind !== "step") continue; stepCountByAgent.set( - node.agentIdentity, - (stepCountByAgent.get(node.agentIdentity) ?? 0) + 1 + node.agentKey, + (stepCountByAgent.get(node.agentKey) ?? 0) + 1 ); } for (const loop of graph.loops) { - for (const agentIdentity of loop.agentIds) { + for (const agentKey of loop.agentKeys) { loopCountByAgent.set( - agentIdentity, - (loopCountByAgent.get(agentIdentity) ?? 0) + 1 + agentKey, + (loopCountByAgent.get(agentKey) ?? 0) + 1 ); } } @@ -317,17 +604,32 @@ export function formatAgentGraph(graph: AgentGraphSnapshot): string { "consensus graph", `agents=${graph.stats.agents} steps=${graph.stats.steps} ` + `transition_edges=${graph.stats.transitionEdges} transitions=${graph.stats.transitions} ` + - `loops=${graph.stats.loops} window_events=${graph.window.retainedEvents}`, + `loops=${graph.stats.loops} window_events=${graph.window.retainedEvents} ` + + `graph_events=${graph.window.graphEvents}`, "", - "AGENTS", + "COVERAGE", ]; + const coverageEntries = Object.entries(graph.coverage.providers); + if (coverageEntries.length === 0) { + lines.push(" none observed"); + } else { + for (const [provider, coverage] of coverageEntries) { + const suffix = coverage.note ? ` — ${coverage.note}` : ""; + lines.push( + ` ${provider} history=${coverage.history} agents=${coverage.agents} ` + + `agents_with_events=${coverage.agentsWithEvents} events=${coverage.retainedEvents}${suffix}` + ); + } + } + + lines.push("", "AGENTS"); if (agentNodes.length === 0) { lines.push(" none observed"); } else { for (const node of agentNodes) { - const steps = stepCountByAgent.get(node.agentIdentity) ?? 0; - const loops = loopCountByAgent.get(node.agentIdentity) ?? 0; + const steps = stepCountByAgent.get(node.agentKey) ?? 0; + const loops = loopCountByAgent.get(node.agentKey) ?? 0; lines.push( ` ${graphStateLabel(node.state)} ${node.label} ` + `[${node.provider}] steps=${steps} loops=${loops}` @@ -342,7 +644,7 @@ export function formatAgentGraph(graph: AgentGraphSnapshot): string { for (const loop of graph.loops) { lines.push( ` ${graphStateLabel(loop.state)} ${formatLoop(loop, nodeById)} ` + - `observations=${loop.observations}` + `transition_observations=${loop.transitionObservations}` ); } } diff --git a/src/graphLoops.ts b/src/graphLoops.ts index b10bb35..fb3421f 100644 --- a/src/graphLoops.ts +++ b/src/graphLoops.ts @@ -120,20 +120,28 @@ export function detectGraphLoops( const memberNodes = component .map((nodeId) => stepNodeById.get(nodeId)) .filter((node): node is AgentGraphNode => !!node); + const currentMemberNodes = memberNodes.filter((node) => node.current); loops.push({ id: `loop:${component.join("|")}`, kind: isSelfLoop ? "self" : "cycle", nodeIds: component, edgeIds: loopEdges.map((edge) => edge.id).sort(), - agentIds: Array.from( - new Set(memberNodes.map((node) => node.agentIdentity)) + agentKeys: Array.from( + new Set(memberNodes.map((node) => node.agentKey)) ).sort(), providers: Array.from( new Set(memberNodes.map((node) => node.provider)) ).sort(), - state: strongestState(memberNodes.map((node) => node.state)), - observations: loopEdges.reduce( + segments: Array.from( + new Set( + memberNodes + .map((node) => node.segment) + .filter((segment): segment is number => typeof segment === "number") + ) + ).sort((a, b) => a - b), + state: strongestState(currentMemberNodes.map((node) => node.state)), + transitionObservations: loopEdges.reduce( (total, edge) => total + edge.observations, 0 ), diff --git a/src/graphTypes.ts b/src/graphTypes.ts index 01900c8..b362903 100644 --- a/src/graphTypes.ts +++ b/src/graphTypes.ts @@ -3,17 +3,20 @@ import type { AgentState } from "./types.js"; export type GraphNodeKind = "agent" | "step"; export type GraphEdgeKind = "contains" | "transition"; export type GraphLoopKind = "self" | "cycle"; +export type GraphHistoryStatus = "available" | "partial" | "unavailable"; export interface AgentGraphNode { id: string; kind: GraphNodeKind; label: string; state: AgentState; - agentId: string; - agentIdentity: string; + agentKey: string; provider: string; repo?: string; phase?: string; + segment?: number; + current?: boolean; + hadError?: boolean; eventTypes?: string[]; firstSeenAt?: number; lastSeenAt?: number; @@ -34,19 +37,33 @@ export interface AgentGraphLoop { kind: GraphLoopKind; nodeIds: string[]; edgeIds: string[]; - agentIds: string[]; + agentKeys: string[]; providers: string[]; + segments: number[]; state: AgentState; - observations: number; + transitionObservations: number; lastSeenAt?: number; } export interface AgentGraphWindow { retainedEvents: number; + graphEvents: number; oldestEventAt?: number; newestEventAt?: number; } +export interface AgentGraphProviderCoverage { + agents: number; + agentsWithEvents: number; + retainedEvents: number; + history: GraphHistoryStatus; + note?: string; +} + +export interface AgentGraphCoverage { + providers: Record; +} + export interface AgentGraphStats { agents: number; steps: number; @@ -63,6 +80,7 @@ export interface AgentGraphSnapshot { source: "observed-events"; ts: number; window: AgentGraphWindow; + coverage: AgentGraphCoverage; nodes: AgentGraphNode[]; edges: AgentGraphEdge[]; loops: AgentGraphLoop[]; @@ -73,6 +91,7 @@ export interface AgentGraphInput { source: AgentGraphSnapshot["source"]; ts: number; window: AgentGraphWindow; + coverage?: AgentGraphCoverage; nodes: AgentGraphNode[]; edges: AgentGraphEdge[]; } diff --git a/src/types.ts b/src/types.ts index faa8ffc..e86fde3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface EventSummary { type: string; summary: string; isError?: boolean; + turnId?: string | number; } export interface WorkSummary { diff --git a/tests/unit/graph.test.ts b/tests/unit/graph.test.ts index e2adcf9..853758d 100644 --- a/tests/unit/graph.test.ts +++ b/tests/unit/graph.test.ts @@ -6,21 +6,34 @@ import { formatAgentGraph, type AgentGraphNode, } from "../../src/graph.js"; -import type { AgentSnapshot, EventSummary, SnapshotPayload } from "../../src/types.js"; +import type { + AgentKind, + AgentSnapshot, + EventSummary, + SnapshotPayload, +} from "../../src/types.js"; function event( ts: number, type: string, summary: string = type, - isError = false + isError = false, + turnId?: string ): EventSummary { - return { ts, type, summary, ...(isError ? { isError } : {}) }; + return { + ts, + type, + summary, + ...(isError ? { isError } : {}), + ...(turnId ? { turnId } : {}), + }; } function agent( events: EventSummary[], state: AgentSnapshot["state"] = "active", - identity = "codex:session-1" + identity = "codex:session-1", + kind: AgentKind = "tui" ): AgentSnapshot { const second = identity.endsWith("2"); return { @@ -28,9 +41,9 @@ function agent( id: second ? "102" : "101", pid: second ? 102 : 101, title: second ? "second worker" : "graph worker", - cmd: "codex", - cmdShort: "codex", - kind: "tui", + cmd: kind.startsWith("claude") ? "claude" : "codex", + cmdShort: kind.startsWith("claude") ? "claude" : "codex", + kind, cpu: 1, mem: 100, state, @@ -50,6 +63,8 @@ describe("agent graph", () => { assert.equal(graph.stats.steps, 0); assert.equal(graph.stats.loops, 0); assert.equal(graph.window.retainedEvents, 0); + assert.equal(graph.window.graphEvents, 0); + assert.equal(graph.coverage.providers.codex.history, "available"); }); it("normalizes provider events into model and tool phases", () => { @@ -67,7 +82,7 @@ describe("agent graph", () => { assert.equal(graph.stats.transitions, 3); assert.equal(graph.loops.length, 1); assert.equal(graph.loops[0].kind, "cycle"); - assert.equal(graph.loops[0].observations, 3); + assert.equal(graph.loops[0].transitionObservations, 3); assert.deepEqual( graph.nodes .filter((node) => node.kind === "step") @@ -104,19 +119,20 @@ describe("agent graph", () => { it("detects an explicit self edge", () => { const node: AgentGraphNode = { - id: "step:x:tool", + id: "step:codex:abc:s1:tool", kind: "step", label: "tool", state: "active", - agentId: "1", - agentIdentity: "x", + agentKey: "codex:abc", provider: "codex", phase: "tool", + segment: 1, + current: true, }; const graph = analyzeAgentGraph({ source: "observed-events", ts: 1, - window: { retainedEvents: 1 }, + window: { retainedEvents: 1, graphEvents: 1 }, nodes: [node], edges: [ { @@ -131,7 +147,7 @@ describe("agent graph", () => { assert.equal(graph.loops.length, 1); assert.equal(graph.loops[0].kind, "self"); - assert.equal(graph.loops[0].observations, 2); + assert.equal(graph.loops[0].transitionObservations, 2); }); it("does not report a one-way phase chain as a loop", () => { @@ -147,7 +163,72 @@ describe("agent graph", () => { assert.equal(graph.stats.loops, 0); }); - it("isolates equal phase names by agent identity", () => { + it("does not connect independent turns into a false loop", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "turn.started", "event: turn.started", false, "turn-1"), + event(2, "user_message", "prompt: first", false, "turn-1"), + event(3, "response_item", "thinking", false, "turn-1"), + event(4, "turn.completed", "event: turn.completed", false, "turn-1"), + event(5, "turn.started", "event: turn.started", false, "turn-2"), + event(6, "user_message", "prompt: second", false, "turn-2"), + event(7, "response_item", "message", false, "turn-2"), + ]) + ); + + assert.equal(graph.stats.loops, 0); + assert.equal(graph.stats.steps, 4); + assert.equal(graph.stats.transitionEdges, 2); + assert.deepEqual( + graph.nodes + .filter((node) => node.kind === "step") + .map((node) => node.segment) + .sort(), + [1, 1, 2, 2] + ); + }); + + it("uses prompt boundaries when a provider does not expose turn ids", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "prompt", "prompt: first"), + event(2, "assistant", "message"), + event(3, "prompt", "prompt: second"), + event(4, "assistant", "message"), + ]) + ); + + assert.equal(graph.stats.loops, 0); + assert.equal(graph.stats.steps, 4); + assert.equal(graph.stats.transitionEdges, 2); + }); + + it("does not mark a completed historical loop active during a later phase", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "turn.started", "event: turn.started", false, "turn-1"), + event(2, "response_item", "thinking", false, "turn-1"), + event(3, "tool", "tool: shell", false, "turn-1"), + event(4, "response_item", "message", false, "turn-1"), + event(5, "turn.completed", "event: turn.completed", false, "turn-1"), + event(6, "turn.started", "event: turn.started", false, "turn-2"), + event(7, "prompt", "prompt: next", false, "turn-2"), + event(8, "file_edit", "edit: src/index.ts", false, "turn-2"), + ]) + ); + + assert.equal(graph.stats.loops, 1); + assert.equal(graph.loops[0].state, "idle"); + assert.equal(graph.stats.activeLoops, 0); + assert.deepEqual(graph.loops[0].segments, [1]); + const currentNode = graph.nodes.find( + (node) => node.kind === "step" && node.current + ); + assert.equal(currentNode?.phase, "edit"); + assert.equal(currentNode?.state, "active"); + }); + + it("isolates equal phase names by agent identity and turn", () => { const graph = buildAgentGraph({ ts: 1_000, agents: [ @@ -156,30 +237,78 @@ describe("agent graph", () => { event(2, "message", "message"), event(3, "tool", "tool: shell"), ]), - agent([event(1, "tool", "tool: shell")], "idle", "codex:session-2"), + agent( + [event(1, "tool", "tool: shell")], + "idle", + "codex:session-2" + ), ], }); assert.equal(graph.stats.agents, 2); assert.equal(graph.stats.steps, 3); assert.equal(graph.stats.loops, 1); - assert.deepEqual(graph.loops[0].agentIds, ["codex:session-1"]); + assert.equal(graph.loops[0].agentKeys.length, 1); }); - it("marks a loop as error when one phase has an error event", () => { + it("does not expose raw or URL-encoded session identities", () => { + const identity = + "/Users/alice/.codex/sessions/2026/07/18/rollout-secret.jsonl"; + const graph = buildAgentGraph({ + ts: 1_000, + agents: [agent([event(1, "assistant", "message")], "idle", identity)], + }); + const json = JSON.stringify(graph); + + assert.equal(json.includes(identity), false); + assert.equal(json.includes(encodeURIComponent(identity)), false); + const agentNode = graph.nodes.find((node) => node.kind === "agent"); + assert.match(agentNode?.agentKey ?? "", /^codex:[a-f0-9]{20}$/); + }); + + it("marks Claude graph history unavailable instead of claiming phase coverage", () => { + const graph = buildAgentGraph({ + ts: 1_000, + agents: [ + agent([], "active", "claude:session-1", "claude-tui"), + ], + }); + + assert.equal(graph.stats.agents, 1); + assert.equal(graph.stats.steps, 0); + assert.equal(graph.coverage.providers.claude.history, "unavailable"); + assert.match( + graph.coverage.providers.claude.note ?? "", + /hook history is not yet retained/i + ); + }); + + it("marks a current loop as error only when the current phase is in that loop", () => { const graph = buildAgentGraph( snapshot([ event(1, "event_msg", "thinking"), - event(2, "tool", "tool: shell", true), + event(2, "tool", "tool: shell"), event(3, "event_msg", "message"), ]) ); + const current = graph.nodes.find( + (node) => node.kind === "step" && node.current + ); + if (current) current.state = "error"; + const analyzed = analyzeAgentGraph({ + source: graph.source, + ts: graph.ts, + window: graph.window, + coverage: graph.coverage, + nodes: graph.nodes, + edges: graph.edges, + }); - assert.equal(graph.loops[0].state, "error"); - assert.equal(graph.stats.errorLoops, 1); + assert.equal(analyzed.loops[0].state, "error"); + assert.equal(analyzed.stats.errorLoops, 1); }); - it("renders a compact human-readable summary", () => { + it("renders coverage and explicit transition observation wording", () => { const graph = buildAgentGraph( snapshot([ event(1, "event_msg", "thinking"), @@ -191,6 +320,8 @@ describe("agent graph", () => { assert.match(output, /consensus graph/); assert.match(output, /agents=1 steps=2/); + assert.match(output, /COVERAGE/); assert.match(output, /model -> tool -> model/); + assert.match(output, /transition_observations=2/); }); }); diff --git a/tests/unit/graphCli.test.ts b/tests/unit/graphCli.test.ts new file mode 100644 index 0000000..a2f2905 --- /dev/null +++ b/tests/unit/graphCli.test.ts @@ -0,0 +1,118 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { loadSnapshot, parseSnapshot } from "../../src/cli/graph.js"; +import type { SnapshotPayload } from "../../src/types.js"; + +const validSnapshot: SnapshotPayload = { + ts: 1_000, + agents: [ + { + identity: "/Users/alice/.codex/sessions/secret.jsonl", + id: "101", + pid: 101, + cmd: "codex", + cmdShort: "codex", + kind: "tui", + cpu: 0, + mem: 100, + state: "idle", + events: [ + { + ts: 900, + type: "user_message", + summary: "prompt: test", + turnId: 7, + }, + ], + }, + ], +}; + +describe("graph CLI snapshot contract", () => { + it("accepts a valid snapshot and numeric turn ids", () => { + assert.deepEqual( + parseSnapshot(JSON.stringify(validSnapshot), "test"), + validSnapshot + ); + }); + + it("rejects invalid JSON", () => { + assert.throws( + () => parseSnapshot("{", "test"), + /invalid snapshot JSON from test/ + ); + }); + + it("rejects malformed agent records", () => { + const malformed = { + ...validSnapshot, + agents: [{ ...validSnapshot.agents[0], state: "running" }], + }; + assert.throws( + () => parseSnapshot(JSON.stringify(malformed), "test"), + /invalid snapshot payload from test/ + ); + }); + + it("rejects malformed retained events", () => { + const malformed = { + ...validSnapshot, + agents: [ + { + ...validSnapshot.agents[0], + events: [{ ts: "yesterday", type: "message", summary: "message" }], + }, + ], + }; + assert.throws( + () => parseSnapshot(JSON.stringify(malformed), "test"), + /invalid snapshot payload from test/ + ); + }); + + it("disables OpenCode autostart during live graph scans and restores the environment", async () => { + const previous = process.env.CONSENSUS_OPENCODE_AUTOSTART; + process.env.CONSENSUS_OPENCODE_AUTOSTART = "1"; + let valueDuringLoad: string | undefined; + let valueDuringScan: string | undefined; + + try { + const snapshot = await loadSnapshot(undefined, async () => { + valueDuringLoad = process.env.CONSENSUS_OPENCODE_AUTOSTART; + return { + scanCodexProcesses: async () => { + valueDuringScan = process.env.CONSENSUS_OPENCODE_AUTOSTART; + return validSnapshot; + }, + }; + }); + + assert.equal(snapshot, validSnapshot); + assert.equal(valueDuringLoad, "0"); + assert.equal(valueDuringScan, "0"); + assert.equal(process.env.CONSENSUS_OPENCODE_AUTOSTART, "1"); + } finally { + if (previous === undefined) { + delete process.env.CONSENSUS_OPENCODE_AUTOSTART; + } else { + process.env.CONSENSUS_OPENCODE_AUTOSTART = previous; + } + } + }); + + it("restores an unset autostart environment after a live scan", async () => { + const previous = process.env.CONSENSUS_OPENCODE_AUTOSTART; + delete process.env.CONSENSUS_OPENCODE_AUTOSTART; + + try { + await loadSnapshot(undefined, async () => ({ + scanCodexProcesses: async () => validSnapshot, + })); + assert.equal(process.env.CONSENSUS_OPENCODE_AUTOSTART, undefined); + } finally { + if (previous !== undefined) { + process.env.CONSENSUS_OPENCODE_AUTOSTART = previous; + } + } + }); +}); From a2541485c889754a2c113e647c4c225bb0482fbf Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 16:50:44 -0400 Subject: [PATCH 08/72] fix(graph): retain privacy-safe Claude hook history --- CHANGELOG.md | 83 ++++---- README.md | 327 +++++++++++------------------- ROADMAP.md | 34 ++-- docs/architecture.md | 44 +++-- docs/cli.md | 32 ++- docs/configuration.md | 270 +++++++++++-------------- docs/data-inventory.md | 70 +++++-- docs/graphs-and-loops.md | 144 +++++++++----- docs/threat-model.md | 81 ++++++-- package.json | 2 +- src/claude/types.ts | 9 + src/claudeHook.ts | 22 ++- src/claudeSnapshot.ts | 50 +++++ src/cli/graph.ts | 12 +- src/scanSnapshot.ts | 29 +++ src/services/claudeEventLog.ts | 140 +++++++++++++ src/services/claudeEventModel.ts | 330 +++++++++++++++++++++++++++++++ src/services/claudeEvents.ts | 158 +++++---------- tests/unit/claudeGraph.test.ts | 190 ++++++++++++++++++ 19 files changed, 1365 insertions(+), 662 deletions(-) create mode 100644 src/claudeSnapshot.ts create mode 100644 src/scanSnapshot.ts create mode 100644 src/services/claudeEventLog.ts create mode 100644 src/services/claudeEventModel.ts create mode 100644 tests/unit/claudeGraph.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bb73af..50eb6ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,67 +4,56 @@ All notable changes to this project will be documented in this file. This project follows Semantic Versioning. ## Unreleased -- Add: `consensus graph` builds an observed execution graph, counts phase transitions, and detects loops from retained session events. + +- Add: `consensus graph` builds a versioned, provider-neutral execution graph from retained events. +- Add: turn-scoped step nodes and lifecycle filtering prevent ordinary multi-turn sessions from appearing as loops. +- Add: bounded metadata-only Claude hook history for one-shot scan and graph inspection. +- Add: current Claude hook coverage for `MessageDisplay`, `PostToolBatch`, `PermissionDenied`, task events, and `StopFailure`. +- Fix: treat `UserPromptSubmit` as the sole retained Claude turn boundary so slash-command expansion does not create a duplicate turn. +- Fix: graph IDs use opaque agent keys instead of reversible raw session paths. +- Fix: historical loops no longer inherit an agent's unrelated current state. +- Fix: live graph inspection disables OpenCode server autostart. +- Fix: loop edge evidence is named `transitionObservations` rather than implied iteration count. +- Fix: graph snapshot input now rejects malformed agents, provider kinds, states, and events. - Fix: normalize OpenCode detection for mixed-case binary paths to keep servers in the correct lane. -- Fix: OpenCode activity now uses work-only timestamps (not heartbeat events) and decays in-flight after idle. +- Fix: OpenCode activity uses work-only timestamps and decays stale in-flight state. - Fix: Claude CLI prompts use a short pulse instead of sticking active indefinitely. -- Fix: prevent OpenCode “server” misclassification when prompts include “server” text (tokenized subcommand parsing). -- Fix: reduce OpenCode server idle flicker with a higher CPU threshold for servers. -- Fix: avoid Codex in-flight flicker by clearing on explicit assistant completion (tool call tracking + assistant message end). -- Fix: remove short in-flight idle clearing by default (configurable via `CONSENSUS_CODEX_INFLIGHT_IDLE_MS`). -- Fix: stabilize Codex activation with prompt pulse + file-growth activity and longer event/hold defaults. -- Fix: Codex prompt pulse for instant activation without waiting on streaming events. -- Fix: ignore OpenCode helper processes without sessions to avoid false active tiles. -- Fix: reduce OpenCode active/hold defaults for faster idle transitions. -- Fix Codex session matching by using `session_meta` cwd when session IDs are missing. -- Add sustained CPU fallback for Codex active detection when log signals lag. -- Use OpenCode API/storage activity timestamps to reduce activation lag. -- Harden WebSocket override handling for UI tests. -- Switch to event-driven updates (Codex log watch + OpenCode SSE) with slow PID polling. -- Surface OpenCode API failures in the UI status line. -- Do not treat Codex prompts as activity (avoid false active state). -- Deduplicate by PID to avoid hiding live agents when session association drifts. -- Cache process/session scans for fast event-driven refreshes. -- Expand Codex in-flight detection for response created/delta events. -- Reduce Codex idle lag by shortening default active + hold windows. -- Clear Codex in-flight state when activity is stale. -- Fix: keep Codex in-flight active until timeout to prevent active/idle flicker mid-run. -- Parallelize Codex tail reads to reduce pickup lag. -- Lower Codex active windows for sub-second idle transitions. +- Fix: prevent OpenCode server misclassification when prompts include server text. +- Fix: reduce OpenCode server idle flicker with a higher CPU threshold. +- Fix: stabilize Codex in-flight activity across tool calls and assistant completion. +- Fix: improve Codex session matching, event-driven updates, and bounded caches. ## 0.1.6 - 2026-01-25 -- Treat Codex response items as activity only for assistant output/tool work (not user prompts). + +- Treat Codex response items as activity only for assistant output and tool work. - Reduce false active state by using activity timestamps instead of generic event timestamps. ## 0.1.5 - 2026-01-25 -- Add Claude Code process detection with prompt/resume parsing. -- Apply CLI-specific palettes (Codex/OpenCode/Claude Code) across tiles and lane items. -- Add Claude CLI parsing unit tests. -- Update README with Claude Code support. + +- Add Claude Code process detection with prompt and resume parsing. +- Apply provider palettes across tiles and lane items. +- Add Claude CLI parsing tests. ## 0.1.4 - 2026-01-24 -- Fix OpenCode event tracking build error (pid activity typing). + +- Fix OpenCode event tracking build error. ## 0.1.3 - 2026-01-24 -- Add OpenCode integration (API sessions, event stream, storage fallback). -- Autostart OpenCode server with opt-out and CLI flags. -- Split servers into a dedicated lane with distinct palette. -- Improve layout keys to prevent tile overlap. -- Add OpenCode unit/integration tests and configuration docs. + +- Add OpenCode sessions, event stream, and storage fallback. +- Add optional OpenCode server autostart. +- Split servers into a dedicated lane. ## 0.1.2 - 2026-01-24 -- Lower CPU threshold for active detection. -- Increase activity window defaults for long-running turns. -- Skip vendor codex helper processes to avoid duplicate tiles. -- Improve session mapping for active-state detection. + +- Improve activity thresholds and session mapping. +- Skip duplicate Codex vendor helper processes. ## 0.1.1 - 2026-01-24 -- Smooth active state to prevent animation flicker. -- Add `consensus-cli` binary alias so `npx consensus-cli` works. -- Extend active window to match Codex event cadence. + +- Smooth active-state rendering. +- Add the `consensus-cli` binary alias. ## 0.1.0 - 2026-01-24 -- Initial public release. -- Improve work summaries and recent events (latest-first, event-only fallback). -- Mark agents active based on recent events (not just CPU). -- License: Apache-2.0. + +- Initial public release under Apache-2.0. diff --git a/README.md b/README.md index 70a39ef..22d9967 100644 --- a/README.md +++ b/README.md @@ -7,263 +7,162 @@ Local-first observability for coding-agent graphs, loops, and live activity across Codex, OpenCode, and Claude Code. ## Status -Beta. Local-only, no hosted service. -## Who it's for -Developers running multiple Codex, OpenCode, or Claude Code sessions who need a clear view of live activity and the execution paths inside each run. +Beta. Local-only, with no hosted service. ## Core use cases -- Inspect observed model, tool, command, edit, and prompt transitions. -- Detect cycles in the retained event window. -- Track which agents are active right now. -- Spot errors or idle processes quickly. -- Inspect recent activity without digging through logs. -## Scope (non-goals) -- Does not start, stop, or manage processes. -- Does not connect to remote Codex, OpenCode, or Claude Code instances. -- No authentication or multi-user access. +- Inspect observed prompt, model, tool, command, and edit transitions. +- Detect cycles inside individual observed turns. +- Track active, idle, and error states across local coding agents. +- Inspect recent activity without reading raw provider logs. + +## Scope + +Consensus observes local runs. It does not start, stop, retry, route, approve, or otherwise control agent work. The default server can auto-start OpenCode for the live dashboard; the one-shot `consensus graph` command explicitly disables that side effect. ## Quickstart + ```bash npm install npm run dev ``` -The server prints the local URL (default `http://127.0.0.1:8787`). -Consensus reads local Codex CLI sessions and does not require API keys. -You just need Codex CLI installed and signed in (Pro subscription or team plan). -If OpenCode is installed, Consensus will auto-start its local server. -If Claude Code is installed, it will appear automatically (run `claude` once to sign in). -Claude activity tracking requires hooks (see "Claude hooks" below). -`npm run dev` also keeps `dist/claudeHook.js` up to date so hooks can point at the compiled entry during development. +The server prints a local URL, normally `http://127.0.0.1:8787`. + +Run the published CLI: -## Run via npx ```bash npx consensus-cli ``` -Expected output: +Consensus reads local Codex sessions and does not require an API key. OpenCode and Claude Code support depend on their local server and hook interfaces. + +## Graphs and loops + +Inspect current local sessions: + +```bash +npx consensus-cli graph ``` -consensus dev server running on http://127.0.0.1:8787 + +Print the versioned JSON graph: + +```bash +npx consensus-cli graph --json ``` -## What you get -- One tile per running `codex`, `opencode`, or `claude` process. -- Activity state (active/idle/error) from CPU and recent events. -- Best-effort "doing" summary from Codex session JSONL, OpenCode events, or Claude CLI flags. -- Click a tile for details and recent events. -- Active lane for agents plus a dedicated lane for servers. -- A versioned observed execution graph with transition counts and detected loops. +Analyze a saved snapshot: -## Graphs and loops (preview) +```bash +npm run scan > snapshot.json +npx consensus-cli graph --snapshot snapshot.json +``` -Run a one-shot graph inspection against live local sessions: +Read a snapshot from standard input: ```bash -npx consensus-cli graph +cat snapshot.json | npx consensus-cli graph --snapshot - ``` -Print the full graph payload for scripts, storage, or later visualization: +The graph model: + +- Creates one opaque agent key per observed process or session. +- Splits step nodes by observed turn so separate prompts cannot create a false cycle. +- Filters lifecycle and transport events such as `turn.completed`, `session.status`, heartbeat, and token-count records. +- Collapses adjacent fragments of the same phase. +- Detects strongly connected components only within a turn segment. +- Applies live state only to the latest observed step. Historical loops remain idle. +- Reports `transitionObservations`, which counts internal transition evidence rather than claiming completed loop iterations. + +Raw session paths are not included in graph IDs. See [`docs/graphs-and-loops.md`](docs/graphs-and-loops.md) for the full contract. + +## Provider data flow + +1. Scan local Codex, OpenCode, and Claude Code processes. +2. Read bounded Codex JSONL summaries and notify events. +3. Read OpenCode sessions and SSE events from the local server. +4. Receive Claude Code hooks and retain bounded metadata-only event history. +5. Build live snapshots and stream them to the browser. +6. Derive per-turn execution graphs and detected cycles. + +### Codex + +`consensus setup` writes the notify command to the user-level `~/.codex/config.toml`. Current Codex documentation defines `notify` as a command array and notes that project-local config does not support it. ```bash -npx consensus-cli graph --json +npx consensus-cli setup ``` -Consensus normalizes retained provider events into `prompt`, `model`, `tool`, `command`, and `edit` phases. It creates transition edges for each observed phase change and reports cycles in that graph. Adjacent stream fragments collapse so token and message deltas do not create false self-loops. +### OpenCode -The preview is read-only and bounded by the retained event window. It does not yet infer parent, subagent, delegation, approval, retry, or cross-agent causal edges. See `docs/graphs-and-loops.md`. +Consensus reads the local OpenCode HTTP server and its SSE event streams. The dashboard may auto-start `opencode serve` unless `CONSENSUS_OPENCODE_AUTOSTART=0`. The graph command never auto-starts it. -## How it works -1) Scan OS process list for Codex + OpenCode + Claude Code. -2) Resolve Codex session JSONL under `CODEX_HOME/sessions/`. -3) Query the OpenCode local server API and event stream (with storage fallback). -4) Ingest Claude Code hook events to infer activity (CLI flags only for "doing"). -5) Poll and push snapshots over WebSocket. -6) Derive a provider-neutral phase graph and detect cycles from retained snapshot events. -7) Render live agent tiles on a canvas with isometric projection. +Current OpenCode server docs also expose `parentID` and `/session/:id/children`; those relationships are reserved for a later explicit parent/subagent graph edge rather than inferred in version 1. -## Install options -- Local dev: `npm install` + `npm run dev` -- npx: `npx consensus-cli` -- Docker: not available yet -- Hosted: planned (opt-in aggregation later) +### Claude Code -## Configuration -- `CONSENSUS_HOST`: bind address (default `127.0.0.1`). -- `CONSENSUS_PORT`: server port (default `8787`). -- `CONSENSUS_POLL_MS`: process presence polling interval in ms (default `500`). -- `CONSENSUS_SCAN_TIMEOUT_MS`: scan timeout in ms (default `5000`). -- `CONSENSUS_SCAN_STALL_MS`: scan stall warning threshold in ms (default `60%` of timeout, min `250`). -- `CONSENSUS_SCAN_STALL_CHECK_MS`: scan stall check interval in ms (default `min(1000, stallMs)`, min `250`). -- `CONSENSUS_CODEX_HOME`: override Codex home (default `~/.codex`). -- `CONSENSUS_CODEX_NOTIFY_INSTALL`: optional path to install a Codex `notify` hook via `codex config set -g notify=[""]` (set to `0`/`false` to disable auto-install). -- `CONSENSUS_OPENCODE_HOST`: OpenCode server host (default `127.0.0.1`). -- `CONSENSUS_OPENCODE_PORT`: OpenCode server port (default `4096`). -- `CONSENSUS_OPENCODE_TIMEOUT_MS`: OpenCode request timeout in ms (default `5000`). -- `CONSENSUS_OPENCODE_AUTOSTART`: set to `0` to disable OpenCode autostart. -- `CONSENSUS_OPENCODE_EVENTS`: set to `0` to disable OpenCode event stream. -- `CONSENSUS_OPENCODE_HOME`: override OpenCode storage (default `~/.local/share/opencode`). -- `CONSENSUS_OPENCODE_EVENT_ACTIVE_MS`: OpenCode active window after last event in ms (default `0`). -- `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS`: OpenCode hold window in ms (default `3000`). -- `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS`: OpenCode in-flight idle timeout in ms (defaults to `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`). -- `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`: OpenCode hard in-flight timeout in ms (default `15000`). -- `CONSENSUS_PROCESS_MATCH`: regex to match codex processes. -- `CONSENSUS_REDACT_PII`: set to `0` to disable redaction (default enabled). -- `CONSENSUS_UI_PORT`: dev UI port for Vite when running `npm run dev` (default `5173`). -- `CONSENSUS_DEBUG_OPENCODE`: set to `1` to log OpenCode server discovery. -- `CONSENSUS_CODEX_EVENT_ACTIVE_MS`: Codex active window after last event in ms (default `30000`). -- `CONSENSUS_CODEX_ACTIVE_HOLD_MS`: Codex hold window in ms (default `3000`). -- `CONSENSUS_CODEX_INFLIGHT_IDLE_MS`: Codex in-flight idle timeout in ms (default `30000`, set to `0` to disable). -- `CONSENSUS_CODEX_CPU_SUSTAIN_MS`: sustained CPU window before Codex becomes active without logs (default `500`). -- `CONSENSUS_CODEX_CPU_SPIKE`: Codex CPU spike threshold for immediate activation (default derived). -- `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`: Codex in-flight timeout in ms (default `3000`). -- `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS`: Codex max event age for in-flight signals (default `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`). -- `CONSENSUS_PROCESS_CACHE_MS`: process cache TTL in ms for full scans (default `1000`). -- `CONSENSUS_PROCESS_CACHE_FAST_MS`: process cache TTL in ms for fast scans (default `500`). -- `CONSENSUS_SESSION_CACHE_MS`: Codex session list cache TTL in ms for full scans (default `1000`). -- `CONSENSUS_SESSION_CACHE_FAST_MS`: Codex session list cache TTL in ms for fast scans (default `500`). -- `CONSENSUS_EVENT_ACTIVE_MS`: active window after last event in ms (default `300000`). -- `CONSENSUS_CPU_ACTIVE`: CPU threshold for active state (default `1`). -- `CONSENSUS_CLAUDE_CPU_ACTIVE`: Claude CPU threshold override (default `1`). -- `CONSENSUS_CLAUDE_CPU_SUSTAIN_MS`: Claude sustained CPU window in ms (default `1000`). -- `CONSENSUS_CLAUDE_CPU_SPIKE`: Claude spike threshold override (default derived). -- Claude activity uses hooks; CPU settings are legacy and ignored for TUI activity. -- `CONSENSUS_CLAUDE_EVENT_TTL_MS`: Claude hook event TTL in ms (default `1800000`). -- `CONSENSUS_CLAUDE_INFLIGHT_TIMEOUT_MS`: Claude in-flight timeout if no hook events (default `15000`). -- `CONSENSUS_CLAUDE_ACTIVE_HOLD_MS`: Claude hold window in ms (default `3000`). -- `CONSENSUS_ACTIVE_HOLD_MS`: keep active state this long after activity (default `3000`). -- `CONSENSUS_IDLE_HOLD_MS`: hold idle state briefly after spans end (default `200`). -- `CONSENSUS_SPAN_STALE_MS`: span stale timeout for event progress (default `15000`). - -Full config details: `docs/configuration.md` - -## Claude hooks (required for activity) -Claude Code hooks are configured in `~/.claude/settings.json`, `.claude/settings.json`, or -`.claude/settings.local.json`. -Consensus ignores Claude `statusLine`; hooks are the sole Claude activity signal. -Hook handler source lives in `src/claudeHook.ts` (Effect) and is compiled to `dist/claudeHook.js`. - -Example (repeat the command for the events you want to track): -```json -{ - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event" - } - ] - } - ], - "SessionEnd": [ - { - "hooks": [ - { - "type": "command", - "command": "node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event" - } - ] - } - ] - } -} +Claude activity and graph history require hooks. Point each selected event at: + +```text +node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event ``` -Notes: -- Tool-related hooks (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`) require a `matcher`. Use `"*"` to capture all tools. -- Claude hooks send JSON via stdin; `dist/claudeHook.js` expects `hook_event_name` and `session_id`. -- Dev (dynamic TS): use `node --import tsx /path/to/consensus-cli/src/claudeHook.ts http://127.0.0.1:8787/api/claude-event` so hook changes apply without rebuilds. +Recommended graph events: + +- Events that do not support matchers: `UserPromptSubmit`, `MessageDisplay`, `PostToolBatch`, `TaskCreated`, `TaskCompleted`, and `Stop`. +- Configure `StopFailure` and `SessionEnd` without a matcher for full coverage, or use their supported matcher fields when narrowing coverage. +- Use matcher `"*"` for broad coverage of `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied`, `SubagentStart`, and `SubagentStop`. + +Claude Code can emit several `MessageDisplay` calls for one assistant message. Consensus treats partial batches as activity but retains only the final batch marker. It does not retain the message delta. + +`UserPromptSubmit` is the canonical turn boundary. `UserPromptExpansion` can still update live activity, but Consensus does not retain it as a second prompt node because direct slash-command expansion occurs inside the submitted turn. + +The local Claude metadata log stores event type, session ID, timestamp, an opaque working-directory key, and small hook labels such as tool or agent type. It does not store prompts, assistant text, message deltas, tool inputs, tool outputs, transcript paths, task descriptions, or error details. Disable it with `CONSENSUS_CLAUDE_EVENT_LOG=0`. + +## Configuration + +The main settings are: + +- `CONSENSUS_HOST` — bind address, default `127.0.0.1`. +- `CONSENSUS_PORT` — HTTP port, default `8787`. +- `CONSENSUS_POLL_MS` — process scan interval. +- `CONSENSUS_CODEX_HOME` — Codex home directory. +- `CONSENSUS_OPENCODE_HOST` / `CONSENSUS_OPENCODE_PORT` — OpenCode server address. +- `CONSENSUS_OPENCODE_AUTOSTART=0` — disable dashboard autostart. +- `CONSENSUS_REDACT_PII=0` — disable normal text redaction. Opaque graph IDs remain opaque. +- `CONSENSUS_CLAUDE_EVENT_LOG` — Claude metadata log path, or `0` to disable. +- `CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES` — bounded log size, default 1 MiB. -Recommended events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, -`PostToolUse`, `PostToolUseFailure`, `Stop`, `SubagentStart`, `SubagentStop`, `SessionEnd`, -`Notification`. +See [`docs/configuration.md`](docs/configuration.md) for the complete list. ## Utilities -- `npm run scan` prints a one-shot JSON snapshot. -- `npm run graph` prints the observed execution graph and detected loops. -- `npm run tail -- ` tails a session file. + +```bash +npm run scan +npm run graph +npm run tail -- +``` ## Tests + ```bash npm run test +npm run test:ui +npm run build ``` -## Troubleshooting -- Port conflict on 8787: set `CONSENSUS_PORT=8790`. -- If the browser cannot connect, try `http://[::1]:` or set `CONSENSUS_HOST=127.0.0.1`. -- If "doing" is empty, the session log may not be resolvable yet. +## Documentation -More: `docs/troubleshooting.md` +- [`docs/architecture.md`](docs/architecture.md) +- [`docs/configuration.md`](docs/configuration.md) +- [`docs/cli.md`](docs/cli.md) +- [`docs/graphs-and-loops.md`](docs/graphs-and-loops.md) +- [`docs/data-inventory.md`](docs/data-inventory.md) +- [`docs/threat-model.md`](docs/threat-model.md) +- [`docs/testing.md`](docs/testing.md) +- [`docs/troubleshooting.md`](docs/troubleshooting.md) -## Documentation -- `docs/architecture.md` -- `docs/configuration.md` -- `docs/install.md` -- `docs/examples.md` -- `docs/cli.md` -- `docs/graphs-and-loops.md` -- `docs/decisions/` -- `docs/audience.md` -- `docs/promises.md` -- `docs/problem.md` -- `docs/data-inventory.md` -- `docs/threat-model.md` -- `docs/constitution.md` -- `docs/testing.md` -- `docs/release.md` -- `docs/troubleshooting.md` - -## Contributing -See `CONTRIBUTING.md`. - -## Security -See `SECURITY.md`. - -## Governance -See `GOVERNANCE.md`. - -## Roadmap -See `ROADMAP.md`. - -## Support -See `SUPPORT.md`. - -## License and trademark -Apache-2.0 License. See `LICENSE`. - -"consensus" is a project name used by the maintainer. Please do not imply -endorsement or use logos without permission. - -## Open source note -Development happens in the open via issues and pull requests. - -## Why open source -This project is meant to be forked, remixed, and adapted to your local workflows. - -## Hosted vision -The OSS version stays local-first. A future hosted service could optionally -aggregate agents across machines with a unified web dashboard. +## License + +Apache-2.0. See [`LICENSE`](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index 6fcc72b..fdf7fdd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,29 +2,33 @@ ## Current direction: graphs and loops -Consensus is moving from a process atlas toward a local-first view of agent execution graphs and loops. The product remains read-only: it observes runs but does not control agent processes. +Consensus is moving from a process atlas toward a local-first view of coding-agent execution graphs. It remains an observability product, not an orchestrator. ### Now -- Build a provider-neutral execution graph from retained Codex, OpenCode, and Claude Code events. -- Expose graph nodes, transition counts, and detected cycles through `consensus graph`. -- Keep graph output versioned and explicit about its observed event window. +- Produce privacy-safe, turn-scoped graphs for Codex, OpenCode, and Claude Code. +- Keep loop state and counts semantically exact. +- Maintain bounded provider metadata and clear data-retention controls. +- Get full unit, integration, UI, and build checks green on supported platforms. ### Next -- Add parent, subagent, delegation, approval, retry, and handoff edges when providers expose them. -- Stream graph deltas through the server and render graph structure in the isometric UI. -- Add loop health signals: duration, retry count, stop-rule status, budget use, and stuck-loop warnings. -- Compare declared workflow graphs with the task graph created at runtime. +- Add explicit parent and child edges where provider contracts expose them. + - OpenCode currently exposes `parentID` and `/session/:id/children`. + - Claude hooks expose subagent and task lifecycle events. +- Stream graph deltas through the server. +- Render turn segments, edges, and loop state in the browser. +- Add traversal-aware loop iterations, duration, stop-rule status, and stuck-loop warnings. ### Later -- Compact mini-map and graph grouping controls. -- Multi-device aggregation through an optional cloud relay with one graph and timeline. -- Run comparison, graph replay, and export for audit and evaluation. -- Basic performance profiling and render budget notes. +- Compare declared workflow graphs with runtime-created task graphs. +- Add graph replay and run comparison. +- Add an optional multi-device relay while keeping local-only mode complete. +- Add compact graph grouping and performance profiling. -## Existing reliability work +## Reliability work -- Improve session-to-process matching with PID metadata when available. -- Keep provider activity parsing stable across Codex, OpenCode, and Claude Code. +- Improve process-to-session matching when providers expose stable metadata. +- Keep activity parsing stable across provider releases. +- Maintain Windows and shell-portable test and setup paths. diff --git a/docs/architecture.md b/docs/architecture.md index b6fb85c..d7c4875 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,13 +1,35 @@ # Architecture -## Data flow -1) `scan.ts` enumerates OS processes and collects CPU/memory stats. -2) `codexLogs.ts` scans `CODEX_HOME/sessions/` for recent JSONL logs. -3) `server.ts` ingests Codex notify + Claude hook events into in-memory stores. -4) `server.ts` polls snapshots and pushes updates over WebSocket. -5) `public/src` renders the isometric map in a canvas (see `public/src/components/CanvasScene.tsx`). - -## Components -- Server: Express + ws, static assets, `/api/snapshot`, `/health`. -- Client: Canvas renderer with pan/zoom and a detail side panel. -- Log tailer: best-effort JSONL parser for "doing" summaries. +## Live data flow + +1. `scan.ts` enumerates Codex, OpenCode, and Claude Code processes. +2. `codexLogs.ts` reads bounded recent JSONL summaries. +3. `opencodeEvents.ts` consumes the local OpenCode SSE stream. +4. `server.ts` validates Codex notify and Claude hook payloads. +5. `services/claudeEvents.ts` keeps bounded hook summaries in memory and writes metadata-only cross-process history. +6. `server.ts` emits snapshots over WebSocket. +7. `public/src` renders the live isometric view. + +## One-shot scan and graph flow + +1. `scanSnapshot.ts` hydrates bounded Claude metadata from disk. +2. It runs the normal process scanner. +3. `claudeSnapshot.ts` attaches retained Claude hook summaries to matching Claude agents. +4. `cli/graph.ts` disables OpenCode autostart before dynamically importing the scan wrapper. +5. `graph.ts` builds opaque agent nodes and turn-scoped step nodes. +6. `graphLoops.ts` detects strongly connected components inside the resulting transition graph. + +## Main components + +- Express and `ws` local server. +- Provider adapters for Codex, OpenCode, and Claude Code. +- Event-driven activity state derivation with bounded caches. +- Versioned graph and loop analysis. +- React/Vite client with canvas rendering. + +## Design boundaries + +- Read-only graph inspection. +- No inferred cross-agent causality in version 1. +- No prompt, assistant-message, or tool-output persistence in Consensus-owned Claude metadata. +- No raw session paths in graph relationship IDs. diff --git a/docs/cli.md b/docs/cli.md index 1148e35..b8da9fb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -8,40 +8,38 @@ npx consensus-cli The server prints the local browser URL. Use `npx consensus-cli --help` for server flags. -## Configure hooks +## Configure Codex ```bash npx consensus-cli setup ``` -This configures the recommended Codex notify hook. +Setup writes the notify command to the user-level `~/.codex/config.toml`. It does not rely on project-local Codex config, where the current Codex contract does not support `notify`. ## Inspect graphs and loops -Build a provider-neutral execution graph from live local sessions: - ```bash npx consensus-cli graph +npx consensus-cli graph --json +npx consensus-cli graph --snapshot snapshot.json +cat snapshot.json | npx consensus-cli graph --snapshot - ``` -Print the full versioned graph payload: +Live graph inspection is read-only. It sets OpenCode autostart off before loading the scanner, restores the environment afterward, and never calls `opencode serve` itself. -```bash -npx consensus-cli graph --json -``` +Snapshot input must contain: -Analyze a saved Consensus snapshot: +- finite numeric `ts` +- an `agents` array +- valid agent `id`, `pid`, provider `kind`, and state +- structurally valid retained events when `events` is present -```bash -npx consensus-cli graph --snapshot snapshot.json -``` +Malformed JSON, unknown provider kinds, invalid states, and malformed events fail with a non-zero exit code. -Read a snapshot from stdin: +## One-shot snapshot ```bash -cat snapshot.json | npx consensus-cli graph --snapshot - +npm run scan ``` -The graph command is read-only. It detects observed phase cycles but does not start, stop, retry, or route agent work. - -See `docs/graphs-and-loops.md` for the graph model and current limits. +This wrapper hydrates bounded Claude hook metadata before scanning, so one-shot output can include Claude phase history captured by the local server. diff --git a/docs/configuration.md b/docs/configuration.md index 1b4e7f6..d1fd85f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,199 +1,151 @@ # Configuration -All configuration is via environment variables. +All configuration uses environment variables. + +## Server -## Variables - `CONSENSUS_HOST` - Default: `127.0.0.1` - - Bind address for the server. + - HTTP bind address. - `CONSENSUS_PORT` - Default: `8787` - - Port for the HTTP server. + - HTTP and WebSocket port. - `CONSENSUS_UI_PORT` - Default: `5173` - - Port for the Vite dev server when running `npm run dev`. + - Vite development port. - `CONSENSUS_POLL_MS` - - Default: `250` - - Poll interval for process presence scans. + - Process-presence scan interval. - `CONSENSUS_SCAN_TIMEOUT_MS` - Default: `5000` - - Max time (ms) for a scan tick before timing out (guardrail for stuck scans). + - Maximum scan duration. - `CONSENSUS_SCAN_STALL_MS` - - Default: `60%` of `CONSENSUS_SCAN_TIMEOUT_MS` (min `250`) - - Emit a stall warning/metric when a scan exceeds this duration. + - Default: 60% of the scan timeout, minimum `250`. - `CONSENSUS_SCAN_STALL_CHECK_MS` - - Default: `min(1000, CONSENSUS_SCAN_STALL_MS)` (min `250`) - - Interval for checking whether a scan is stalling. -- `CONSENSUS_OTEL_ENABLED` - - Default: disabled - - Set to `1` to enable Effect OpenTelemetry tracing/metrics (dev-only rollout). -- `CONSENSUS_OTEL_ENDPOINT` - - Default: unset - - OTLP/HTTP base endpoint (e.g., `http://localhost:4318`). When unset, exporters fall back to console. -- `CONSENSUS_OTEL_SERVICE_NAME` - - Default: `consensus-cli` - - Service name for telemetry resource. -- `CONSENSUS_OTEL_ENV` - - Default: `development` - - Deployment environment. -- `CONSENSUS_OTEL_VERSION` - - Default: package version (or `unknown`). - - Service version for telemetry resource. -- `CONSENSUS_OTEL_SAMPLE_RATIO` - - Default: `1` - - Trace sampling ratio (0–1). Prod can lower to `0.1` later. -- `CONSENSUS_OTEL_METRIC_INTERVAL_MS` - - Default: `10000` - - Metric export interval in milliseconds. -- `CONSENSUS_OTEL_CONSOLE_FALLBACK` - - Default: enabled - - Set to `0` to disable console exporters when OTLP endpoint is unset. + - Stall-check interval. + +## Codex + - `CONSENSUS_CODEX_HOME` - - Default: `~/.codex` - - Override Codex home directory. + - Default: `~/.codex`. - `CONSENSUS_CODEX_NOTIFY_INSTALL` - - Optional. If set, consensus will run `codex config set -g notify=[""]` on startup. - - Intended for wiring Codex TUI `notify` hook to consensus without manual setup. - - Use `CONSENSUS_CODEX_NOTIFY_INSTALL_TIMEOUT_MS` to cap install time (default 5000). - - Set to `0`, `false`, or `off` to disable the auto-install. + - Deprecated compatibility path. Use `npx consensus-cli setup`. + - Values `0`, `false`, or `off` disable it. +- `CONSENSUS_CODEX_NOTIFY_INSTALL_TIMEOUT_MS` + - Default: `5000`. - `CONSENSUS_CODEX_WATCH_POLL` - - Default: enabled - - Set to `0` to disable polling for Codex JSONL watch events. Polling is the default because native FS events are unreliable for these files. + - Set to `0` to disable polling-based JSONL watching. - `CONSENSUS_CODEX_WATCH_INTERVAL_MS` - - Default: `1000` - - Polling interval (ms) for Codex JSONL watcher when polling is enabled. + - Default: `1000`. - `CONSENSUS_CODEX_WATCH_BINARY_INTERVAL_MS` - - Default: `CONSENSUS_CODEX_WATCH_INTERVAL_MS` - - Polling interval (ms) for binary file changes when polling is enabled. + - Defaults to the watch interval. +- `CONSENSUS_CODEX_EVENT_ACTIVE_MS` + - Recent-event activity window. +- `CONSENSUS_CODEX_ACTIVE_HOLD_MS` + - Default: `3000`. +- `CONSENSUS_CODEX_INFLIGHT_IDLE_MS` + - Default: `30000`; set `0` to disable. +- `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS` + - Default: `3000`. +- `CONSENSUS_CODEX_FILE_FRESH_MS` + - JSONL freshness window. +- `CONSENSUS_CODEX_STALE_FILE_MS` + - Default: `120000`. +- `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS` + - Defaults to the in-flight timeout. + +Current Codex documentation defines `notify` as an array of command arguments in the user-level `~/.codex/config.toml`. Project-local config does not support `notify`. + +## OpenCode + - `CONSENSUS_OPENCODE_HOST` - - Default: `127.0.0.1` - - OpenCode server host. + - Default: `127.0.0.1`. - `CONSENSUS_OPENCODE_PORT` - - Default: `4096` - - OpenCode server port. + - Default: `4096`. - `CONSENSUS_OPENCODE_TIMEOUT_MS` - - Default: `5000` - - Timeout for OpenCode HTTP requests. + - Default: `5000`. - `CONSENSUS_OPENCODE_AUTOSTART` - - Default: enabled - - Set to `0` to disable OpenCode server autostart. + - Default: enabled for the live dashboard. + - Set to `0` to disable. + - `consensus graph` disables autostart internally regardless of this setting. - `CONSENSUS_OPENCODE_EVENTS` - - Default: enabled - - Set to `0` to disable OpenCode event stream. + - Set to `0` to disable SSE activity ingestion. - `CONSENSUS_OPENCODE_HOME` - - Default: `~/.local/share/opencode` - - Override OpenCode storage directory. + - Default: `~/.local/share/opencode`. - `CONSENSUS_OPENCODE_EVENT_ACTIVE_MS` - - Default: `0` - - OpenCode event window before dropping to idle. + - Recent-event activity window. - `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS` - - Default: `3000` - - OpenCode hold window after activity. + - Default: `3000`. - `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS` - - Default: `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS` - - OpenCode in-flight idle timeout in ms before dropping to idle if no activity is observed. + - In-flight idle decay window. - `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS` - - Default: `15000` - - Hard timeout (ms) used to clear OpenCode in-flight when no fresh events are observed. -- `CONSENSUS_PROCESS_MATCH` - - Default: unset - - Regex to match process name or command line. + - Default: `15000`. - `CONSENSUS_DEBUG_OPENCODE` - - Default: unset - - Set to `1` to log OpenCode server discovery (debug only). + - Set to `1` for discovery logs. + +## Claude Code + +- `CONSENSUS_CLAUDE_EVENT_TTL_MS` + - Default: `1800000` (30 minutes). + - In-memory and hydrated metadata age limit. +- `CONSENSUS_CLAUDE_INFLIGHT_TIMEOUT_MS` + - Default: `15000`. +- `CONSENSUS_CLAUDE_ACTIVE_HOLD_MS` + - Default: `3000`. +- `CONSENSUS_CLAUDE_EVENT_LOG` + - Default: `~/.consensus/claude-events.jsonl`. + - Set a custom local path, or `0`, `false`, or `off` to disable cross-process graph history. +- `CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES` + - Default: `1048576` (1 MiB). + - Minimum accepted value: 64 KiB. + - When the file exceeds the bound, Consensus keeps roughly the newest half. + +The Claude log contains metadata only. It excludes prompts, assistant text, message deltas, tool input/output, transcript paths, task descriptions, and error details. + +## Privacy and matching + - `CONSENSUS_REDACT_PII` - - Default: enabled - - Set to `0` to disable redaction. -- `ACTIVITY_TEST_MODE` - - Default: disabled - - Set to `1` to enable test-only activity injection endpoints under `/__test`. -- `CONSENSUS_CODEX_EVENT_ACTIVE_MS` - - Default: `30000` - - Codex event window before dropping to idle. -- `CONSENSUS_CODEX_MTIME_ACTIVE_MS` - - Default: `750` - - Treat recent Codex JSONL file mtime as activity within this window (bridges log write lag). -- `CONSENSUS_CODEX_ACTIVE_HOLD_MS` - - Default: `3000` - - Codex hold window after activity. -- `CONSENSUS_CODEX_INFLIGHT_GRACE_MS` - - Default: `750` - - Codex in-flight grace window after the last in-flight signal (prevents brief idle flicker). -- `CONSENSUS_CODEX_STRICT_INFLIGHT` - - Default: disabled - - Set to `1` to require explicit in-flight signals from logs (disables CPU/mtime bridging). -- `CONSENSUS_CODEX_INFLIGHT_IDLE_MS` - - Default: `30000` - - Idle timeout to clear Codex in-flight when activity is stale. Set to `0` to disable. -- `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS` - - Default: `3000` - - Hard timeout to clear Codex in-flight if no recent signals and file is not fresh. -- `CONSENSUS_CODEX_FILE_FRESH_MS` - - Default: `10000` - - Treat recent Codex JSONL file mtime within this window as fresh and keep in-flight on. -- `CONSENSUS_CODEX_CPU_SUSTAIN_MS` - - Default: `500` - - Require sustained Codex CPU activity (ms) before marking active when logs are missing. -- `CONSENSUS_CODEX_CPU_SPIKE` - - Default: derived (`max(cpuThreshold * 10, 25)`) - - Codex CPU spike threshold for immediate active state when logs are missing. -- `CONSENSUS_CODEX_STALE_FILE_MS` - - Default: `120000` - - Treat Codex JSONL files older than this as stale to prevent stale sessions from staying active. -- `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS` - - Default: `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS` - - Ignore Codex events older than this when setting in-flight signals (prevents stale sessions after restart). + - Default: enabled. + - Set to `0` to disable normal text redaction. + - Graph identity fields remain opaque even when text redaction is disabled. +- `CONSENSUS_PROCESS_MATCH` + - Optional regular expression for Codex process matching. +- `CONSENSUS_INCLUDE_CODEX_VENDOR` + - Set to `1` or `true` to include standalone vendor processes. + +## Cache and activity + - `CONSENSUS_PROCESS_CACHE_MS` - - Default: `1000` - - Process cache TTL (ms) for full scans. + - Default: `1000`. - `CONSENSUS_PROCESS_CACHE_FAST_MS` - - Default: `500` - - Process cache TTL (ms) for fast scans. + - Default: `500`. - `CONSENSUS_SESSION_CACHE_MS` - - Default: `1000` - - Codex session list cache TTL (ms) for full scans. + - Default: `1000`. - `CONSENSUS_SESSION_CACHE_FAST_MS` - - Default: `500` - - Codex session list cache TTL (ms) for fast scans. + - Default: `500`. - `CONSENSUS_EVENT_ACTIVE_MS` - - Default: `300000` - - Window after the last event to mark an agent active. -- Claude activity is hook-driven; CPU thresholds are legacy and ignored for TUI activity. -- `CONSENSUS_CPU_ACTIVE` - - Default: `1` - - CPU threshold for marking an agent active. -- `CONSENSUS_CLAUDE_CPU_ACTIVE` - - Default: `1` - - Claude CPU threshold override. -- `CONSENSUS_CLAUDE_CPU_SUSTAIN_MS` - - Default: `1000` - - Claude sustained CPU window in ms. -- `CONSENSUS_CLAUDE_CPU_SPIKE` - - Default: derived - - Claude spike threshold override. -- `CONSENSUS_CLAUDE_EVENT_TTL_MS` - - Default: `1800000` - - Claude hook event TTL before a session is pruned. -- `CONSENSUS_CLAUDE_INFLIGHT_TIMEOUT_MS` - - Default: `15000` - - Clear Claude in-flight if no hook activity arrives within this window. -- `CONSENSUS_CLAUDE_ACTIVE_HOLD_MS` - - Default: `3000` - - Claude-specific active hold window to smooth brief hook gaps (reduce flicker). -- `CONSENSUS_CLAUDE_START_ACTIVE_MS` - - Default: `1200` - - Legacy (not used for hook-driven Claude activity). + - General recent-event window. - `CONSENSUS_ACTIVE_HOLD_MS` - - Default: `3000` - - Keep agents active for this long after activity. + - Default: `3000`. - `CONSENSUS_IDLE_HOLD_MS` - - Default: `200` - - Hold an agent in idle briefly after spans end (prevents idle flicker). + - Default: `200`. - `CONSENSUS_SPAN_STALE_MS` - - Default: `15000` - - Consider agent spans stale after this duration with no progress updates. + - Default: `15000`. + +## Observability + +- `CONSENSUS_OTEL_ENABLED` +- `CONSENSUS_OTEL_ENDPOINT` +- `CONSENSUS_OTEL_SERVICE_NAME` +- `CONSENSUS_OTEL_ENV` +- `CONSENSUS_OTEL_VERSION` +- `CONSENSUS_OTEL_SAMPLE_RATIO` +- `CONSENSUS_OTEL_METRIC_INTERVAL_MS` +- `CONSENSUS_OTEL_CONSOLE_FALLBACK` +- `CONSENSUS_PROFILE` +- `CONSENSUS_PROFILE_MS` +- `CONSENSUS_DEBUG_ACTIVITY` + +## Test mode -## Example -```bash -CONSENSUS_PORT=8790 CONSENSUS_POLL_MS=250 npm run dev -``` +- `ACTIVITY_TEST_MODE=1` + - Enables test-only activity routes. diff --git a/docs/data-inventory.md b/docs/data-inventory.md index 4c0baed..d49e4b4 100644 --- a/docs/data-inventory.md +++ b/docs/data-inventory.md @@ -1,16 +1,64 @@ # Data inventory -## Data collected -- Process metadata: pid, cmdline, cpu, memory, cwd. -- Recent Codex session event summaries (best-effort). -- Claude hook events (session id, cwd, transcript path, event type). +## Data observed -## Storage -- No persistent storage outside the local machine. -- Data is held in memory for live rendering only. +- Process metadata: PID, command line, CPU, memory, and working directory. +- Recent Codex event summaries and local session metadata. +- OpenCode session, status, message-summary, and SSE event metadata. +- Claude hook metadata: event type, session ID, timestamp, working directory, transcript path, notification type, tool name, agent type, final-message marker, and current activity state. -## Retention -- No historical retention; data refreshes per poll. +Raw prompt, assistant, tool-input, and tool-output content can exist in provider-owned logs or hook payloads. Consensus normalizes only bounded summaries for its live snapshot. -## Deletion -- Stop the server to clear in-memory data. +## Graph export + +Graph JSON contains: + +- opaque agent keys +- provider and repository labels +- turn-segment numbers +- normalized phases and event types +- transition observation counts +- loop membership and current state + +Graph relationship IDs do not contain raw Codex session paths or raw working directories. + +## Claude metadata storage + +To make one-shot `scan` and `graph` commands see hook history captured by the running server, Consensus writes a bounded local metadata file at: + +```text +~/.consensus/claude-events.jsonl +``` + +Stored fields: + +- schema version +- event type +- session ID +- timestamp +- opaque SHA-256 working-directory key +- notification type +- tool name +- agent type +- final-message marker + +Not stored: + +- raw working directory +- transcript path +- prompt text +- assistant text or `MessageDisplay.delta` +- message or turn IDs +- tool input or output +- task subject or description +- error details + +The file is mode `0600`, the directory is created with mode `0700`, and the default bound is 1 MiB. + +## Retention and deletion + +- Provider event arrays are bounded in memory. +- Claude metadata older than `CONSENSUS_CLAUDE_EVENT_TTL_MS` is ignored during hydration. +- Set `CONSENSUS_CLAUDE_EVENT_LOG=0` to disable new writes. +- Stop Consensus and delete `~/.consensus/claude-events.jsonl` to clear persisted Claude metadata. +- Stop the server to clear in-memory state. diff --git a/docs/graphs-and-loops.md b/docs/graphs-and-loops.md index 7ac7c7e..803bc16 100644 --- a/docs/graphs-and-loops.md +++ b/docs/graphs-and-loops.md @@ -1,51 +1,44 @@ # Graphs and loops -Consensus treats an agent loop as a cycle inside an execution graph. +Consensus treats an observed agent loop as a cycle inside a turn-scoped execution graph. -The graph answers two different questions: +- **Graph:** which agents and normalized work phases were observed, and which transitions occurred? +- **Loop:** which transitions within one observed turn return to an earlier phase? -- **Graph:** What agents and work phases exist, and which transitions have been observed? -- **Loop:** Which observed transitions return to an earlier phase? +Consensus remains read-only. It reports local activity and does not start, stop, retry, route, approve, or otherwise control agent work. -This keeps the runtime model useful for both fixed workflows and agent-created task paths. Consensus remains an observability tool: it reports what ran but does not start, stop, retry, or route work. - -## Preview command - -Build a graph from the current local snapshot: +## Commands ```bash npx consensus-cli graph +npx consensus-cli graph --json +npx consensus-cli graph --snapshot snapshot.json +cat snapshot.json | npx consensus-cli graph --snapshot - ``` -Print the full graph payload: +Live graph inspection disables OpenCode autostart before loading the scanner. Saved snapshots receive strict structural validation before graph construction. -```bash -npx consensus-cli graph --json -``` +## Version 1 data model -Analyze a saved snapshot: +### Agent nodes -```bash -npm run scan > snapshot.json -npx consensus-cli graph --snapshot snapshot.json -``` +Each observed process or session produces one `agent` node. Graph output derives an opaque `agentKey` from provider plus local identity. It never includes the original Codex session path in graph IDs or relationship fields. -Read a snapshot from stdin: +### Turn segments -```bash -cat snapshot.json | npx consensus-cli graph --snapshot - -``` +Step nodes are scoped to an observed turn segment: -## Graph model +```text +step::s: +``` -The version 1 graph uses the retained events already present in a Consensus snapshot. +A new prompt opens a new segment. Explicit end events such as `turn.completed`, `Stop`, `StopFailure`, and `SessionEnd` close the current segment. No transition edge crosses a segment boundary. -### Nodes +This rule prevents a routine second prompt from closing a cycle through the prior turn. -- `agent`: one node for each observed Codex, OpenCode, or Claude Code session or process. -- `step`: one provider-neutral work phase for an agent. +### Phases -The first normalized phases are: +Provider events normalize to: - `prompt` - `model` @@ -53,39 +46,94 @@ The first normalized phases are: - `command` - `edit` -Unknown event types remain visible as their own phases instead of being dropped. +Unknown non-lifecycle events remain visible under their normalized event name. Lifecycle and transport records are excluded, including session/thread/turn/response status events, token counts, heartbeats, connection events, compaction, notifications, and standalone configuration or filesystem notifications. ### Edges -- `contains`: connects an agent to its observed phases. -- `transition`: connects one phase to the next phase seen for the same agent. +- `contains`: agent to turn-scoped step. +- `transition`: one phase to the next phase in the same segment. -Repeated adjacent stream fragments collapse into one phase visit. This prevents token or message deltas from creating false self-loops. Repeated transitions still increase the edge observation count. +Adjacent events in the same phase collapse into one visit. Repeated non-adjacent transitions increase the edge observation count. ### Loops -Consensus finds strongly connected components in the transition graph: +Consensus runs strongly connected component detection over transition edges. - A self-edge is a `self` loop. -- Two or more mutually reachable phases form a `cycle` loop. +- Two or more mutually reachable step nodes form a `cycle` loop. + +`transitionObservations` is the sum of internal edge observations. It is not a completed-iteration count and must not be used as a retry budget without a separate traversal model. + +### Current state + +Only the latest observed step for an agent is marked `current` and receives the agent's live `active`, `idle`, or `error` state. Older step nodes remain idle, even if their phase names match the current phase in a later turn. A historical loop therefore cannot become active merely because the agent is active elsewhere. + +`hadError` records whether an error event occurred on a step without converting that historical step into a current error state. + +## Provider coverage + +Provider contracts were checked on July 18, 2026. + +### Codex + +Consensus uses the user-level Codex notify command plus bounded local JSONL summaries. Current Codex configuration documents `notify` as an array of command arguments in `~/.codex/config.toml`; project-local config does not support it. + +Source: + +### OpenCode + +Consensus reads the local HTTP server, `/global/event` and `/event` SSE streams, session status, and retained message activity. Version 1 does not infer parent or delegation edges. The current server contract exposes `parentID` on session creation and `/session/:id/children`, which can support explicit parent/subagent edges later. + +Source: + +### Claude Code + +Consensus receives hook events through `src/claudeHook.ts`. Current Claude Code hooks include `MessageDisplay`, `PostToolBatch`, `PermissionDenied`, `TaskCreated`, `TaskCompleted`, and `StopFailure` in addition to the original session, prompt, tool, subagent, and stop events. + +`MessageDisplay` can fire several times while one assistant message streams. Consensus uses partial batches only as activity pulses and retains the final marker. It never stores `delta` content. + +`UserPromptSubmit` defines the graph turn boundary. `UserPromptExpansion` is treated as activity only and is not retained as a second prompt phase, avoiding a duplicate segment for direct slash-command expansion. + +The metadata log retains only: + +- event type +- session ID +- timestamp +- opaque working-directory key +- notification type +- tool name +- agent type +- final-message marker + +It excludes prompt text, assistant text, message and turn IDs, tool input and output, transcript path, task subject and description, and error details. + +Source: + +### OpenClaw framing + +OpenClaw's documented agent loop remains a serialized runtime loop around model inference, tools, streaming, and persistence. Consensus models that loop as turn-scoped cycles inside a broader observed graph. -Loop state follows the strongest current signal from its member phases: `error`, then `active`, then `idle`. +Source: -## Output limits +## Retention and privacy -Version 1 has explicit limits: +- Snapshot events remain bounded by each provider adapter. +- Claude cross-process metadata defaults to `~/.consensus/claude-events.jsonl`. +- The file mode is set to `0600`; its directory is created with mode `0700`. +- Default maximum size is 1 MiB. When exceeded, the log keeps roughly the newest half. +- Set `CONSENSUS_CLAUDE_EVENT_LOG=0` to disable it. +- Stop the server and remove the metadata file to clear retained Claude graph history. -- It analyzes only the events retained in the current snapshot. -- Provider event normalization is best effort. -- It does not infer causal links between separate agents. -- It does not yet represent parent, subagent, delegation, approval, or retry edges. -- It reports an observed phase-transition graph, not a complete workflow definition. +## Current limits -These limits appear in the data model through `source: "observed-events"` and the retained event window. +- The graph reflects retained evidence, not a full workflow definition. +- Turn boundaries are best effort when a provider omits explicit lifecycle events; every prompt still starts a new segment. +- Version 1 does not represent parent, delegation, approval, retry, handoff, or cross-agent causal edges. +- Loop duration, iteration count, stop-rule status, and budget use need additional event models. -## Next graph slices +## Next slices -1. Add explicit parent, subagent, delegation, approval, and retry edges from provider and harness data. -2. Stream graph deltas through the server and render them in the isometric UI. -3. Support declared workflow graphs alongside runtime-created task graphs. -4. Add loop budgets, stop-rule signals, stuck-loop detection, and run comparisons. +1. Add explicit parent and child edges from provider-supported identifiers. +2. Stream graph deltas through the server and render them in the browser. +3. Compare declared workflow graphs with runtime-created task graphs. +4. Add traversal-aware loop counts, budgets, stop rules, and stuck-loop alerts. diff --git a/docs/threat-model.md b/docs/threat-model.md index f982459..3bce4ca 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -1,19 +1,78 @@ # Threat model ## Entry points -- CLI args and environment variables. -- HTTP server endpoints. + +- CLI arguments and snapshot files. +- Environment variables. +- Local HTTP and WebSocket endpoints. +- Codex notify payloads. +- OpenCode HTTP/SSE responses. +- Claude hook payloads. ## Trust boundaries -- Local machine only by default. -- No external services required. -## Secrets handling -- The app does not require secrets. -- Redaction protects common PII patterns. +- Consensus binds to localhost by default. +- Provider logs, events, and user-supplied snapshots are untrusted input. +- The project does not require API secrets. +- Exposing the server beyond localhost creates a new trust boundary and is not the default deployment model. + +## Main risks and controls + +### Path and identity disclosure + +Risk: provider identities can contain home directories or raw session paths. + +Controls: + +- Graph keys use truncated SHA-256 identifiers derived from provider plus local identity. +- Raw identities are not emitted in graph node or loop relationship fields. +- Claude persisted metadata uses an opaque working-directory key rather than the path. + +### Content retention + +Risk: hook payloads can contain prompts, assistant text, tool input/output, transcript paths, tasks, and error details. + +Controls: + +- The Claude hook adapter selects only small metadata fields before posting to Consensus. +- `MessageDisplay.delta`, tool input/output, prompt content, task descriptions, and error details are not retained. +- The local metadata log is bounded and can be disabled. + +### Local file access + +Risk: another local user reads retained metadata. + +Controls: + +- Claude metadata directory is created with mode `0700`. +- Metadata file is set to mode `0600` after writes and rotation. +- No network upload path exists. + +### Graph false positives + +Risk: lifecycle records or separate turns form a false cycle. + +Controls: + +- Lifecycle and transport events are excluded from graph phases. +- Every prompt opens a new segment. +- Transition edges never cross segment boundaries. +- Only the latest step receives live state. + +### Side effects from inspection + +Risk: a read-only graph command starts an OpenCode server. + +Control: `consensus graph` sets `CONSENSUS_OPENCODE_AUTOSTART=0` before dynamically loading the scanner, then restores the prior environment value. + +### Malformed snapshots + +Risk: invalid JSON or fields crash graph inspection or misclassify providers. + +Control: the graph CLI validates timestamps, agents, provider kinds, states, and retained events before building a graph. -## Data in transit -- HTTP over localhost unless explicitly exposed. +## Logging -## Logging risks -- Avoid logging secrets; use redaction for summaries. +- Avoid logging secrets or content-bearing provider payloads. +- Use existing redaction for user-facing summaries. +- Do not treat URL encoding as anonymization. diff --git a/package.json b/package.json index e21ab4e..0aac85b 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "build": "tsc && npm run build:client", "build:client": "cd public && vite build", "start": "node dist/server.js", - "scan": "node dist/scan.js", + "scan": "node dist/scanSnapshot.js", "graph": "tsx src/cli.ts graph", "tail": "node dist/tail.js", "test": "npm run test:unit && npm run test:integration", diff --git a/src/claude/types.ts b/src/claude/types.ts index 0a86c9c..4d06771 100644 --- a/src/claude/types.ts +++ b/src/claude/types.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import type { EventSummary, WorkSummary } from "../types.js"; export const ClaudeEventSchema = Schema.Struct({ type: Schema.String, @@ -6,6 +7,9 @@ export const ClaudeEventSchema = Schema.Struct({ cwd: Schema.optional(Schema.String), transcriptPath: Schema.optional(Schema.String), notificationType: Schema.optional(Schema.String), + toolName: Schema.optional(Schema.String), + agentType: Schema.optional(Schema.String), + final: Schema.optional(Schema.Boolean), timestamp: Schema.Number, }); @@ -15,8 +19,13 @@ export interface ClaudeSessionState { sessionId: string; inFlight: boolean; lastActivityAt?: number; + lastEventAt?: number; lastSeenAt: number; cwd?: string; + cwdKey?: string; transcriptPath?: string; lastEvent?: string; + events: EventSummary[]; + summary: WorkSummary; + hasError?: boolean; } diff --git a/src/claudeHook.ts b/src/claudeHook.ts index 3bf5546..c092b88 100644 --- a/src/claudeHook.ts +++ b/src/claudeHook.ts @@ -9,6 +9,9 @@ type NormalizedEvent = { cwd?: string; transcriptPath?: string; notificationType?: string; + toolName?: string; + agentType?: string; + final?: boolean; timestamp: number; }; @@ -18,6 +21,10 @@ function readString(value: unknown): string | undefined { return trimmed ? trimmed : undefined; } +function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + async function readStdin(): Promise { if (process.stdin.isTTY) return ""; const chunks: Buffer[] = []; @@ -35,17 +42,18 @@ function normalizePayload(payload: RawPayload): NormalizedEvent | null { readString(payload.type); const sessionId = readString(payload.session_id) || readString(payload.sessionId); if (!hookEvent || !sessionId) return null; - const cwd = readString(payload.cwd); - const transcriptPath = readString(payload.transcript_path) || readString(payload.transcriptPath); - const notificationType = - readString(payload.notification_type) || readString(payload.notificationType); return { type: hookEvent, sessionId, - cwd, - transcriptPath, - notificationType, + cwd: readString(payload.cwd), + transcriptPath: + readString(payload.transcript_path) || readString(payload.transcriptPath), + notificationType: + readString(payload.notification_type) || readString(payload.notificationType), + toolName: readString(payload.tool_name) || readString(payload.toolName), + agentType: readString(payload.agent_type) || readString(payload.agentType), + final: readBoolean(payload.final), timestamp: Date.now(), }; } diff --git a/src/claudeSnapshot.ts b/src/claudeSnapshot.ts new file mode 100644 index 0000000..cd4fcc6 --- /dev/null +++ b/src/claudeSnapshot.ts @@ -0,0 +1,50 @@ +import type { AgentSnapshot, SnapshotPayload, WorkSummary } from "./types.js"; +import { + getClaudeActivityByCwd, + getClaudeActivityBySession, +} from "./services/claudeEvents.js"; + +function claudeSessionId(agent: AgentSnapshot): string | undefined { + for (const candidate of [agent.sessionPath, agent.identity]) { + if (!candidate?.startsWith("claude:")) continue; + const sessionId = candidate.slice("claude:".length).trim(); + if (sessionId) return sessionId; + } + return undefined; +} + +function mergeSummary( + existing: WorkSummary | undefined, + observed: WorkSummary | undefined +): WorkSummary | undefined { + if (!existing && !observed) return undefined; + return { ...(existing ?? {}), ...(observed ?? {}) }; +} + +export function attachClaudeEvents(snapshot: SnapshotPayload): SnapshotPayload { + const agents = snapshot.agents.map((agent) => { + if (!agent.kind.startsWith("claude")) return agent; + + const sessionId = claudeSessionId(agent); + const state = + (sessionId ? getClaudeActivityBySession(sessionId, snapshot.ts) : undefined) ?? + (agent.cwd ? getClaudeActivityByCwd(agent.cwd, snapshot.ts) : undefined); + if (!state) return agent; + + const summary = mergeSummary(agent.summary, state.summary); + return { + ...agent, + state: state.hasError ? "error" : agent.state, + lastEventAt: + typeof state.lastEventAt === "number" + ? Math.max(agent.lastEventAt ?? 0, state.lastEventAt) + : agent.lastEventAt, + lastActivityAt: state.lastActivityAt ?? agent.lastActivityAt, + doing: summary?.current ?? agent.doing, + summary, + events: state.events.slice(-20), + }; + }); + + return { ...snapshot, agents }; +} diff --git a/src/cli/graph.ts b/src/cli/graph.ts index ac573a2..248c1a2 100644 --- a/src/cli/graph.ts +++ b/src/cli/graph.ts @@ -144,10 +144,10 @@ export function parseSnapshot(input: string, source: string): SnapshotPayload { return parsed as unknown as SnapshotPayload; } -const defaultScanLoader: GraphScanLoader = async () => - (await import("../scan.js")) as { - scanCodexProcesses: SnapshotScanner; - }; +const defaultScanLoader: GraphScanLoader = async () => { + const { scanSnapshot } = await import("../scanSnapshot.js"); + return { scanCodexProcesses: scanSnapshot }; +}; async function loadLiveSnapshot( loadScan: GraphScanLoader = defaultScanLoader @@ -170,9 +170,7 @@ export async function loadSnapshot( snapshotPath: string | undefined, loadScan: GraphScanLoader = defaultScanLoader ): Promise { - if (!snapshotPath) { - return loadLiveSnapshot(loadScan); - } + if (!snapshotPath) return loadLiveSnapshot(loadScan); const input = snapshotPath === "-" ? await readStdin() : await readFile(snapshotPath, "utf8"); diff --git a/src/scanSnapshot.ts b/src/scanSnapshot.ts new file mode 100644 index 0000000..57e5740 --- /dev/null +++ b/src/scanSnapshot.ts @@ -0,0 +1,29 @@ +import type { ScanOptions } from "./scan.js"; +import { scanCodexProcesses } from "./scan.js"; +import { attachClaudeEvents } from "./claudeSnapshot.js"; +import { hydrateClaudeEventsFromDisk } from "./services/claudeEvents.js"; +import type { SnapshotPayload } from "./types.js"; + +export async function scanSnapshot( + options: ScanOptions = { mode: "full" } +): Promise { + await hydrateClaudeEventsFromDisk(); + const snapshot = await scanCodexProcesses(options); + return attachClaudeEvents(snapshot); +} + +const isDirectRun = + process.argv[1] && + (process.argv[1].endsWith("scanSnapshot.js") || + process.argv[1].endsWith("scanSnapshot.ts")); + +if (isDirectRun) { + scanSnapshot({ mode: "full" }) + .then((snapshot) => { + process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`); + }) + .catch((error) => { + process.stderr.write(`[consensus] scan error: ${String(error)}\n`); + process.exit(1); + }); +} diff --git a/src/services/claudeEventLog.ts b/src/services/claudeEventLog.ts new file mode 100644 index 0000000..5b9daaf --- /dev/null +++ b/src/services/claudeEventLog.ts @@ -0,0 +1,140 @@ +import { + appendFile, + chmod, + mkdir, + readFile, + rename, + stat, + writeFile, +} from "fs/promises"; +import { homedir } from "os"; +import path from "path"; +import type { ClaudeEvent } from "../claude/types.js"; +import { + CLAUDE_STALE_TTL_MS, + fromStoredClaudeEvent, + isStoredClaudeEvent, + storedClaudeEventKey, + toStoredClaudeEvent, + type StoredClaudeEvent, +} from "./claudeEventModel.js"; + +const DEFAULT_EVENT_LOG_MAX_BYTES = 1024 * 1024; +const MAX_SEEN_STORED_EVENTS = 5000; +const seenStoredEvents = new Set(); +let persistenceQueue: Promise = Promise.resolve(); + +function rememberStoredEvent(key: string): void { + if (seenStoredEvents.has(key)) return; + seenStoredEvents.add(key); + while (seenStoredEvents.size > MAX_SEEN_STORED_EVENTS) { + const oldest = seenStoredEvents.values().next().value; + if (typeof oldest !== "string") break; + seenStoredEvents.delete(oldest); + } +} + +function resolveEventLogPath(): string | undefined { + const configured = process.env.CONSENSUS_CLAUDE_EVENT_LOG?.trim(); + if (configured) { + const lowered = configured.toLowerCase(); + if (lowered === "0" || lowered === "false" || lowered === "off") { + return undefined; + } + return configured; + } + return path.join(homedir(), ".consensus", "claude-events.jsonl"); +} + +function resolveEventLogMaxBytes(): number { + const parsed = Number(process.env.CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_EVENT_LOG_MAX_BYTES; + return Math.max(64 * 1024, Math.floor(parsed)); +} + +async function trimEventLog(filePath: string, maxBytes: number): Promise { + let info; + try { + info = await stat(filePath); + } catch { + return; + } + if (info.size <= maxBytes) return; + + const content = await readFile(filePath, "utf8"); + const keepCharacters = Math.max(32 * 1024, Math.floor(maxBytes / 2)); + let trimmed = content.slice(-keepCharacters); + if (trimmed.length < content.length) { + const firstNewline = trimmed.indexOf("\n"); + if (firstNewline >= 0) trimmed = trimmed.slice(firstNewline + 1); + } + const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + await writeFile(tempPath, trimmed, { encoding: "utf8", mode: 0o600 }); + await rename(tempPath, filePath); + await chmod(filePath, 0o600).catch(() => undefined); +} + +async function persistStoredEvent(event: StoredClaudeEvent): Promise { + const filePath = resolveEventLogPath(); + if (!filePath) return; + await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + await appendFile(filePath, `${JSON.stringify(event)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await chmod(filePath, 0o600).catch(() => undefined); + await trimEventLog(filePath, resolveEventLogMaxBytes()); +} + +export function queueClaudeEventPersistence(event: ClaudeEvent): Promise { + const stored = toStoredClaudeEvent(event); + if (!stored) return persistenceQueue; + const key = storedClaudeEventKey(stored); + rememberStoredEvent(key); + persistenceQueue = persistenceQueue + .then(() => persistStoredEvent(stored)) + .catch(() => undefined); + return persistenceQueue; +} + +export function flushClaudeEventPersistence(): Promise { + return persistenceQueue; +} + +export async function readStoredClaudeEvents(): Promise< + Array<{ event: ClaudeEvent; cwdKey?: string }> +> { + const filePath = resolveEventLogPath(); + if (!filePath) return []; + let input: string; + try { + input = await readFile(filePath, "utf8"); + } catch { + return []; + } + + const cutoff = Date.now() - CLAUDE_STALE_TTL_MS; + const storedEvents: StoredClaudeEvent[] = []; + for (const line of input.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const parsed: unknown = JSON.parse(line); + if (!isStoredClaudeEvent(parsed) || parsed.timestamp < cutoff) continue; + storedEvents.push(parsed); + } catch { + // Ignore malformed or partially written lines. + } + } + storedEvents.sort((a, b) => a.timestamp - b.timestamp); + + const result: Array<{ event: ClaudeEvent; cwdKey?: string }> = []; + for (const stored of storedEvents) { + const key = storedClaudeEventKey(stored); + if (seenStoredEvents.has(key)) continue; + rememberStoredEvent(key); + result.push({ event: fromStoredClaudeEvent(stored), cwdKey: stored.cwdKey }); + } + return result; +} diff --git a/src/services/claudeEventModel.ts b/src/services/claudeEventModel.ts new file mode 100644 index 0000000..3c2d3af --- /dev/null +++ b/src/services/claudeEventModel.ts @@ -0,0 +1,330 @@ +import { createHash } from "crypto"; +import type { EventSummary, WorkSummary } from "../types.js"; +import type { ClaudeEvent, ClaudeSessionState } from "../claude/types.js"; + +export const CLAUDE_STALE_TTL_MS = Number( + process.env.CONSENSUS_CLAUDE_EVENT_TTL_MS || 30 * 60 * 1000 +); +const INFLIGHT_TIMEOUT_MS = Number( + process.env.CONSENSUS_CLAUDE_INFLIGHT_TIMEOUT_MS || 15000 +); +const MAX_EVENTS = 50; +const MAX_METADATA_LABEL_LENGTH = 160; +export const CLAUDE_EVENT_LOG_VERSION = 1 as const; + +const CANONICAL_TYPES: Record = { + setup: "Setup", + sessionstart: "SessionStart", + sessionend: "SessionEnd", + userpromptsubmit: "UserPromptSubmit", + userpromptexpansion: "UserPromptExpansion", + messagedisplay: "MessageDisplay", + pretooluse: "PreToolUse", + posttooluse: "PostToolUse", + posttoolusefailure: "PostToolUseFailure", + posttoolbatch: "PostToolBatch", + permissionrequest: "PermissionRequest", + permissiondenied: "PermissionDenied", + notification: "Notification", + subagentstart: "SubagentStart", + subagentstop: "SubagentStop", + taskcreated: "TaskCreated", + taskcompleted: "TaskCompleted", + stop: "Stop", + stopfailure: "StopFailure", + teammateidle: "TeammateIdle", + configchange: "ConfigChange", + cwdchanged: "CwdChanged", + filechanged: "FileChanged", + worktreecreate: "WorktreeCreate", + worktreeremove: "WorktreeRemove", + instructionsloaded: "InstructionsLoaded", + precompact: "PreCompact", + postcompact: "PostCompact", + elicitation: "Elicitation", + elicitationresult: "ElicitationResult", +}; + +const INFLIGHT_EVENTS = new Set([ + "UserPromptSubmit", + "UserPromptExpansion", + "MessageDisplay", + "PreToolUse", + "PermissionRequest", + "PermissionDenied", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", +]); +const IDLE_EVENTS = new Set(["Stop", "StopFailure", "SessionEnd"]); +const NON_ACTIVITY_EVENTS = new Set([ + "Setup", + "SessionStart", + "Notification", + "TeammateIdle", + "ConfigChange", + "CwdChanged", + "FileChanged", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "PreCompact", + "PostCompact", + ...IDLE_EVENTS, +]); +const ERROR_EVENTS = new Set(["PostToolUseFailure", "StopFailure"]); +const GRAPH_EVENT_TYPES = new Set([ + "UserPromptSubmit", + "MessageDisplay", + "PreToolUse", + "PermissionRequest", + "PermissionDenied", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "Stop", + "StopFailure", + "SessionEnd", + "Elicitation", + "ElicitationResult", +]); + +export interface StoredClaudeEvent { + version: typeof CLAUDE_EVENT_LOG_VERSION; + type: string; + sessionId: string; + timestamp: number; + cwdKey?: string; + notificationType?: string; + toolName?: string; + agentType?: string; + final?: boolean; +} + +export type ClaudeStateMap = Map; + +export function boundedClaudeLabel(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed.slice(0, MAX_METADATA_LABEL_LENGTH) : undefined; +} + +export function normalizeClaudeEventType(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + const key = trimmed.replace(/[^a-z0-9]/gi, "").toLowerCase(); + return CANONICAL_TYPES[key] ?? trimmed; +} + +export function stableClaudePathKey(value: string | undefined): string | undefined { + return value + ? createHash("sha256").update(value).digest("hex").slice(0, 24) + : undefined; +} + +function isActivityEvent(type: string): boolean { + return !!type && !NON_ACTIVITY_EVENTS.has(type); +} + +function shouldRetainGraphEvent(event: ClaudeEvent, type: string): boolean { + if (!GRAPH_EVENT_TYPES.has(type)) return false; + return type !== "MessageDisplay" || event.final !== false; +} + +export function shouldPersistClaudeEvent(event: ClaudeEvent, type: string): boolean { + if (shouldRetainGraphEvent(event, type)) return true; + if (type === "SessionStart" || type === "CwdChanged") return true; + return ( + type === "Notification" && + event.notificationType?.trim().toLowerCase() === "idle_prompt" + ); +} + +function summarizeClaudeEvent(event: ClaudeEvent, type: string): EventSummary { + let summary = `event: ${type}`; + if (type === "UserPromptSubmit" || type === "UserPromptExpansion") { + summary = "prompt"; + } else if (type === "MessageDisplay") { + summary = "message"; + } else if (type === "SubagentStart" || type === "SubagentStop") { + const agentType = boundedClaudeLabel(event.agentType); + summary = agentType ? `tool: subagent ${agentType}` : "tool: subagent"; + } else if (type === "TaskCreated" || type === "TaskCompleted") { + summary = "tool: task"; + } else if (type === "Elicitation" || type === "ElicitationResult") { + summary = "tool: elicitation"; + } else if ( + type === "PreToolUse" || + type === "PostToolUse" || + type === "PostToolUseFailure" || + type === "PostToolBatch" || + type === "PermissionRequest" || + type === "PermissionDenied" + ) { + summary = `tool: ${boundedClaudeLabel(event.toolName) || type}`; + } + + return { + ts: event.timestamp, + type, + summary, + ...(ERROR_EVENTS.has(type) ? { isError: true } : {}), + }; +} + +function updateSummary( + previous: WorkSummary, + entry: EventSummary, + activity: boolean +): WorkSummary { + const summary = { ...previous }; + if (entry.summary === "prompt") summary.lastPrompt = entry.summary; + if (entry.summary === "message") summary.lastMessage = entry.summary; + if (entry.summary.startsWith("tool:")) summary.lastTool = entry.summary; + if (activity && !entry.summary.startsWith("event:")) { + summary.current = entry.summary; + } + return summary; +} + +export function expireClaudeInFlight( + state: ClaudeSessionState, + now: number +): ClaudeSessionState { + if (!state.inFlight) return state; + const lastSignal = state.lastActivityAt ?? state.lastSeenAt; + return typeof lastSignal === "number" && now - lastSignal > INFLIGHT_TIMEOUT_MS + ? { ...state, inFlight: false } + : state; +} + +export function pruneClaudeState(map: ClaudeStateMap, now: number): ClaudeStateMap { + let changed = false; + const next = new Map(); + for (const [sessionId, state] of map.entries()) { + if (now - state.lastSeenAt > CLAUDE_STALE_TTL_MS) { + changed = true; + continue; + } + const updated = expireClaudeInFlight(state, now); + if (updated !== state) changed = true; + next.set(sessionId, updated); + } + return changed ? next : map; +} + +export function applyClaudeEvent( + map: ClaudeStateMap, + event: ClaudeEvent, + storedCwdKey?: string +): ClaudeStateMap { + const now = typeof event.timestamp === "number" ? event.timestamp : Date.now(); + const type = normalizeClaudeEventType(event.type); + const isIdleNotification = + type === "Notification" && + event.notificationType?.trim().toLowerCase() === "idle_prompt"; + const previous = map.get(event.sessionId); + const entry = summarizeClaudeEvent(event, type); + const activity = isActivityEvent(type); + const retain = shouldRetainGraphEvent(event, type); + const events = retain + ? [...(previous?.events ?? []), entry].slice(-MAX_EVENTS) + : previous?.events ?? []; + const next: ClaudeSessionState = { + sessionId: event.sessionId, + inFlight: previous?.inFlight ?? false, + lastSeenAt: now, + lastEventAt: now, + cwd: event.cwd ?? previous?.cwd, + cwdKey: stableClaudePathKey(event.cwd) ?? storedCwdKey ?? previous?.cwdKey, + transcriptPath: event.transcriptPath ?? previous?.transcriptPath, + lastEvent: type, + lastActivityAt: previous?.lastActivityAt, + events, + summary: updateSummary(previous?.summary ?? {}, entry, activity), + hasError: entry.isError ? true : activity ? false : previous?.hasError, + }; + + if (INFLIGHT_EVENTS.has(type)) next.inFlight = true; + if (IDLE_EVENTS.has(type) || isIdleNotification) { + next.inFlight = false; + next.lastActivityAt = undefined; + } else if (activity) { + next.lastActivityAt = now; + } + + const nextMap = new Map(map); + nextMap.set(event.sessionId, next); + return nextMap; +} + +export function toStoredClaudeEvent( + event: ClaudeEvent +): StoredClaudeEvent | undefined { + const type = normalizeClaudeEventType(event.type); + if (!shouldPersistClaudeEvent(event, type)) return undefined; + return { + version: CLAUDE_EVENT_LOG_VERSION, + type, + sessionId: event.sessionId, + timestamp: event.timestamp, + cwdKey: stableClaudePathKey(event.cwd), + notificationType: boundedClaudeLabel(event.notificationType), + toolName: boundedClaudeLabel(event.toolName), + agentType: boundedClaudeLabel(event.agentType), + final: event.final, + }; +} + +export function storedClaudeEventKey(event: StoredClaudeEvent): string { + return [ + event.version, + event.sessionId, + event.timestamp, + event.type, + event.cwdKey ?? "", + event.notificationType ?? "", + event.toolName ?? "", + event.agentType ?? "", + event.final === undefined ? "" : String(event.final), + ].join("\0"); +} + +export function isStoredClaudeEvent(value: unknown): value is StoredClaudeEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const event = value as Record; + return ( + event.version === CLAUDE_EVENT_LOG_VERSION && + typeof event.type === "string" && + typeof event.sessionId === "string" && + typeof event.timestamp === "number" && + Number.isFinite(event.timestamp) && + (event.cwdKey === undefined || typeof event.cwdKey === "string") && + (event.notificationType === undefined || + typeof event.notificationType === "string") && + (event.toolName === undefined || typeof event.toolName === "string") && + (event.agentType === undefined || typeof event.agentType === "string") && + (event.final === undefined || typeof event.final === "boolean") + ); +} + +export function fromStoredClaudeEvent(event: StoredClaudeEvent): ClaudeEvent { + return { + type: event.type, + sessionId: event.sessionId, + notificationType: boundedClaudeLabel(event.notificationType), + toolName: boundedClaudeLabel(event.toolName), + agentType: boundedClaudeLabel(event.agentType), + final: event.final, + timestamp: event.timestamp, + } as ClaudeEvent; +} diff --git a/src/services/claudeEvents.ts b/src/services/claudeEvents.ts index 4fdc7bc..dfdf7f1 100644 --- a/src/services/claudeEvents.ts +++ b/src/services/claudeEvents.ts @@ -1,129 +1,57 @@ -import { Effect, Ref } from "effect"; +import { Effect, Ref, pipe } from "effect"; import type { ClaudeEvent, ClaudeSessionState } from "../claude/types.js"; - -const STALE_TTL_MS = Number(process.env.CONSENSUS_CLAUDE_EVENT_TTL_MS || 30 * 60 * 1000); -const INFLIGHT_TIMEOUT_MS = Number( - process.env.CONSENSUS_CLAUDE_INFLIGHT_TIMEOUT_MS || 15000 -); - -const INFLIGHT_EVENTS = new Set([ - "UserPromptSubmit", - "PreToolUse", - "PermissionRequest", - "PostToolUse", - "PostToolUseFailure", - "SubagentStart", -]); - -const IDLE_EVENTS = new Set(["Stop", "SubagentStop", "SessionEnd"]); -const NON_ACTIVITY_EVENTS = new Set(["SessionStart", ...IDLE_EVENTS]); - -type StateMap = Map; +import { + applyClaudeEvent, + pruneClaudeState, + stableClaudePathKey, + type ClaudeStateMap, +} from "./claudeEventModel.js"; +import { + flushClaudeEventPersistence, + queueClaudeEventPersistence, + readStoredClaudeEvents, +} from "./claudeEventLog.js"; const stateRef = Effect.runSync(Ref.make(new Map())); -function normalizeType(input: string): string { - const trimmed = input.trim(); - if (!trimmed) return trimmed; - const key = trimmed.replace(/[^a-z0-9]/gi, "").toLowerCase(); - switch (key) { - case "userpromptsubmit": - return "UserPromptSubmit"; - case "pretooluse": - return "PreToolUse"; - case "posttooluse": - return "PostToolUse"; - case "posttoolusefailure": - return "PostToolUseFailure"; - case "permissionrequest": - return "PermissionRequest"; - case "subagentstart": - return "SubagentStart"; - case "subagentstop": - return "SubagentStop"; - case "sessionstart": - return "SessionStart"; - case "sessionend": - return "SessionEnd"; - case "stop": - return "Stop"; - case "notification": - return "Notification"; - default: - return trimmed; - } -} - -function isActivityEvent(type: string): boolean { - if (!type) return false; - return !NON_ACTIVITY_EVENTS.has(type); -} - -function expireInFlight(state: ClaudeSessionState, now: number): ClaudeSessionState { - if (!state.inFlight) return state; - const lastSignal = state.lastActivityAt ?? state.lastSeenAt; - if (typeof lastSignal === "number" && now - lastSignal > INFLIGHT_TIMEOUT_MS) { - return { ...state, inFlight: false }; - } - return state; +export async function hydrateClaudeEventsFromDisk(): Promise { + const storedEvents = await readStoredClaudeEvents(); + Effect.runSync( + Ref.update(stateRef, (map) => { + let next = pruneClaudeState(map, Date.now()); + for (const stored of storedEvents) { + next = applyClaudeEvent(next, stored.event, stored.cwdKey); + } + return next; + }) + ); } -function pruneStale(map: StateMap, now: number): StateMap { - let changed = false; - const next = new Map(); - for (const [sessionId, state] of map.entries()) { - if (now - state.lastSeenAt > STALE_TTL_MS) { - changed = true; - continue; - } - const updated = expireInFlight(state, now); - if (updated !== state) changed = true; - next.set(sessionId, updated); - } - return changed ? next : map; -} +export { flushClaudeEventPersistence }; -function applyEvent(map: StateMap, event: ClaudeEvent): StateMap { - const now = typeof event.timestamp === "number" ? event.timestamp : Date.now(); - const type = normalizeType(event.type); - const notificationType = event.notificationType?.trim(); - const isIdleNotification = - type === "Notification" && notificationType?.toLowerCase() === "idle_prompt"; - const prev = map.get(event.sessionId); - const next: ClaudeSessionState = { - sessionId: event.sessionId, - inFlight: prev?.inFlight ?? false, - lastSeenAt: now, - cwd: event.cwd ?? prev?.cwd, - transcriptPath: event.transcriptPath ?? prev?.transcriptPath, - lastEvent: type, - lastActivityAt: prev?.lastActivityAt, - }; - if (INFLIGHT_EVENTS.has(type)) next.inFlight = true; - if (IDLE_EVENTS.has(type) || isIdleNotification) { - next.inFlight = false; - next.lastActivityAt = undefined; - } else if (isActivityEvent(type)) { - next.lastActivityAt = now; - } - const nextMap = new Map(map); - nextMap.set(event.sessionId, next); - return nextMap; +function updateClaudeEventState(event: ClaudeEvent): Effect.Effect { + return Ref.update(stateRef, (map) => { + const now = typeof event.timestamp === "number" ? event.timestamp : Date.now(); + return applyClaudeEvent(pruneClaudeState(map, now), event); + }); } export const handleClaudeEventEffect = (event: ClaudeEvent): Effect.Effect => - Ref.update(stateRef, (map) => { - const now = typeof event.timestamp === "number" ? event.timestamp : Date.now(); - const pruned = pruneStale(map, now); - return applyEvent(pruned, event); - }); + pipe( + updateClaudeEventState(event), + Effect.flatMap(() => { + void queueClaudeEventPersistence(event); + return Effect.succeed(undefined as void); + }), + Effect.catchAll(() => Effect.succeed(undefined as void)) + ); export const getClaudeActivityBySessionEffect = ( sessionId: string, now: number = Date.now() ): Effect.Effect => Ref.modify(stateRef, (map) => { - const pruned = pruneStale(map, now); + const pruned = pruneClaudeState(map, now); return [pruned.get(sessionId), pruned]; }); @@ -133,11 +61,12 @@ export const getClaudeActivityByCwdEffect = ( ): Effect.Effect => Ref.modify(stateRef, (map) => { if (!cwd) return [undefined, map]; - const pruned = pruneStale(map, now); + const pruned = pruneClaudeState(map, now); + const cwdKey = stableClaudePathKey(cwd); let best: ClaudeSessionState | undefined; let bestAt = 0; for (const state of pruned.values()) { - if (!state.cwd || state.cwd !== cwd) continue; + if (state.cwd !== cwd && (!cwdKey || state.cwdKey !== cwdKey)) continue; const candidateAt = state.lastActivityAt ?? state.lastSeenAt ?? 0; if (!best || candidateAt > bestAt) { best = state; @@ -147,9 +76,10 @@ export const getClaudeActivityByCwdEffect = ( return [best, pruned]; }); -// Sync wrappers for non-Effect code paths (scan.ts/tests). +// Sync wrappers for non-Effect code paths and tests. The HTTP path uses the +// Effect handler above, which also queues bounded metadata persistence. export function handleClaudeEvent(event: ClaudeEvent): void { - Effect.runSync(handleClaudeEventEffect(event)); + Effect.runSync(updateClaudeEventState(event)); } export function getClaudeActivityBySession( diff --git a/tests/unit/claudeGraph.test.ts b/tests/unit/claudeGraph.test.ts new file mode 100644 index 0000000..ab913e9 --- /dev/null +++ b/tests/unit/claudeGraph.test.ts @@ -0,0 +1,190 @@ +import { createHash } from "crypto"; +import { appendFile, mkdtemp, readFile, rm } from "fs/promises"; +import os from "os"; +import path from "path"; +import { describe, it } from "node:test"; +import { Effect } from "effect"; +import assert from "node:assert/strict"; +import { attachClaudeEvents } from "../../src/claudeSnapshot.js"; +import { buildAgentGraph } from "../../src/graph.js"; +import { + flushClaudeEventPersistence, + getClaudeActivityByCwd, + getClaudeActivityBySession, + handleClaudeEventEffect, + hydrateClaudeEventsFromDisk, +} from "../../src/services/claudeEvents.js"; +import type { ClaudeEvent } from "../../src/claude/types.js"; +import type { SnapshotPayload } from "../../src/types.js"; + +async function send( + sessionId: string, + timestamp: number, + type: string, + extra: Partial = {} +): Promise { + await Effect.runPromise( + handleClaudeEventEffect({ + type, + sessionId, + timestamp, + cwd: "/tmp/project", + ...extra, + } as ClaudeEvent) + ); +} + +function cwdKey(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 24); +} + +describe("Claude graph events", () => { + it("retains metadata-only hook history and builds prompt/model/tool phases", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-claude-")); + const logPath = path.join(directory, "events.jsonl"); + process.env.CONSENSUS_CLAUDE_EVENT_LOG = logPath; + const now = Date.now(); + const sessionId = `claude-test-${now}-a`; + + await send(sessionId, now, "UserPromptSubmit"); + await send(sessionId, now + 1, "UserPromptExpansion"); + await send(sessionId, now + 2, "MessageDisplay", { final: false }); + await send(sessionId, now + 3, "MessageDisplay", { final: true }); + await send(sessionId, now + 4, "PreToolUse", { toolName: "Bash" }); + await send(sessionId, now + 5, "PostToolUse", { toolName: "Bash" }); + await send(sessionId, now + 6, "SubagentStart", { agentType: "Explore" }); + await send(sessionId, now + 7, "SubagentStop", { agentType: "Explore" }); + + const beforeStop = getClaudeActivityBySession(sessionId, now + 7); + assert.equal(beforeStop?.inFlight, true); + + await send(sessionId, now + 8, "Stop"); + await flushClaudeEventPersistence(); + const state = getClaudeActivityBySession(sessionId, now + 8); + assert.equal(state?.inFlight, false); + assert.deepEqual( + state?.events.map((entry) => entry.type), + [ + "UserPromptSubmit", + "MessageDisplay", + "PreToolUse", + "PostToolUse", + "SubagentStart", + "SubagentStop", + "Stop", + ] + ); + assert.equal(state?.summary.lastPrompt, "prompt"); + assert.equal(state?.summary.lastMessage, "message"); + assert.equal(state?.summary.lastTool, "tool: subagent Explore"); + + const snapshot: SnapshotPayload = { + ts: now + 8, + agents: [ + { + identity: `claude:${sessionId}`, + id: "55", + pid: 55, + cmd: "claude", + cmdShort: "claude", + kind: "claude-tui", + cpu: 0, + mem: 0, + state: "idle", + cwd: "/tmp/project", + sessionPath: `claude:${sessionId}`, + }, + ], + }; + const enriched = attachClaudeEvents(snapshot); + assert.equal(enriched.agents[0].events?.length, 7); + + const graph = buildAgentGraph(enriched); + assert.deepEqual( + graph.nodes + .filter((node) => node.kind === "step") + .map((node) => node.phase) + .sort(), + ["model", "prompt", "tool"] + ); + + const persisted = await readFile(logPath, "utf8"); + assert.ok(!persisted.includes("/tmp/project")); + assert.ok(!persisted.includes("transcript")); + assert.ok(!persisted.includes("turn_id")); + assert.ok(!persisted.includes("message_id")); + assert.ok(!persisted.includes("tool_input")); + assert.ok(!persisted.includes("delta")); + + delete process.env.CONSENSUS_CLAUDE_EVENT_LOG; + await rm(directory, { recursive: true, force: true }); + }); + + it("hydrates a fresh session from the bounded metadata log by opaque cwd key", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-claude-")); + const logPath = path.join(directory, "events.jsonl"); + process.env.CONSENSUS_CLAUDE_EVENT_LOG = logPath; + const now = Date.now(); + const sessionId = `persisted-${now}`; + const cwd = "/Users/private/work/project"; + const stored = [ + { + version: 1, + type: "UserPromptSubmit", + sessionId, + timestamp: now, + cwdKey: cwdKey(cwd), + }, + { + version: 1, + type: "MessageDisplay", + sessionId, + timestamp: now + 1, + cwdKey: cwdKey(cwd), + final: true, + }, + { + version: 1, + type: "Stop", + sessionId, + timestamp: now + 2, + cwdKey: cwdKey(cwd), + }, + ]; + await appendFile(logPath, `${stored.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + + await hydrateClaudeEventsFromDisk(); + const state = getClaudeActivityByCwd(cwd, now + 2); + assert.equal(state?.sessionId, sessionId); + assert.deepEqual(state?.events.map((entry) => entry.type), [ + "UserPromptSubmit", + "MessageDisplay", + "Stop", + ]); + assert.equal(state?.cwd, undefined); + assert.equal(state?.cwdKey, cwdKey(cwd)); + + delete process.env.CONSENSUS_CLAUDE_EVENT_LOG; + await rm(directory, { recursive: true, force: true }); + }); + + it("marks StopFailure as a session error without storing error details", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-claude-")); + const logPath = path.join(directory, "events.jsonl"); + process.env.CONSENSUS_CLAUDE_EVENT_LOG = logPath; + const now = Date.now(); + const sessionId = `claude-test-${now}-b`; + await send(sessionId, now, "UserPromptSubmit"); + await send(sessionId, now + 1, "StopFailure"); + await flushClaudeEventPersistence(); + const state = getClaudeActivityBySession(sessionId, now + 1); + assert.equal(state?.inFlight, false); + assert.equal(state?.hasError, true); + assert.equal(state?.events.at(-1)?.summary, "event: StopFailure"); + const persisted = await readFile(logPath, "utf8"); + assert.ok(!persisted.includes("error_details")); + + delete process.env.CONSENSUS_CLAUDE_EVENT_LOG; + await rm(directory, { recursive: true, force: true }); + }); +}); From 8cbb8d0555149fdf65a43e5a903130ab36ab27f7 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:05:43 -0400 Subject: [PATCH 09/72] fix(graph): harden saved snapshot validation --- src/cli/graph.ts | 106 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 85 insertions(+), 21 deletions(-) diff --git a/src/cli/graph.ts b/src/cli/graph.ts index 248c1a2..3aaa7c5 100644 --- a/src/cli/graph.ts +++ b/src/cli/graph.ts @@ -6,6 +6,7 @@ import type { AgentState, EventSummary, SnapshotPayload, + WorkSummary, } from "../types.js"; type SnapshotScanner = (options?: { @@ -29,6 +30,12 @@ const agentKinds = new Set([ "unknown", ]); const agentStates = new Set(["active", "idle", "error"]); +const MAX_AGENTS = 10_000; +const MAX_EVENTS_PER_AGENT = 10_000; +const MAX_TEXT_LENGTH = 16_384; +const MAX_EVENT_TYPE_LENGTH = 256; +const MAX_EVENT_SUMMARY_LENGTH = 4_096; +const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/; function graphHelp(): string { return [ @@ -66,24 +73,67 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } -function isOptionalString(value: unknown): boolean { - return value === undefined || typeof value === "string"; +function isNonNegativeFiniteNumber(value: unknown): value is number { + return isFiniteNumber(value) && value >= 0; +} + +function isNonNegativeInteger(value: unknown): value is number { + return Number.isInteger(value) && (value as number) >= 0; +} + +function isSafeString(value: unknown, maxLength: number = MAX_TEXT_LENGTH): value is string { + return ( + typeof value === "string" && + value.length <= maxLength && + !CONTROL_CHARACTER_RE.test(value) + ); +} + +function isNonEmptySafeString( + value: unknown, + maxLength: number = MAX_TEXT_LENGTH +): value is string { + return isSafeString(value, maxLength) && value.length > 0; +} + +function isOptionalSafeString( + value: unknown, + maxLength: number = MAX_TEXT_LENGTH +): boolean { + return value === undefined || isSafeString(value, maxLength); +} + +function isOptionalTimestamp(value: unknown): boolean { + return value === undefined || isNonNegativeFiniteNumber(value); } function isOptionalTurnId(value: unknown): boolean { return ( value === undefined || - typeof value === "string" || + isNonEmptySafeString(value, MAX_EVENT_TYPE_LENGTH) || (typeof value === "number" && Number.isFinite(value)) ); } +function isWorkSummary(value: unknown): value is WorkSummary | undefined { + if (value === undefined) return true; + if (!isRecord(value)) return false; + return [ + value.current, + value.lastCommand, + value.lastEdit, + value.lastMessage, + value.lastTool, + value.lastPrompt, + ].every((entry) => isOptionalSafeString(entry, MAX_EVENT_SUMMARY_LENGTH)); +} + function isEventSummary(value: unknown): value is EventSummary { if (!isRecord(value)) return false; return ( - isFiniteNumber(value.ts) && - typeof value.type === "string" && - typeof value.summary === "string" && + isNonNegativeFiniteNumber(value.ts) && + isNonEmptySafeString(value.type, MAX_EVENT_TYPE_LENGTH) && + isSafeString(value.summary, MAX_EVENT_SUMMARY_LENGTH) && (value.isError === undefined || typeof value.isError === "boolean") && isOptionalTurnId(value.turnId) ); @@ -92,32 +142,45 @@ function isEventSummary(value: unknown): value is EventSummary { function isAgentSnapshot(value: unknown): value is AgentSnapshot { if (!isRecord(value)) return false; if ( - typeof value.id !== "string" || - !isFiniteNumber(value.pid) || - typeof value.cmd !== "string" || - typeof value.cmdShort !== "string" || + !isNonEmptySafeString(value.id) || + !isNonNegativeInteger(value.pid) || + !isNonEmptySafeString(value.cmd) || + !isNonEmptySafeString(value.cmdShort) || typeof value.kind !== "string" || !agentKinds.has(value.kind as AgentKind) || typeof value.state !== "string" || !agentStates.has(value.state as AgentState) || - !isFiniteNumber(value.cpu) || - !isFiniteNumber(value.mem) + !isNonNegativeFiniteNumber(value.cpu) || + !isNonNegativeFiniteNumber(value.mem) + ) { + return false; + } + if ( + !isOptionalSafeString(value.identity) || + !isOptionalSafeString(value.title) || + !isOptionalSafeString(value.doing) || + !isOptionalSafeString(value.sessionPath) || + !isOptionalSafeString(value.repo) || + !isOptionalSafeString(value.cwd) || + !isOptionalSafeString(value.model) || + !isOptionalSafeString(value.activityReason) ) { return false; } if ( - !isOptionalString(value.identity) || - !isOptionalString(value.title) || - !isOptionalString(value.doing) || - !isOptionalString(value.sessionPath) || - !isOptionalString(value.repo) || - !isOptionalString(value.cwd) || - !isOptionalString(value.model) + !isOptionalTimestamp(value.startedAt) || + !isOptionalTimestamp(value.lastEventAt) || + !isOptionalTimestamp(value.lastActivityAt) || + !isWorkSummary(value.summary) ) { return false; } if (value.events !== undefined) { - if (!Array.isArray(value.events) || !value.events.every(isEventSummary)) { + if ( + !Array.isArray(value.events) || + value.events.length > MAX_EVENTS_PER_AGENT || + !value.events.every(isEventSummary) + ) { return false; } } @@ -134,8 +197,9 @@ export function parseSnapshot(input: string, source: string): SnapshotPayload { if ( !isRecord(parsed) || - !isFiniteNumber(parsed.ts) || + !isNonNegativeFiniteNumber(parsed.ts) || !Array.isArray(parsed.agents) || + parsed.agents.length > MAX_AGENTS || !parsed.agents.every(isAgentSnapshot) ) { throw new Error(`invalid snapshot payload from ${source}`); From 22c16a64e6b665dd62ae6ab727a30720cb8d8e95 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:06:18 -0400 Subject: [PATCH 10/72] test(graph): cover hostile and malformed snapshots --- tests/unit/graphCli.test.ts | 98 +++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/unit/graphCli.test.ts b/tests/unit/graphCli.test.ts index a2f2905..6249cfe 100644 --- a/tests/unit/graphCli.test.ts +++ b/tests/unit/graphCli.test.ts @@ -10,12 +10,20 @@ const validSnapshot: SnapshotPayload = { identity: "/Users/alice/.codex/sessions/secret.jsonl", id: "101", pid: 101, + startedAt: 100, + lastEventAt: 900, + lastActivityAt: 900, + activityReason: "tail_event", cmd: "codex", cmdShort: "codex", kind: "tui", cpu: 0, mem: 100, state: "idle", + summary: { + current: "message", + lastMessage: "message", + }, events: [ { ts: 900, @@ -70,6 +78,96 @@ describe("graph CLI snapshot contract", () => { ); }); + it("rejects non-finite and negative timestamps", () => { + assert.throws( + () => parseSnapshot('{"ts":1e400,"agents":[]}', "test"), + /invalid snapshot payload from test/ + ); + assert.throws( + () => + parseSnapshot( + JSON.stringify({ ...validSnapshot, ts: -1 }), + "test" + ), + /invalid snapshot payload from test/ + ); + assert.throws( + () => + parseSnapshot( + JSON.stringify({ + ...validSnapshot, + agents: [{ ...validSnapshot.agents[0], lastEventAt: null }], + }), + "test" + ), + /invalid snapshot payload from test/ + ); + }); + + it("rejects invalid process metrics", () => { + for (const patch of [ + { pid: 1.5 }, + { pid: -1 }, + { cpu: -0.1 }, + { mem: -1 }, + ]) { + assert.throws( + () => + parseSnapshot( + JSON.stringify({ + ...validSnapshot, + agents: [{ ...validSnapshot.agents[0], ...patch }], + }), + "test" + ), + /invalid snapshot payload from test/ + ); + } + }); + + it("rejects terminal control characters in exported labels", () => { + const malformedTitle = { + ...validSnapshot, + agents: [{ ...validSnapshot.agents[0], title: "worker\u001b[2J" }], + }; + const malformedEvent = { + ...validSnapshot, + agents: [ + { + ...validSnapshot.agents[0], + events: [{ ts: 900, type: "message\nforged", summary: "message" }], + }, + ], + }; + + assert.throws( + () => parseSnapshot(JSON.stringify(malformedTitle), "test"), + /invalid snapshot payload from test/ + ); + assert.throws( + () => parseSnapshot(JSON.stringify(malformedEvent), "test"), + /invalid snapshot payload from test/ + ); + }); + + it("rejects unbounded retained event arrays", () => { + const event = { ts: 900, type: "message", summary: "message" }; + const malformed = { + ...validSnapshot, + agents: [ + { + ...validSnapshot.agents[0], + events: Array.from({ length: 10_001 }, () => event), + }, + ], + }; + + assert.throws( + () => parseSnapshot(JSON.stringify(malformed), "test"), + /invalid snapshot payload from test/ + ); + }); + it("disables OpenCode autostart during live graph scans and restores the environment", async () => { const previous = process.env.CONSENSUS_OPENCODE_AUTOSTART; process.env.CONSENSUS_OPENCODE_AUTOSTART = "1"; From 0569a94537ee338857ff26e45e4463a1b8779e73 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:07:19 -0400 Subject: [PATCH 11/72] fix(claude): make event persistence bounded and idempotent --- src/services/claudeEventLog.ts | 65 ++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/src/services/claudeEventLog.ts b/src/services/claudeEventLog.ts index 5b9daaf..14d0321 100644 --- a/src/services/claudeEventLog.ts +++ b/src/services/claudeEventLog.ts @@ -6,9 +6,9 @@ import { rename, stat, writeFile, -} from "fs/promises"; -import { homedir } from "os"; -import path from "path"; +} from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; import type { ClaudeEvent } from "../claude/types.js"; import { CLAUDE_STALE_TTL_MS, @@ -22,16 +22,24 @@ import { const DEFAULT_EVENT_LOG_MAX_BYTES = 1024 * 1024; const MAX_SEEN_STORED_EVENTS = 5000; const seenStoredEvents = new Set(); +let seenEventLogPath: string | undefined; let persistenceQueue: Promise = Promise.resolve(); -function rememberStoredEvent(key: string): void { - if (seenStoredEvents.has(key)) return; +function useSeenEventLogPath(filePath: string): void { + if (seenEventLogPath === filePath) return; + seenStoredEvents.clear(); + seenEventLogPath = filePath; +} + +function rememberStoredEvent(key: string): boolean { + if (seenStoredEvents.has(key)) return false; seenStoredEvents.add(key); while (seenStoredEvents.size > MAX_SEEN_STORED_EVENTS) { const oldest = seenStoredEvents.values().next().value; if (typeof oldest !== "string") break; seenStoredEvents.delete(oldest); } + return true; } function resolveEventLogPath(): string | undefined { @@ -41,7 +49,7 @@ function resolveEventLogPath(): string | undefined { if (lowered === "0" || lowered === "false" || lowered === "off") { return undefined; } - return configured; + return path.resolve(configured); } return path.join(homedir(), ".consensus", "claude-events.jsonl"); } @@ -52,6 +60,13 @@ function resolveEventLogMaxBytes(): number { return Math.max(64 * 1024, Math.floor(parsed)); } +function newestCompleteLines(buffer: Buffer, keepBytes: number): Buffer { + if (buffer.length <= keepBytes) return buffer; + const tail = buffer.subarray(Math.max(0, buffer.length - keepBytes)); + const firstNewline = tail.indexOf(0x0a); + return firstNewline >= 0 ? tail.subarray(firstNewline + 1) : Buffer.alloc(0); +} + async function trimEventLog(filePath: string, maxBytes: number): Promise { let info; try { @@ -61,24 +76,21 @@ async function trimEventLog(filePath: string, maxBytes: number): Promise { } if (info.size <= maxBytes) return; - const content = await readFile(filePath, "utf8"); - const keepCharacters = Math.max(32 * 1024, Math.floor(maxBytes / 2)); - let trimmed = content.slice(-keepCharacters); - if (trimmed.length < content.length) { - const firstNewline = trimmed.indexOf("\n"); - if (firstNewline >= 0) trimmed = trimmed.slice(firstNewline + 1); - } + const content = await readFile(filePath); + const keepBytes = Math.max(32 * 1024, Math.floor(maxBytes / 2)); + const trimmed = newestCompleteLines(content, keepBytes); const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random() .toString(16) .slice(2)}`; - await writeFile(tempPath, trimmed, { encoding: "utf8", mode: 0o600 }); + await writeFile(tempPath, trimmed, { mode: 0o600 }); await rename(tempPath, filePath); await chmod(filePath, 0o600).catch(() => undefined); } -async function persistStoredEvent(event: StoredClaudeEvent): Promise { - const filePath = resolveEventLogPath(); - if (!filePath) return; +async function persistStoredEvent( + filePath: string, + event: StoredClaudeEvent +): Promise { await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); await appendFile(filePath, `${JSON.stringify(event)}\n`, { encoding: "utf8", @@ -90,12 +102,18 @@ async function persistStoredEvent(event: StoredClaudeEvent): Promise { export function queueClaudeEventPersistence(event: ClaudeEvent): Promise { const stored = toStoredClaudeEvent(event); - if (!stored) return persistenceQueue; + const filePath = resolveEventLogPath(); + if (!stored || !filePath) return persistenceQueue; + + useSeenEventLogPath(filePath); const key = storedClaudeEventKey(stored); - rememberStoredEvent(key); + if (!rememberStoredEvent(key)) return persistenceQueue; + persistenceQueue = persistenceQueue - .then(() => persistStoredEvent(stored)) - .catch(() => undefined); + .then(() => persistStoredEvent(filePath, stored)) + .catch(() => { + seenStoredEvents.delete(key); + }); return persistenceQueue; } @@ -108,6 +126,8 @@ export async function readStoredClaudeEvents(): Promise< > { const filePath = resolveEventLogPath(); if (!filePath) return []; + useSeenEventLogPath(filePath); + let input: string; try { input = await readFile(filePath, "utf8"); @@ -132,8 +152,7 @@ export async function readStoredClaudeEvents(): Promise< const result: Array<{ event: ClaudeEvent; cwdKey?: string }> = []; for (const stored of storedEvents) { const key = storedClaudeEventKey(stored); - if (seenStoredEvents.has(key)) continue; - rememberStoredEvent(key); + if (!rememberStoredEvent(key)) continue; result.push({ event: fromStoredClaudeEvent(stored), cwdKey: stored.cwdKey }); } return result; From 4bbb425e146b25832e28b68a440cba6ea5df9312 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:07:55 -0400 Subject: [PATCH 12/72] test(claude): cover persistence dedupe and byte bounds --- tests/unit/claudeEventLog.test.ts | 107 ++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/unit/claudeEventLog.test.ts diff --git a/tests/unit/claudeEventLog.test.ts b/tests/unit/claudeEventLog.test.ts new file mode 100644 index 0000000..4487c0b --- /dev/null +++ b/tests/unit/claudeEventLog.test.ts @@ -0,0 +1,107 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + flushClaudeEventPersistence, + queueClaudeEventPersistence, +} from "../../src/services/claudeEventLog.js"; +import type { ClaudeEvent } from "../../src/claude/types.js"; + +function event( + sessionId: string, + timestamp: number, + extra: Partial = {} +): ClaudeEvent { + return { + type: "SubagentStart", + sessionId, + timestamp, + agentType: "Explore", + ...extra, + } as ClaudeEvent; +} + +async function withEventLog( + run: (directory: string, logPath: string) => Promise +): Promise { + const previousPath = process.env.CONSENSUS_CLAUDE_EVENT_LOG; + const previousMax = process.env.CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES; + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-claude-log-")); + const logPath = path.join(directory, "events.jsonl"); + process.env.CONSENSUS_CLAUDE_EVENT_LOG = logPath; + + try { + await run(directory, logPath); + } finally { + await flushClaudeEventPersistence(); + if (previousPath === undefined) { + delete process.env.CONSENSUS_CLAUDE_EVENT_LOG; + } else { + process.env.CONSENSUS_CLAUDE_EVENT_LOG = previousPath; + } + if (previousMax === undefined) { + delete process.env.CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES; + } else { + process.env.CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES = previousMax; + } + await rm(directory, { recursive: true, force: true }); + } +} + +describe("Claude event log", { concurrency: false }, () => { + it("persists duplicate hook deliveries only once", async () => { + await withEventLog(async (_directory, logPath) => { + const duplicate = event("duplicate-session", Date.now()); + void queueClaudeEventPersistence(duplicate); + void queueClaudeEventPersistence(duplicate); + await flushClaudeEventPersistence(); + + const lines = (await readFile(logPath, "utf8")) + .split(/\r?\n/) + .filter(Boolean); + assert.equal(lines.length, 1); + }); + }); + + it("captures the configured path when a write is queued", async () => { + await withEventLog(async (directory, firstPath) => { + const secondPath = path.join(directory, "later.jsonl"); + const queued = queueClaudeEventPersistence( + event("path-session", Date.now()) + ); + process.env.CONSENSUS_CLAUDE_EVENT_LOG = secondPath; + await queued; + + const first = await readFile(firstPath, "utf8"); + assert.match(first, /path-session/); + await assert.rejects(() => readFile(secondPath, "utf8")); + }); + }); + + it("enforces the byte limit with multibyte metadata and keeps valid JSON lines", async () => { + await withEventLog(async (_directory, logPath) => { + process.env.CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES = String(64 * 1024); + const now = Date.now(); + const agentType = "🚀".repeat(80); + + for (let index = 0; index < 500; index += 1) { + void queueClaudeEventPersistence( + event(`unicode-${index}`, now + index, { agentType }) + ); + } + await flushClaudeEventPersistence(); + + const info = await stat(logPath); + assert.ok(info.size <= 64 * 1024, `event log was ${info.size} bytes`); + const lines = (await readFile(logPath, "utf8")) + .split(/\r?\n/) + .filter(Boolean); + assert.ok(lines.length > 0); + for (const line of lines) { + assert.doesNotThrow(() => JSON.parse(line)); + } + }); + }); +}); From de58b954e65cc109273ef7f60c86f0f67585033d Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:10:51 -0400 Subject: [PATCH 13/72] fix(graph): close remaining turn and output edge cases --- src/graph.ts | 94 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/src/graph.ts b/src/graph.ts index 3cdfb56..05895ad 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -40,6 +40,38 @@ interface IndexedEvent { index: number; } +const LIFECYCLE_OR_META_TYPES = new Set([ + "agentturncomplete", + "sessionstart", + "sessionend", + "setup", + "stop", + "stopfailure", + "userpromptexpansion", + "precompact", + "postcompact", + "notification", + "instructionsloaded", + "configchange", + "cwdchanged", + "filechanged", + "worktreecreate", + "worktreeremove", + "teammateidle", + "serverconnected", + "serverdisconnected", + "heartbeat", + "connected", + "ready", + "ping", + "pong", + "snapshot", + "history", + "tokencount", +]); +const GRAPH_TEXT_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g; +const MAX_GRAPH_LABEL_LENGTH = 240; + function identityForAgent(agent: AgentSnapshot): string { return agent.identity || agent.id; } @@ -62,6 +94,21 @@ function opaqueAgentKey(provider: string, identity: string): string { return `${provider}:${digest}`; } +function graphText(value: string | undefined, fallback: string): string { + const normalized = value + ?.replace(GRAPH_TEXT_CONTROL_RE, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_GRAPH_LABEL_LENGTH); + return normalized || fallback; +} + +function optionalGraphText(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const normalized = graphText(value, ""); + return normalized || undefined; +} + function idPart(value: string): string { return encodeURIComponent(value); } @@ -104,38 +151,7 @@ function isLifecycleOrMetaEvent(event: EventSummary): boolean { return true; } - if ( - new Set([ - "agentturncomplete", - "sessionstart", - "sessionend", - "setup", - "stop", - "stopfailure", - "precompact", - "postcompact", - "notification", - "instructionsloaded", - "configchange", - "cwdchanged", - "filechanged", - "worktreecreate", - "worktreeremove", - "teammateidle", - "serverconnected", - "serverdisconnected", - "heartbeat", - "connected", - "ready", - "ping", - "pong", - "snapshot", - "history", - "tokencount", - ]).has(compact) - ) { - return true; - } + if (LIFECYCLE_OR_META_TYPES.has(compact)) return true; return ( summary.startsWith("event:") && @@ -146,6 +162,8 @@ function isLifecycleOrMetaEvent(event: EventSummary): boolean { } function phaseForEvent(event: EventSummary): string | undefined { + if (isLifecycleOrMetaEvent(event)) return undefined; + const summary = normalizedSummary(event); const eventType = normalizedEventType(event); const lowerType = eventType.toLowerCase(); @@ -175,11 +193,10 @@ function phaseForEvent(event: EventSummary): string | undefined { ) { return "model"; } - if (isLifecycleOrMetaEvent(event)) return undefined; if (summary && !summary.startsWith("event:") && !summary.startsWith("compaction:")) { return "model"; } - return eventType; + return graphText(eventType, "event"); } function isTurnStartEvent(event: EventSummary): boolean { @@ -296,7 +313,7 @@ function historyStatusForProvider( return { history: "unavailable", note: - "Claude activity is visible, but Claude hook history is not yet retained in SnapshotPayload.events.", + "No retained Claude hook events were available; hooks may be unconfigured, history may be disabled, or events may be outside the retention window.", }; } if (provider === "codex" || provider === "opencode") { @@ -427,11 +444,11 @@ export function buildAgentGraph(snapshot: SnapshotPayload): AgentGraphSnapshot { nodes.set(rootNodeId, { id: rootNodeId, kind: "agent", - label: agent.title || agent.doing || `${provider} agent`, + label: graphText(agent.title || agent.doing, `${provider} agent`), state: agent.state, agentKey, provider, - repo: agent.repo, + repo: optionalGraphText(agent.repo), firstSeenAt: typeof agent.startedAt === "number" ? agent.startedAt * 1000 : undefined, lastSeenAt: maxDefined([agent.lastActivityAt, agent.lastEventAt, snapshot.ts]), @@ -451,6 +468,7 @@ export function buildAgentGraph(snapshot: SnapshotPayload): AgentGraphSnapshot { activeTurnId = turnId; previousStepNodeId = undefined; segmentHasPhase = false; + latestStepNodeId = undefined; return activeSegment; }; @@ -507,7 +525,7 @@ export function buildAgentGraph(snapshot: SnapshotPayload): AgentGraphSnapshot { state: "idle", agentKey, provider, - repo: agent.repo, + repo: optionalGraphText(agent.repo), phase, segment: activeSegment, hadError: !!event.isError, From 8a3c70310a8a903b7fa8eaf5807497344c7631c0 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:13:41 -0400 Subject: [PATCH 14/72] fix(graph): enforce turn scope in loop analysis --- src/graphLoops.ts | 134 ++++++++++++++++++++++++++++++---------------- 1 file changed, 87 insertions(+), 47 deletions(-) diff --git a/src/graphLoops.ts b/src/graphLoops.ts index fb3421f..3652b5e 100644 --- a/src/graphLoops.ts +++ b/src/graphLoops.ts @@ -28,67 +28,105 @@ function maxDefined(values: Array): number | undefined { return max; } -function findStronglyConnectedComponents( +function sortedAdjacency( nodeIds: string[], - edges: AgentGraphEdge[] -): string[][] { - const adjacency = new Map(); - for (const nodeId of nodeIds) adjacency.set(nodeId, []); + edges: AgentGraphEdge[], + reverse = false +): Map { + const targetSets = new Map>(); + for (const nodeId of nodeIds) targetSets.set(nodeId, new Set()); + for (const edge of edges) { - if (edge.kind !== "transition") continue; - if (!adjacency.has(edge.source) || !adjacency.has(edge.target)) continue; - adjacency.get(edge.source)?.push(edge.target); + const source = reverse ? edge.target : edge.source; + const target = reverse ? edge.source : edge.target; + const targets = targetSets.get(source); + if (!targets || !targetSets.has(target)) continue; + targets.add(target); } - for (const targets of adjacency.values()) targets.sort(); - let nextIndex = 0; - const indexByNode = new Map(); - const lowLinkByNode = new Map(); - const stack: string[] = []; - const onStack = new Set(); - const components: string[][] = []; + return new Map( + [...targetSets.entries()].map(([nodeId, targets]) => [ + nodeId, + [...targets].sort(), + ]) + ); +} + +function findStronglyConnectedComponents( + nodeIds: string[], + edges: AgentGraphEdge[] +): string[][] { + const uniqueNodeIds = [...new Set(nodeIds)].sort(); + const adjacency = sortedAdjacency(uniqueNodeIds, edges); + const reverseAdjacency = sortedAdjacency(uniqueNodeIds, edges, true); + const visited = new Set(); + const finishOrder: string[] = []; + + for (const startNodeId of uniqueNodeIds) { + if (visited.has(startNodeId)) continue; + visited.add(startNodeId); + const stack: Array<{ nodeId: string; nextTargetIndex: number }> = [ + { nodeId: startNodeId, nextTargetIndex: 0 }, + ]; - const visit = (nodeId: string): void => { - indexByNode.set(nodeId, nextIndex); - lowLinkByNode.set(nodeId, nextIndex); - nextIndex += 1; - stack.push(nodeId); - onStack.add(nodeId); - - for (const target of adjacency.get(nodeId) ?? []) { - if (!indexByNode.has(target)) { - visit(target); - lowLinkByNode.set( - nodeId, - Math.min(lowLinkByNode.get(nodeId) ?? 0, lowLinkByNode.get(target) ?? 0) - ); - } else if (onStack.has(target)) { - lowLinkByNode.set( - nodeId, - Math.min(lowLinkByNode.get(nodeId) ?? 0, indexByNode.get(target) ?? 0) - ); + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const targets = adjacency.get(frame.nodeId) ?? []; + if (frame.nextTargetIndex < targets.length) { + const target = targets[frame.nextTargetIndex]; + frame.nextTargetIndex += 1; + if (!visited.has(target)) { + visited.add(target); + stack.push({ nodeId: target, nextTargetIndex: 0 }); + } + continue; } + + stack.pop(); + finishOrder.push(frame.nodeId); } + } - if (lowLinkByNode.get(nodeId) !== indexByNode.get(nodeId)) return; + const assigned = new Set(); + const components: string[][] = []; + for (let index = finishOrder.length - 1; index >= 0; index -= 1) { + const startNodeId = finishOrder[index]; + if (assigned.has(startNodeId)) continue; const component: string[] = []; + const stack = [startNodeId]; + assigned.add(startNodeId); while (stack.length > 0) { - const member = stack.pop(); - if (!member) break; - onStack.delete(member); - component.push(member); - if (member === nodeId) break; + const nodeId = stack.pop(); + if (!nodeId) continue; + component.push(nodeId); + for (const target of reverseAdjacency.get(nodeId) ?? []) { + if (assigned.has(target)) continue; + assigned.add(target); + stack.push(target); + } } component.sort(); components.push(component); - }; - - for (const nodeId of [...nodeIds].sort()) { - if (!indexByNode.has(nodeId)) visit(nodeId); } - return components; + return components.sort((a, b) => (a[0] ?? "").localeCompare(b[0] ?? "")); +} + +function isTurnScopedTransition( + edge: AgentGraphEdge, + stepNodeById: Map +): boolean { + if (edge.kind !== "transition") return false; + const source = stepNodeById.get(edge.source); + const target = stepNodeById.get(edge.target); + if (!source || !target) return false; + return ( + source.agentKey === target.agentKey && + source.provider === target.provider && + typeof source.segment === "number" && + source.segment === target.segment + ); } export function detectGraphLoops( @@ -97,14 +135,16 @@ export function detectGraphLoops( ): AgentGraphLoop[] { const stepNodes = nodes.filter((node) => node.kind === "step"); const stepNodeById = new Map(stepNodes.map((node) => [node.id, node])); - const transitionEdges = edges.filter((edge) => edge.kind === "transition"); + const transitionEdges = edges.filter((edge) => + isTurnScopedTransition(edge, stepNodeById) + ); const selfLoopNodeIds = new Set( transitionEdges .filter((edge) => edge.source === edge.target) .map((edge) => edge.source) ); const components = findStronglyConnectedComponents( - stepNodes.map((node) => node.id), + [...stepNodeById.keys()], transitionEdges ); From 23623291c7eadd9152aaca8112a86deb2a4ac217 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:14:55 -0400 Subject: [PATCH 15/72] test(graph): cover turn, scope, and stack hardening --- tests/unit/graphHardening.test.ts | 214 ++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/unit/graphHardening.test.ts diff --git a/tests/unit/graphHardening.test.ts b/tests/unit/graphHardening.test.ts new file mode 100644 index 0000000..9b1b7a5 --- /dev/null +++ b/tests/unit/graphHardening.test.ts @@ -0,0 +1,214 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + analyzeAgentGraph, + buildAgentGraph, + formatAgentGraph, + type AgentGraphEdge, + type AgentGraphNode, +} from "../../src/graph.js"; +import type { + AgentSnapshot, + EventSummary, + SnapshotPayload, +} from "../../src/types.js"; + +function event( + ts: number, + type: string, + summary: string = type, + turnId?: string +): EventSummary { + return { ts, type, summary, ...(turnId ? { turnId } : {}) }; +} + +function snapshot( + events: EventSummary[], + overrides: Partial = {} +): SnapshotPayload { + return { + ts: 10_000, + agents: [ + { + identity: "codex:hardening-session", + id: "901", + pid: 901, + cmd: "codex", + cmdShort: "codex", + kind: "tui", + cpu: 0, + mem: 100, + state: "active", + events, + ...overrides, + }, + ], + }; +} + +function stepNode( + id: string, + agentKey: string, + segment: number, + phase: string +): AgentGraphNode { + return { + id, + kind: "step", + label: phase, + state: "idle", + agentKey, + provider: "codex", + segment, + phase, + }; +} + +function transition( + id: string, + source: string, + target: string +): AgentGraphEdge { + return { + id, + source, + target, + kind: "transition", + observations: 1, + }; +} + +describe("agent graph hardening", () => { + it("does not reactivate a historical loop when a new turn starts without a phase", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "turn.started", "event: turn.started", "turn-1"), + event(2, "response_item", "thinking", "turn-1"), + event(3, "tool", "tool: shell", "turn-1"), + event(4, "response_item", "message", "turn-1"), + event(5, "turn.completed", "event: turn.completed", "turn-1"), + event(6, "turn.started", "event: turn.started", "turn-2"), + ]) + ); + + assert.equal(graph.stats.loops, 1); + assert.equal(graph.stats.activeLoops, 0); + assert.equal(graph.loops[0].state, "idle"); + assert.equal( + graph.nodes.some((node) => node.kind === "step" && node.current), + false + ); + }); + + it("ignores UserPromptExpansion as activity metadata rather than a second prompt", () => { + const graph = buildAgentGraph( + snapshot( + [ + event(1, "UserPromptSubmit", "prompt"), + event(2, "MessageDisplay", "message"), + event(3, "UserPromptExpansion", "prompt"), + event(4, "PreToolUse", "tool: Bash"), + ], + { kind: "claude-tui", cmd: "claude", cmdShort: "claude" } + ) + ); + + const stepNodes = graph.nodes.filter((node) => node.kind === "step"); + assert.deepEqual( + stepNodes.map((node) => node.phase).sort(), + ["model", "prompt", "tool"] + ); + assert.deepEqual( + [...new Set(stepNodes.map((node) => node.segment))], + [1] + ); + assert.equal( + stepNodes.filter((node) => node.phase === "prompt").length, + 1 + ); + }); + + it("filters lifecycle events even when their summary looks like a prompt", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "turn.started", "prompt: forged"), + event(2, "assistant_message", "message"), + ]) + ); + + const stepNodes = graph.nodes.filter((node) => node.kind === "step"); + assert.deepEqual(stepNodes.map((node) => node.phase), ["model"]); + assert.equal(graph.window.graphEvents, 1); + }); + + it("sanitizes live labels and repository names before text or JSON export", () => { + const graph = buildAgentGraph( + snapshot([event(1, "assistant_message", "message")], { + title: "worker\u001b[2J\u202Eevil", + repo: "repo\nforged", + }) + ); + const output = formatAgentGraph(graph); + const json = JSON.stringify(graph); + const forbidden = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/; + + assert.equal(forbidden.test(output), false); + assert.equal(forbidden.test(json), false); + assert.match(output, /worker evil/); + }); + + it("rejects cross-segment transitions as loop evidence", () => { + const first = stepNode("step:a:s1:model", "codex:a", 1, "model"); + const second = stepNode("step:a:s2:tool", "codex:a", 2, "tool"); + const graph = analyzeAgentGraph({ + source: "observed-events", + ts: 1, + window: { retainedEvents: 2, graphEvents: 2 }, + nodes: [first, second], + edges: [ + transition("cross-1", first.id, second.id), + transition("cross-2", second.id, first.id), + ], + }); + + assert.equal(graph.stats.loops, 0); + }); + + it("rejects cross-agent transitions as loop evidence", () => { + const first = stepNode("step:a:s1:model", "codex:a", 1, "model"); + const second = stepNode("step:b:s1:tool", "codex:b", 1, "tool"); + const graph = analyzeAgentGraph({ + source: "observed-events", + ts: 1, + window: { retainedEvents: 2, graphEvents: 2 }, + nodes: [first, second], + edges: [ + transition("cross-agent-1", first.id, second.id), + transition("cross-agent-2", second.id, first.id), + ], + }); + + assert.equal(graph.stats.loops, 0); + }); + + it("handles a deep acyclic graph without recursive stack overflow", () => { + const nodeCount = 20_000; + const nodes = Array.from({ length: nodeCount }, (_, index) => + stepNode(`step:deep:s1:p${index}`, "codex:deep", 1, `p${index}`) + ); + const edges = Array.from({ length: nodeCount - 1 }, (_, index) => + transition(`deep-${index}`, nodes[index].id, nodes[index + 1].id) + ); + + const graph = analyzeAgentGraph({ + source: "observed-events", + ts: 1, + window: { retainedEvents: nodeCount, graphEvents: nodeCount }, + nodes, + edges, + }); + + assert.equal(graph.stats.loops, 0); + assert.equal(graph.stats.transitionEdges, nodeCount - 1); + }); +}); From 01cd796c1d6f65f3a579478244fde0d1bf1688e1 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:15:51 -0400 Subject: [PATCH 16/72] test(graph): align Claude coverage contract --- tests/unit/graph.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/graph.test.ts b/tests/unit/graph.test.ts index 853758d..60f52b0 100644 --- a/tests/unit/graph.test.ts +++ b/tests/unit/graph.test.ts @@ -266,7 +266,7 @@ describe("agent graph", () => { assert.match(agentNode?.agentKey ?? "", /^codex:[a-f0-9]{20}$/); }); - it("marks Claude graph history unavailable instead of claiming phase coverage", () => { + it("marks Claude graph history unavailable when no retained events exist", () => { const graph = buildAgentGraph({ ts: 1_000, agents: [ @@ -279,7 +279,7 @@ describe("agent graph", () => { assert.equal(graph.coverage.providers.claude.history, "unavailable"); assert.match( graph.coverage.providers.claude.note ?? "", - /hook history is not yet retained/i + /No retained Claude hook events were available/i ); }); From 68e31f53d72aaa94aeba8a25a0b103a358ee5a01 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:20:26 -0400 Subject: [PATCH 17/72] fix(claude): make event reduction chronological and idempotent --- src/services/claudeEventModel.ts | 132 ++++++++++++++++++++++++++----- 1 file changed, 111 insertions(+), 21 deletions(-) diff --git a/src/services/claudeEventModel.ts b/src/services/claudeEventModel.ts index 3c2d3af..cb88f9e 100644 --- a/src/services/claudeEventModel.ts +++ b/src/services/claudeEventModel.ts @@ -1,4 +1,4 @@ -import { createHash } from "crypto"; +import { createHash } from "node:crypto"; import type { EventSummary, WorkSummary } from "../types.js"; import type { ClaudeEvent, ClaudeSessionState } from "../claude/types.js"; @@ -10,6 +10,8 @@ const INFLIGHT_TIMEOUT_MS = Number( ); const MAX_EVENTS = 50; const MAX_METADATA_LABEL_LENGTH = 160; +const MAX_SESSION_ID_LENGTH = 512; +const METADATA_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g; export const CLAUDE_EVENT_LOG_VERSION = 1 as const; const CANONICAL_TYPES: Record = { @@ -113,16 +115,33 @@ export interface StoredClaudeEvent { export type ClaudeStateMap = Map; +function normalizeMetadataText( + value: string | undefined, + maxLength: number +): string | undefined { + const normalized = value + ?.replace(METADATA_CONTROL_RE, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); + return normalized || undefined; +} + export function boundedClaudeLabel(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed.slice(0, MAX_METADATA_LABEL_LENGTH) : undefined; + return normalizeMetadataText(value, MAX_METADATA_LABEL_LENGTH); +} + +function validClaudeSessionId(value: string | undefined): string | undefined { + if (!value || value.length > MAX_SESSION_ID_LENGTH) return undefined; + const normalized = normalizeMetadataText(value, MAX_SESSION_ID_LENGTH); + return normalized === value.trim() ? normalized : undefined; } export function normalizeClaudeEventType(input: string): string { const trimmed = input.trim(); if (!trimmed) return trimmed; const key = trimmed.replace(/[^a-z0-9]/gi, "").toLowerCase(); - return CANONICAL_TYPES[key] ?? trimmed; + return CANONICAL_TYPES[key] ?? boundedClaudeLabel(trimmed) ?? ""; } export function stableClaudePathKey(value: string | undefined): string | undefined { @@ -181,6 +200,36 @@ function summarizeClaudeEvent(event: ClaudeEvent, type: string): EventSummary { }; } +function eventSummaryKey(event: EventSummary): string { + return JSON.stringify([ + event.ts, + event.type, + event.summary, + event.isError === true, + event.turnId ?? null, + ]); +} + +function mergeRetainedEvents( + previous: EventSummary[], + entry: EventSummary, + retain: boolean +): EventSummary[] { + if (!retain) return previous; + const seen = new Set(); + return [...previous, entry] + .map((event, index) => ({ event, index })) + .filter(({ event }) => { + const key = eventSummaryKey(event); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => a.event.ts - b.event.ts || a.index - b.index) + .slice(-MAX_EVENTS) + .map(({ event }) => event); +} + function updateSummary( previous: WorkSummary, entry: EventSummary, @@ -227,25 +276,47 @@ export function applyClaudeEvent( event: ClaudeEvent, storedCwdKey?: string ): ClaudeStateMap { - const now = typeof event.timestamp === "number" ? event.timestamp : Date.now(); + const sessionId = validClaudeSessionId(event.sessionId); + const now = event.timestamp; + if ( + !sessionId || + !Number.isFinite(now) || + now < 0 + ) { + return map; + } + const type = normalizeClaudeEventType(event.type); + if (!type) return map; const isIdleNotification = type === "Notification" && event.notificationType?.trim().toLowerCase() === "idle_prompt"; - const previous = map.get(event.sessionId); + const previous = map.get(sessionId); const entry = summarizeClaudeEvent(event, type); const activity = isActivityEvent(type); const retain = shouldRetainGraphEvent(event, type); - const events = retain - ? [...(previous?.events ?? []), entry].slice(-MAX_EVENTS) - : previous?.events ?? []; + const events = mergeRetainedEvents(previous?.events ?? [], entry, retain); + const eventCwdKey = stableClaudePathKey(event.cwd); + + if (previous && now < previous.lastSeenAt) { + const nextMap = new Map(map); + nextMap.set(sessionId, { + ...previous, + cwd: previous.cwd ?? event.cwd, + cwdKey: previous.cwdKey ?? eventCwdKey ?? storedCwdKey, + transcriptPath: previous.transcriptPath ?? event.transcriptPath, + events, + }); + return nextMap; + } + const next: ClaudeSessionState = { - sessionId: event.sessionId, + sessionId, inFlight: previous?.inFlight ?? false, - lastSeenAt: now, - lastEventAt: now, + lastSeenAt: Math.max(previous?.lastSeenAt ?? now, now), + lastEventAt: Math.max(previous?.lastEventAt ?? now, now), cwd: event.cwd ?? previous?.cwd, - cwdKey: stableClaudePathKey(event.cwd) ?? storedCwdKey ?? previous?.cwdKey, + cwdKey: eventCwdKey ?? storedCwdKey ?? previous?.cwdKey, transcriptPath: event.transcriptPath ?? previous?.transcriptPath, lastEvent: type, lastActivityAt: previous?.lastActivityAt, @@ -263,19 +334,28 @@ export function applyClaudeEvent( } const nextMap = new Map(map); - nextMap.set(event.sessionId, next); + nextMap.set(sessionId, next); return nextMap; } export function toStoredClaudeEvent( event: ClaudeEvent ): StoredClaudeEvent | undefined { + const sessionId = validClaudeSessionId(event.sessionId); const type = normalizeClaudeEventType(event.type); - if (!shouldPersistClaudeEvent(event, type)) return undefined; + if ( + !sessionId || + !type || + !Number.isFinite(event.timestamp) || + event.timestamp < 0 || + !shouldPersistClaudeEvent(event, type) + ) { + return undefined; + } return { version: CLAUDE_EVENT_LOG_VERSION, type, - sessionId: event.sessionId, + sessionId, timestamp: event.timestamp, cwdKey: stableClaudePathKey(event.cwd), notificationType: boundedClaudeLabel(event.notificationType), @@ -299,20 +379,30 @@ export function storedClaudeEventKey(event: StoredClaudeEvent): string { ].join("\0"); } +function isOptionalBoundedLabel(value: unknown): boolean { + return ( + value === undefined || + (typeof value === "string" && boundedClaudeLabel(value) === value) + ); +} + export function isStoredClaudeEvent(value: unknown): value is StoredClaudeEvent { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const event = value as Record; return ( event.version === CLAUDE_EVENT_LOG_VERSION && typeof event.type === "string" && + normalizeClaudeEventType(event.type) === event.type && typeof event.sessionId === "string" && + validClaudeSessionId(event.sessionId) === event.sessionId && typeof event.timestamp === "number" && Number.isFinite(event.timestamp) && - (event.cwdKey === undefined || typeof event.cwdKey === "string") && - (event.notificationType === undefined || - typeof event.notificationType === "string") && - (event.toolName === undefined || typeof event.toolName === "string") && - (event.agentType === undefined || typeof event.agentType === "string") && + event.timestamp >= 0 && + (event.cwdKey === undefined || + (typeof event.cwdKey === "string" && /^[a-f0-9]{24}$/.test(event.cwdKey))) && + isOptionalBoundedLabel(event.notificationType) && + isOptionalBoundedLabel(event.toolName) && + isOptionalBoundedLabel(event.agentType) && (event.final === undefined || typeof event.final === "boolean") ); } From da86141eea8dd60f7e8c63ae32b1c1d9b8d95318 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:21:12 -0400 Subject: [PATCH 18/72] fix(claude): isolate persistence retry state by log path --- src/services/claudeEventLog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/claudeEventLog.ts b/src/services/claudeEventLog.ts index 14d0321..f5328c6 100644 --- a/src/services/claudeEventLog.ts +++ b/src/services/claudeEventLog.ts @@ -112,7 +112,7 @@ export function queueClaudeEventPersistence(event: ClaudeEvent): Promise { persistenceQueue = persistenceQueue .then(() => persistStoredEvent(filePath, stored)) .catch(() => { - seenStoredEvents.delete(key); + if (seenEventLogPath === filePath) seenStoredEvents.delete(key); }); return persistenceQueue; } From 30ba24dc29bbe0c5ff3cca5db4d320d65f0b1117 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:21:39 -0400 Subject: [PATCH 19/72] test(claude): cover chronological and idempotent reduction --- tests/unit/claudeEventModel.test.ts | 132 ++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/unit/claudeEventModel.test.ts diff --git a/tests/unit/claudeEventModel.test.ts b/tests/unit/claudeEventModel.test.ts new file mode 100644 index 0000000..ad2a30f --- /dev/null +++ b/tests/unit/claudeEventModel.test.ts @@ -0,0 +1,132 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + applyClaudeEvent, + boundedClaudeLabel, + isStoredClaudeEvent, + toStoredClaudeEvent, + type ClaudeStateMap, +} from "../../src/services/claudeEventModel.js"; +import type { ClaudeEvent } from "../../src/claude/types.js"; + +function event( + type: string, + timestamp: number, + extra: Partial = {} +): ClaudeEvent { + return { + type, + sessionId: "claude-model-session", + timestamp, + ...extra, + } as ClaudeEvent; +} + +function state( + events: ClaudeEvent[] +): ReturnType { + let map: ClaudeStateMap = new Map(); + for (const current of events) map = applyClaudeEvent(map, current); + return map; +} + +describe("Claude event model", () => { + it("deduplicates repeated retained hook deliveries in memory", () => { + const duplicate = event("PreToolUse", 100, { toolName: "Bash" }); + const map = state([duplicate, duplicate]); + const current = map.get(duplicate.sessionId); + + assert.equal(current?.events.length, 1); + assert.equal(current?.events[0].summary, "tool: Bash"); + assert.equal(current?.inFlight, true); + }); + + it("does not let an older stored stop event clear newer live activity", () => { + const prompt = event("UserPromptSubmit", 200); + let map = applyClaudeEvent(new Map(), prompt); + map = applyClaudeEvent(map, event("Stop", 100)); + const current = map.get(prompt.sessionId); + + assert.equal(current?.inFlight, true); + assert.equal(current?.lastSeenAt, 200); + assert.equal(current?.lastEventAt, 200); + assert.equal(current?.lastEvent, "UserPromptSubmit"); + assert.deepEqual( + current?.events.map((entry) => [entry.ts, entry.type]), + [ + [100, "Stop"], + [200, "UserPromptSubmit"], + ] + ); + }); + + it("does not let an older failure overwrite a newer successful state", () => { + const message = event("MessageDisplay", 200, { final: true }); + let map = applyClaudeEvent(new Map(), message); + map = applyClaudeEvent(map, event("StopFailure", 100)); + const current = map.get(message.sessionId); + + assert.notEqual(current?.hasError, true); + assert.equal(current?.lastEvent, "MessageDisplay"); + assert.equal(current?.lastSeenAt, 200); + assert.deepEqual( + current?.events.map((entry) => entry.type), + ["StopFailure", "MessageDisplay"] + ); + }); + + it("rejects invalid session ids and timestamps without mutating state", () => { + const original: ClaudeStateMap = new Map(); + const controlSession = event("UserPromptSubmit", 100, { + sessionId: "bad\nidentity", + }); + const negativeTime = event("UserPromptSubmit", -1); + + assert.equal(applyClaudeEvent(original, controlSession), original); + assert.equal(applyClaudeEvent(original, negativeTime), original); + assert.equal(toStoredClaudeEvent(controlSession), undefined); + assert.equal(toStoredClaudeEvent(negativeTime), undefined); + }); + + it("sanitizes and bounds metadata labels before persistence", () => { + const label = `Bash\n\u001b[2J${"x".repeat(300)}`; + const normalized = boundedClaudeLabel(label); + const stored = toStoredClaudeEvent( + event("PreToolUse", 100, { toolName: label }) + ); + + assert.ok(normalized); + assert.equal(normalized?.length, 160); + assert.equal(/[\u0000-\u001f\u007f-\u009f]/.test(normalized ?? ""), false); + assert.equal(stored?.toolName, normalized); + }); + + it("rejects malformed stored records at the disk boundary", () => { + const base = { + version: 1, + type: "PreToolUse", + sessionId: "stored-session", + timestamp: 100, + cwdKey: "a".repeat(24), + toolName: "Bash", + }; + + assert.equal(isStoredClaudeEvent(base), true); + assert.equal( + isStoredClaudeEvent({ ...base, timestamp: -1 }), + false + ); + assert.equal( + isStoredClaudeEvent({ ...base, cwdKey: "not-a-hash" }), + false + ); + assert.equal( + isStoredClaudeEvent({ ...base, toolName: "Bash\nforged" }), + false + ); + assert.equal( + isStoredClaudeEvent({ ...base, sessionId: "s".repeat(513) }), + false + ); + }); +}); From ed4b22a6331fe67e089ba5b33dfd87db55fc59b6 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:23:24 -0400 Subject: [PATCH 20/72] fix(graph): bound snapshot input before parsing --- src/cli/graph.ts | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/cli/graph.ts b/src/cli/graph.ts index 3aaa7c5..75ffbd7 100644 --- a/src/cli/graph.ts +++ b/src/cli/graph.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { buildAgentGraph, formatAgentGraph } from "../graph.js"; import type { AgentKind, @@ -30,6 +30,7 @@ const agentKinds = new Set([ "unknown", ]); const agentStates = new Set(["active", "idle", "error"]); +export const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024; const MAX_AGENTS = 10_000; const MAX_EVENTS_PER_AGENT = 10_000; const MAX_TEXT_LENGTH = 16_384; @@ -54,15 +55,25 @@ function graphHelp(): string { ].join("\n"); } +function snapshotTooLarge(source: string): Error { + return new Error( + `snapshot from ${source} exceeds ${MAX_SNAPSHOT_BYTES} bytes` + ); +} + async function readStdin(): Promise { if (process.stdin.isTTY) { throw new Error("--snapshot - requires JSON piped on stdin"); } - let input = ""; + const chunks: Buffer[] = []; + let totalBytes = 0; for await (const chunk of process.stdin) { - input += String(chunk); + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > MAX_SNAPSHOT_BYTES) throw snapshotTooLarge("stdin"); + chunks.push(buffer); } - return input; + return Buffer.concat(chunks, totalBytes).toString("utf8"); } function isRecord(value: unknown): value is Record { @@ -78,7 +89,7 @@ function isNonNegativeFiniteNumber(value: unknown): value is number { } function isNonNegativeInteger(value: unknown): value is number { - return Number.isInteger(value) && (value as number) >= 0; + return typeof value === "number" && Number.isInteger(value) && value >= 0; } function isSafeString(value: unknown, maxLength: number = MAX_TEXT_LENGTH): value is string { @@ -188,6 +199,10 @@ function isAgentSnapshot(value: unknown): value is AgentSnapshot { } export function parseSnapshot(input: string, source: string): SnapshotPayload { + if (Buffer.byteLength(input, "utf8") > MAX_SNAPSHOT_BYTES) { + throw snapshotTooLarge(source); + } + let parsed: unknown; try { parsed = JSON.parse(input); @@ -230,6 +245,12 @@ async function loadLiveSnapshot( } } +async function readSnapshotFile(snapshotPath: string): Promise { + const info = await stat(snapshotPath); + if (info.size > MAX_SNAPSHOT_BYTES) throw snapshotTooLarge(snapshotPath); + return readFile(snapshotPath, "utf8"); +} + export async function loadSnapshot( snapshotPath: string | undefined, loadScan: GraphScanLoader = defaultScanLoader @@ -237,7 +258,7 @@ export async function loadSnapshot( if (!snapshotPath) return loadLiveSnapshot(loadScan); const input = - snapshotPath === "-" ? await readStdin() : await readFile(snapshotPath, "utf8"); + snapshotPath === "-" ? await readStdin() : await readSnapshotFile(snapshotPath); return parseSnapshot(input, snapshotPath === "-" ? "stdin" : snapshotPath); } From 049a0efe1afb8c67990e3f413a9c1b3f1a38258c Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:24:13 -0400 Subject: [PATCH 21/72] test(graph): cover snapshot byte bounds --- tests/unit/graphCli.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/unit/graphCli.test.ts b/tests/unit/graphCli.test.ts index 6249cfe..eaa53ea 100644 --- a/tests/unit/graphCli.test.ts +++ b/tests/unit/graphCli.test.ts @@ -1,6 +1,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { loadSnapshot, parseSnapshot } from "../../src/cli/graph.js"; +import { + loadSnapshot, + MAX_SNAPSHOT_BYTES, + parseSnapshot, +} from "../../src/cli/graph.js"; import type { SnapshotPayload } from "../../src/types.js"; const validSnapshot: SnapshotPayload = { @@ -51,6 +55,14 @@ describe("graph CLI snapshot contract", () => { ); }); + it("rejects oversized snapshot text before JSON parsing", () => { + const oversized = " ".repeat(MAX_SNAPSHOT_BYTES + 1); + assert.throws( + () => parseSnapshot(oversized, "test"), + /snapshot from test exceeds/ + ); + }); + it("rejects malformed agent records", () => { const malformed = { ...validSnapshot, From b7952bdfc1fe0dec1998182fa620bacb1f788bed Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:25:04 -0400 Subject: [PATCH 22/72] test(graph): distinguish formatting newlines from unsafe controls --- tests/unit/graphHardening.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/unit/graphHardening.test.ts b/tests/unit/graphHardening.test.ts index 9b1b7a5..7edfe4e 100644 --- a/tests/unit/graphHardening.test.ts +++ b/tests/unit/graphHardening.test.ts @@ -150,11 +150,12 @@ describe("agent graph hardening", () => { ); const output = formatAgentGraph(graph); const json = JSON.stringify(graph); - const forbidden = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/; + const unsafeTerminalControl = + /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/; - assert.equal(forbidden.test(output), false); - assert.equal(forbidden.test(json), false); - assert.match(output, /worker evil/); + assert.equal(unsafeTerminalControl.test(output), false); + assert.equal(unsafeTerminalControl.test(json), false); + assert.match(output, /worker .*evil/); }); it("rejects cross-segment transitions as loop evidence", () => { From 2b8c8c7bd71017531e3537b2f56b260530cab494 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:37:34 -0400 Subject: [PATCH 23/72] feat(harnesses): add capability-based provider registry --- src/harnesses.ts | 475 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 src/harnesses.ts diff --git a/src/harnesses.ts b/src/harnesses.ts new file mode 100644 index 0000000..ef69bc3 --- /dev/null +++ b/src/harnesses.ts @@ -0,0 +1,475 @@ +export const agentKinds = [ + "tui", + "exec", + "app-server", + "opencode-tui", + "opencode-cli", + "opencode-server", + "claude-tui", + "claude-cli", + "openclaw-cli", + "openclaw-server", + "hermes-cli", + "hermes-server", + "kimi-cli", + "minimax-cli", + "cursor-cli", + "warp-cli", + "factory-cli", + "gemini-cli", + "antigravity-cli", + "qwen-cli", + "copilot-cli", + "aider-cli", + "goose-cli", + "amp-cli", + "amazon-q-cli", + "kiro-cli", + "openhands-cli", + "cline-ide", + "roo-code-ide", + "windsurf-ide", + "junie-ide", + "replit-agent", + "zed-agent", + "unknown", +] as const; + +export type AgentKind = (typeof agentKinds)[number]; + +export const harnessIds = [ + "codex", + "opencode", + "claude", + "openclaw", + "hermes", + "kimi", + "minimax", + "cursor", + "warp", + "factory", + "gemini", + "antigravity", + "qwen", + "copilot", + "aider", + "goose", + "amp", + "amazon-q", + "kiro", + "openhands", + "cline", + "roo-code", + "windsurf", + "junie", + "replit", + "zed", + "other", +] as const; + +export type HarnessId = (typeof harnessIds)[number]; +export type HarnessClass = + | "local-cli" + | "local-server" + | "ide-agent" + | "remote-agent" + | "tool-provider"; +export type HarnessTelemetry = + | "native-events" + | "hooks" + | "structured-stream" + | "remote-api" + | "process-only" + | "tool-only" + | "manual"; + +export interface HarnessProcessRule { + binaries: readonly string[]; + kind: AgentKind; + requiredAnyToken?: readonly string[]; +} + +export interface HarnessDefinition { + id: HarnessId; + displayName: string; + kinds: readonly AgentKind[]; + class: HarnessClass; + telemetry: HarnessTelemetry; + docs?: string; + processRules?: readonly HarnessProcessRule[]; + note?: string; +} + +export const harnessDefinitions: readonly HarnessDefinition[] = [ + { + id: "codex", + displayName: "Codex", + kinds: ["tui", "exec", "app-server"], + class: "local-cli", + telemetry: "native-events", + docs: "https://developers.openai.com/codex/config-reference", + }, + { + id: "opencode", + displayName: "OpenCode", + kinds: ["opencode-tui", "opencode-cli", "opencode-server"], + class: "local-server", + telemetry: "native-events", + docs: "https://opencode.ai/docs/server/", + }, + { + id: "claude", + displayName: "Claude Code", + kinds: ["claude-tui", "claude-cli"], + class: "local-cli", + telemetry: "hooks", + docs: "https://code.claude.com/docs/en/hooks", + }, + { + id: "openclaw", + displayName: "OpenClaw", + kinds: ["openclaw-cli", "openclaw-server"], + class: "local-server", + telemetry: "native-events", + docs: "https://docs.openclaw.ai/agent-loop", + processRules: [ + { + binaries: ["openclaw"], + kind: "openclaw-server", + requiredAnyToken: ["gateway", "serve", "server", "daemon"], + }, + { binaries: ["openclaw"], kind: "openclaw-cli" }, + ], + }, + { + id: "hermes", + displayName: "Hermes Agent", + kinds: ["hermes-cli", "hermes-server"], + class: "local-cli", + telemetry: "hooks", + docs: "https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/", + processRules: [ + { + binaries: ["hermes"], + kind: "hermes-server", + requiredAnyToken: ["gateway", "serve", "server", "daemon"], + }, + { binaries: ["hermes"], kind: "hermes-cli" }, + ], + }, + { + id: "kimi", + displayName: "Kimi Code", + kinds: ["kimi-cli"], + class: "local-cli", + telemetry: "hooks", + docs: "https://moonshotai.github.io/kimi-code/en/customization/hooks", + processRules: [ + { binaries: ["kimi", "kimi-code", "kimi-cli"], kind: "kimi-cli" }, + ], + }, + { + id: "minimax", + displayName: "MiniMax CLI", + kinds: ["minimax-cli"], + class: "tool-provider", + telemetry: "tool-only", + docs: "https://platform.minimax.io/docs/token-plan/minimax-cli", + processRules: [ + { binaries: ["mmx", "mmx-cli", "minimax-cli"], kind: "minimax-cli" }, + ], + note: + "MiniMax CLI is currently modeled as a tool/model integration, not a full coding-agent runtime.", + }, + { + id: "cursor", + displayName: "Cursor Agent", + kinds: ["cursor-cli"], + class: "local-cli", + telemetry: "structured-stream", + docs: "https://docs.cursor.com/en/cli/using", + processRules: [ + { binaries: ["cursor-agent"], kind: "cursor-cli" }, + ], + note: + "Cursor CLI exposes structured stream output and a partial hook set; coverage must state which source was observed.", + }, + { + id: "warp", + displayName: "Warp Oz", + kinds: ["warp-cli"], + class: "remote-agent", + telemetry: "remote-api", + docs: "https://docs.warp.dev/reference/cli", + processRules: [ + { + binaries: ["oz", "oz-preview"], + kind: "warp-cli", + requiredAnyToken: ["agent"], + }, + ], + }, + { + id: "factory", + displayName: "Factory Droid", + kinds: ["factory-cli"], + class: "local-cli", + telemetry: "hooks", + docs: "https://docs.factory.ai/reference/hooks-reference", + processRules: [{ binaries: ["droid"], kind: "factory-cli" }], + }, + { + id: "gemini", + displayName: "Gemini CLI", + kinds: ["gemini-cli"], + class: "local-cli", + telemetry: "process-only", + docs: "https://developers.google.com/gemini-code-assist/docs/gemini-cli", + processRules: [{ binaries: ["gemini"], kind: "gemini-cli" }], + }, + { + id: "antigravity", + displayName: "Antigravity CLI", + kinds: ["antigravity-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [ + { binaries: ["antigravity", "antigravity-cli"], kind: "antigravity-cli" }, + ], + }, + { + id: "qwen", + displayName: "Qwen Code", + kinds: ["qwen-cli"], + class: "local-cli", + telemetry: "hooks", + docs: "https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/", + processRules: [ + { binaries: ["qwen", "qwen-code"], kind: "qwen-cli" }, + ], + }, + { + id: "copilot", + displayName: "GitHub Copilot CLI", + kinds: ["copilot-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [{ binaries: ["copilot"], kind: "copilot-cli" }], + }, + { + id: "aider", + displayName: "Aider", + kinds: ["aider-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [{ binaries: ["aider"], kind: "aider-cli" }], + }, + { + id: "goose", + displayName: "Goose", + kinds: ["goose-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [{ binaries: ["goose"], kind: "goose-cli" }], + }, + { + id: "amp", + displayName: "Amp", + kinds: ["amp-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [{ binaries: ["amp"], kind: "amp-cli" }], + }, + { + id: "amazon-q", + displayName: "Amazon Q Developer", + kinds: ["amazon-q-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [ + { + binaries: ["q"], + kind: "amazon-q-cli", + requiredAnyToken: ["chat", "agent"], + }, + ], + }, + { + id: "kiro", + displayName: "Kiro", + kinds: ["kiro-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [ + { binaries: ["kiro", "kiro-cli"], kind: "kiro-cli" }, + ], + }, + { + id: "openhands", + displayName: "OpenHands", + kinds: ["openhands-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [ + { binaries: ["openhands", "openhands-cli"], kind: "openhands-cli" }, + ], + }, + { + id: "cline", + displayName: "Cline", + kinds: ["cline-ide"], + class: "ide-agent", + telemetry: "manual", + }, + { + id: "roo-code", + displayName: "Roo Code", + kinds: ["roo-code-ide"], + class: "ide-agent", + telemetry: "manual", + }, + { + id: "windsurf", + displayName: "Windsurf Cascade", + kinds: ["windsurf-ide"], + class: "ide-agent", + telemetry: "manual", + }, + { + id: "junie", + displayName: "JetBrains Junie", + kinds: ["junie-ide"], + class: "ide-agent", + telemetry: "manual", + }, + { + id: "replit", + displayName: "Replit Agent", + kinds: ["replit-agent"], + class: "remote-agent", + telemetry: "remote-api", + }, + { + id: "zed", + displayName: "Zed Agent", + kinds: ["zed-agent"], + class: "ide-agent", + telemetry: "manual", + }, + { + id: "other", + displayName: "Other agent", + kinds: ["unknown"], + class: "local-cli", + telemetry: "process-only", + }, +] as const; + +const agentKindSet = new Set(agentKinds); +const harnessById = new Map( + harnessDefinitions.map((definition) => [definition.id, definition]) +); +const harnessByKind = new Map(); +for (const definition of harnessDefinitions) { + for (const kind of definition.kinds) harnessByKind.set(kind, definition); +} + +function stripQuotes(value: string): string { + return value.replace(/^["']|["']$/g, ""); +} + +function basename(value: string): string { + const parts = stripQuotes(value).split(/[\\/]/); + return (parts[parts.length - 1] || value).toLowerCase(); +} + +function commandTokens(command: string | undefined): string[] { + if (!command) return []; + return ( + command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map(stripQuotes) ?? [] + ); +} + +function ruleMatches( + rule: HarnessProcessRule, + command: string | undefined, + processName: string | undefined +): boolean { + const tokens = commandTokens(command); + const executable = tokens[0] ? basename(tokens[0]) : undefined; + const name = processName ? basename(processName) : undefined; + const binaries = new Set(rule.binaries.map((binary) => binary.toLowerCase())); + if (!((executable && binaries.has(executable)) || (name && binaries.has(name)))) { + return false; + } + if (!rule.requiredAnyToken?.length) return true; + const commandTokensLower = new Set(tokens.slice(1).map((token) => token.toLowerCase())); + return rule.requiredAnyToken.some((token) => + commandTokensLower.has(token.toLowerCase()) + ); +} + +export interface DetectedHarnessProcess { + harness: HarnessDefinition; + kind: AgentKind; +} + +export function isAgentKind(value: unknown): value is AgentKind { + return typeof value === "string" && agentKindSet.has(value); +} + +export function harnessForId(id: HarnessId): HarnessDefinition { + return harnessById.get(id) ?? harnessById.get("other")!; +} + +export function harnessForKind(kind: AgentKind): HarnessDefinition { + return harnessByKind.get(kind) ?? harnessById.get("other")!; +} + +export function harnessIdForKind(kind: AgentKind): HarnessId { + return harnessForKind(kind).id; +} + +export function detectGenericHarnessProcess( + command: string | undefined, + processName: string | undefined +): DetectedHarnessProcess | undefined { + for (const harness of harnessDefinitions) { + if (harness.id === "codex" || harness.id === "opencode" || harness.id === "claude") { + continue; + } + for (const rule of harness.processRules ?? []) { + if (ruleMatches(rule, command, processName)) { + return { harness, kind: rule.kind }; + } + } + } + return undefined; +} + +export function harnessCoverageNote( + harness: HarnessDefinition, + hasEvents: boolean +): string | undefined { + if (hasEvents) return undefined; + if (harness.note) return harness.note; + if (harness.telemetry === "hooks") { + return `${harness.displayName} was detected, but no retained hook events were available.`; + } + if (harness.telemetry === "native-events") { + return `${harness.displayName} was detected, but no native runtime events were retained.`; + } + if (harness.telemetry === "structured-stream") { + return `${harness.displayName} was detected, but no structured stream was attached to this snapshot.`; + } + if (harness.telemetry === "remote-api") { + return `${harness.displayName} was detected locally; remote run history requires an explicit API adapter.`; + } + if (harness.telemetry === "tool-only") { + return `${harness.displayName} is a tool/model integration rather than a complete observed agent loop.`; + } + if (harness.telemetry === "manual") { + return `${harness.displayName} requires an IDE or extension adapter; process detection alone is not reliable.`; + } + return `${harness.displayName} currently has process-level coverage only.`; +} From 60339d0c3e0fa90e869726d7c0c2a4eed13b3e5c Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:37:49 -0400 Subject: [PATCH 24/72] refactor(types): source agent kinds from harness registry --- src/types.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/types.ts b/src/types.ts index e86fde3..30ad3a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,14 +1,7 @@ +import type { AgentKind } from "./harnesses.js"; + +export type { AgentKind } from "./harnesses.js"; export type AgentState = "active" | "idle" | "error"; -export type AgentKind = - | "tui" - | "exec" - | "app-server" - | "opencode-tui" - | "opencode-cli" - | "opencode-server" - | "claude-tui" - | "claude-cli" - | "unknown"; export interface EventSummary { ts: number; From 43b7b21b6ce0c7b762613fc4563e8416bc2e3af8 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:39:17 -0400 Subject: [PATCH 25/72] refactor(graph): validate kinds through harness registry --- src/cli/graph.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/cli/graph.ts b/src/cli/graph.ts index 75ffbd7..d41ea4c 100644 --- a/src/cli/graph.ts +++ b/src/cli/graph.ts @@ -1,7 +1,7 @@ import { readFile, stat } from "node:fs/promises"; import { buildAgentGraph, formatAgentGraph } from "../graph.js"; +import { isAgentKind } from "../harnesses.js"; import type { - AgentKind, AgentSnapshot, AgentState, EventSummary, @@ -18,17 +18,6 @@ export type GraphScanLoader = () => Promise<{ scanCodexProcesses: SnapshotScanner; }>; -const agentKinds = new Set([ - "tui", - "exec", - "app-server", - "opencode-tui", - "opencode-cli", - "opencode-server", - "claude-tui", - "claude-cli", - "unknown", -]); const agentStates = new Set(["active", "idle", "error"]); export const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024; const MAX_AGENTS = 10_000; @@ -157,8 +146,7 @@ function isAgentSnapshot(value: unknown): value is AgentSnapshot { !isNonNegativeInteger(value.pid) || !isNonEmptySafeString(value.cmd) || !isNonEmptySafeString(value.cmdShort) || - typeof value.kind !== "string" || - !agentKinds.has(value.kind as AgentKind) || + !isAgentKind(value.kind) || typeof value.state !== "string" || !agentStates.has(value.state as AgentState) || !isNonNegativeFiniteNumber(value.cpu) || From 80a5b8d143300cd27565acfa7c85e2f7ba798648 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:40:59 -0400 Subject: [PATCH 26/72] refactor(graph): derive providers and coverage from harness registry --- src/graph.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/graph.ts b/src/graph.ts index 05895ad..f163b7a 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -1,4 +1,9 @@ import { createHash } from "node:crypto"; +import { + harnessCoverageNote, + harnessDefinitions, + harnessIdForKind, +} from "./harnesses.js"; import type { AgentKind, AgentSnapshot, @@ -77,11 +82,7 @@ function identityForAgent(agent: AgentSnapshot): string { } function providerForKind(kind: AgentKind): string { - if (kind.startsWith("opencode")) return "opencode"; - if (kind.startsWith("claude")) return "claude"; - if (kind === "app-server") return "server"; - if (kind === "unknown") return "other"; - return "codex"; + return harnessIdForKind(kind); } function opaqueAgentKey(provider: string, identity: string): string { @@ -309,22 +310,24 @@ function historyStatusForProvider( note: "Only some observed agents included retained events.", }; } - if (provider === "claude") { - return { - history: "unavailable", - note: - "No retained Claude hook events were available; hooks may be unconfigured, history may be disabled, or events may be outside the retention window.", - }; - } if (provider === "codex" || provider === "opencode") { return { history: "available", note: "No retained graph events were present in this snapshot window.", }; } + + const harness = harnessDefinitions.find((candidate) => candidate.id === provider); + if (harness) { + return { + history: "unavailable", + note: harnessCoverageNote(harness, false), + }; + } + return { history: "unavailable", - note: "This provider does not expose retained graph events in the current adapter.", + note: "This harness does not expose retained graph events in the current adapter.", }; } From 9c498c414d45bcc3fff9b9f02e012ab3d50d5956 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:41:35 -0400 Subject: [PATCH 27/72] feat(harnesses): detect additional local agent runtimes --- src/genericHarnessSnapshot.ts | 157 ++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/genericHarnessSnapshot.ts diff --git a/src/genericHarnessSnapshot.ts b/src/genericHarnessSnapshot.ts new file mode 100644 index 0000000..ec624d8 --- /dev/null +++ b/src/genericHarnessSnapshot.ts @@ -0,0 +1,157 @@ +import path from "node:path"; +import pidusage from "pidusage"; +import psList from "ps-list"; +import { + detectGenericHarnessProcess, + type DetectedHarnessProcess, +} from "./harnesses.js"; +import { redactText } from "./redact.js"; +import type { AgentSnapshot, SnapshotPayload } from "./types.js"; + +export interface GenericHarnessProcess { + pid: number; + name?: string; + cmd?: string; +} + +export interface GenericHarnessUsage { + cpu?: number; + memory?: number; + elapsed?: number; +} + +export interface GenericHarnessSnapshotOptions { + processes?: readonly GenericHarnessProcess[]; + usage?: Readonly>; + now?: number; +} + +function shortenCommand(command: string, maxLength = 120): string { + const normalized = command.replace(/\s+/g, " ").trim(); + return normalized.length <= maxLength + ? normalized + : `${normalized.slice(0, maxLength - 3)}...`; +} + +function commandTokens(command: string): string[] { + return ( + command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => + part.replace(/^["']|["']$/g, "") + ) ?? [] + ); +} + +function extractCwd(command: string): string | undefined { + const tokens = commandTokens(command); + const flags = new Set(["--cwd", "--working-dir", "--workdir", "--dir"]); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (flags.has(token)) { + const value = tokens[index + 1]; + if (value && !value.startsWith("-")) return value; + } + for (const flag of flags) { + if (token.startsWith(`${flag}=`)) { + const value = token.slice(flag.length + 1); + if (value) return value; + } + } + } + return undefined; +} + +async function loadUsage( + pids: number[] +): Promise> { + if (pids.length === 0) return {}; + try { + const result = await pidusage(pids); + if ( + pids.length === 1 && + result && + typeof result === "object" && + "cpu" in result + ) { + return { [pids[0]]: result as GenericHarnessUsage }; + } + return result as Record; + } catch { + return {}; + } +} + +function processState(cpu: number): AgentSnapshot["state"] { + const threshold = Number(process.env.CONSENSUS_GENERIC_CPU_ACTIVE || 1); + return Number.isFinite(threshold) && cpu > threshold ? "active" : "idle"; +} + +function toAgentSnapshot( + process: GenericHarnessProcess, + detected: DetectedHarnessProcess, + usage: GenericHarnessUsage, + now: number +): AgentSnapshot { + const commandRaw = process.cmd || process.name || detected.harness.displayName; + const command = redactText(commandRaw) || commandRaw; + const cwdRaw = extractCwd(commandRaw); + const cwd = redactText(cwdRaw) || cwdRaw; + const cpu = typeof usage.cpu === "number" && Number.isFinite(usage.cpu) ? usage.cpu : 0; + const mem = + typeof usage.memory === "number" && Number.isFinite(usage.memory) + ? usage.memory + : 0; + const elapsed = + typeof usage.elapsed === "number" && Number.isFinite(usage.elapsed) + ? usage.elapsed + : undefined; + const startedAt = + typeof elapsed === "number" ? Math.floor((now - elapsed) / 1000) : undefined; + const state = processState(cpu); + const role = detected.kind.endsWith("server") ? "server" : "agent"; + + return { + identity: `${detected.harness.id}:pid:${process.pid}`, + id: String(process.pid), + pid: process.pid, + startedAt, + title: detected.harness.displayName, + cmd: command, + cmdShort: shortenCommand(command), + kind: detected.kind, + cpu, + mem, + state, + activityReason: state === "active" ? "process_cpu" : "process_detected", + doing: `${detected.harness.displayName} ${role}`, + cwd, + repo: cwdRaw ? path.basename(path.resolve(cwdRaw)) : undefined, + }; +} + +export async function attachGenericHarnessProcesses( + snapshot: SnapshotPayload, + options: GenericHarnessSnapshotOptions = {} +): Promise { + const processes = options.processes ?? (await psList()); + const existingPids = new Set(snapshot.agents.map((agent) => agent.pid)); + const matches = processes.flatMap((process) => { + if (!Number.isInteger(process.pid) || process.pid < 0 || existingPids.has(process.pid)) { + return []; + } + const detected = detectGenericHarnessProcess(process.cmd, process.name); + return detected ? [{ process, detected }] : []; + }); + if (matches.length === 0) return snapshot; + + const usage = + options.usage ?? (await loadUsage(matches.map(({ process }) => process.pid))); + const now = options.now ?? snapshot.ts; + const additionalAgents = matches.map(({ process, detected }) => + toAgentSnapshot(process, detected, usage[process.pid] ?? {}, now) + ); + + return { + ...snapshot, + agents: [...snapshot.agents, ...additionalAgents], + }; +} From 64267d21dbdbae4c9a88bd679442bc723afc4100 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:41:52 -0400 Subject: [PATCH 28/72] feat(harnesses): include generic runtimes in one-shot snapshots --- src/scanSnapshot.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/scanSnapshot.ts b/src/scanSnapshot.ts index 57e5740..743020f 100644 --- a/src/scanSnapshot.ts +++ b/src/scanSnapshot.ts @@ -1,6 +1,7 @@ import type { ScanOptions } from "./scan.js"; import { scanCodexProcesses } from "./scan.js"; import { attachClaudeEvents } from "./claudeSnapshot.js"; +import { attachGenericHarnessProcesses } from "./genericHarnessSnapshot.js"; import { hydrateClaudeEventsFromDisk } from "./services/claudeEvents.js"; import type { SnapshotPayload } from "./types.js"; @@ -9,7 +10,8 @@ export async function scanSnapshot( ): Promise { await hydrateClaudeEventsFromDisk(); const snapshot = await scanCodexProcesses(options); - return attachClaudeEvents(snapshot); + const withClaudeEvents = attachClaudeEvents(snapshot); + return attachGenericHarnessProcesses(withClaudeEvents); } const isDirectRun = From 98ceef867cdfa6d73fa1cbeca19f1bc21446aeca Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:42:19 -0400 Subject: [PATCH 29/72] test(harnesses): cover registry and process detection --- tests/unit/harnesses.test.ts | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/unit/harnesses.test.ts diff --git a/tests/unit/harnesses.test.ts b/tests/unit/harnesses.test.ts new file mode 100644 index 0000000..e123f8e --- /dev/null +++ b/tests/unit/harnesses.test.ts @@ -0,0 +1,73 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + agentKinds, + detectGenericHarnessProcess, + harnessDefinitions, + harnessIdForKind, + isAgentKind, +} from "../../src/harnesses.js"; + +describe("harness registry", () => { + it("maps every agent kind to exactly one harness", () => { + const seen = new Map(); + for (const harness of harnessDefinitions) { + for (const kind of harness.kinds) { + assert.equal(seen.has(kind), false, `${kind} mapped more than once`); + seen.set(kind, harness.id); + } + } + + assert.deepEqual([...seen.keys()].sort(), [...agentKinds].sort()); + for (const kind of agentKinds) { + assert.equal(isAgentKind(kind), true); + assert.equal(harnessIdForKind(kind), seen.get(kind)); + } + }); + + it("detects the named local harness binaries", () => { + const cases = [ + ["openclaw agent --message test", "openclaw", "openclaw-cli"], + ["openclaw gateway", "openclaw", "openclaw-server"], + ["hermes chat", "hermes", "hermes-cli"], + ["hermes gateway", "hermes", "hermes-server"], + ["kimi --resume session", "kimi", "kimi-cli"], + ["mmx video generate", "minimax", "minimax-cli"], + ["cursor-agent --print task", "cursor", "cursor-cli"], + ["oz agent run --prompt task", "warp", "warp-cli"], + ["droid", "factory", "factory-cli"], + ["gemini", "gemini", "gemini-cli"], + ["qwen-code", "qwen", "qwen-cli"], + ["q chat", "amazon-q", "amazon-q-cli"], + ["kiro-cli", "kiro", "kiro-cli"], + ["openhands", "openhands", "openhands-cli"], + ] as const; + + for (const [command, harnessId, kind] of cases) { + const detected = detectGenericHarnessProcess(command, undefined); + assert.equal(detected?.harness.id, harnessId, command); + assert.equal(detected?.kind, kind, command); + } + }); + + it("uses the executable token instead of matching names mentioned in prompts", () => { + assert.equal( + detectGenericHarnessProcess("node runner.js --prompt 'use openclaw'", "node"), + undefined + ); + assert.equal(detectGenericHarnessProcess("oz config list", "oz"), undefined); + assert.equal(detectGenericHarnessProcess("q --version", "q"), undefined); + assert.equal(detectGenericHarnessProcess("agent --print task", "agent"), undefined); + }); + + it("prefers server rules before broad CLI rules", () => { + assert.equal( + detectGenericHarnessProcess("openclaw daemon", "openclaw")?.kind, + "openclaw-server" + ); + assert.equal( + detectGenericHarnessProcess("hermes serve", "hermes")?.kind, + "hermes-server" + ); + }); +}); From fdd4e0c3ab82f775dcb764c2b7238fd9bf9826f7 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:42:53 -0400 Subject: [PATCH 30/72] test(harnesses): cover generic snapshot enrichment --- tests/unit/genericHarnessSnapshot.test.ts | 117 ++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/unit/genericHarnessSnapshot.test.ts diff --git a/tests/unit/genericHarnessSnapshot.test.ts b/tests/unit/genericHarnessSnapshot.test.ts new file mode 100644 index 0000000..72b01f5 --- /dev/null +++ b/tests/unit/genericHarnessSnapshot.test.ts @@ -0,0 +1,117 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { attachGenericHarnessProcesses } from "../../src/genericHarnessSnapshot.js"; +import { buildAgentGraph } from "../../src/graph.js"; +import type { SnapshotPayload } from "../../src/types.js"; + +const emptySnapshot: SnapshotPayload = { + ts: 10_000, + agents: [], +}; + +describe("generic harness snapshot", () => { + it("adds process-level agents for detected runtimes", async () => { + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + now: 10_000, + processes: [ + { pid: 10, name: "openclaw", cmd: "openclaw agent --cwd /tmp/project" }, + { pid: 11, name: "droid", cmd: "droid" }, + { pid: 12, name: "node", cmd: "node server.js" }, + ], + usage: { + 10: { cpu: 4, memory: 1000, elapsed: 2000 }, + 11: { cpu: 0, memory: 2000, elapsed: 1000 }, + }, + }); + + assert.equal(snapshot.agents.length, 2); + const openclaw = snapshot.agents.find((agent) => agent.pid === 10); + const factory = snapshot.agents.find((agent) => agent.pid === 11); + assert.equal(openclaw?.kind, "openclaw-cli"); + assert.equal(openclaw?.state, "active"); + assert.equal(openclaw?.repo, "project"); + assert.equal(openclaw?.identity, "openclaw:pid:10"); + assert.equal(factory?.kind, "factory-cli"); + assert.equal(factory?.state, "idle"); + }); + + it("does not duplicate a process already represented by a specialized adapter", async () => { + const snapshot = await attachGenericHarnessProcesses( + { + ts: 10_000, + agents: [ + { + identity: "codex:session", + id: "50", + pid: 50, + cmd: "codex", + cmdShort: "codex", + kind: "tui", + cpu: 0, + mem: 0, + state: "idle", + }, + ], + }, + { + processes: [ + { pid: 50, name: "openclaw", cmd: "openclaw agent" }, + ], + usage: { 50: { cpu: 9, memory: 1 } }, + } + ); + + assert.equal(snapshot.agents.length, 1); + assert.equal(snapshot.agents[0].kind, "tui"); + }); + + it("keeps command prompts out of titles and doing summaries", async () => { + const secret = "private prompt contents"; + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + processes: [ + { + pid: 70, + name: "cursor-agent", + cmd: `cursor-agent --print ${secret}`, + }, + ], + usage: { 70: { cpu: 0, memory: 0 } }, + }); + + const agent = snapshot.agents[0]; + assert.equal(agent.title, "Cursor Agent"); + assert.equal(agent.doing, "Cursor Agent agent"); + assert.equal(agent.title?.includes(secret), false); + assert.equal(agent.doing?.includes(secret), false); + }); + + it("reports honest process-only coverage in the graph", async () => { + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + processes: [{ pid: 80, name: "gemini", cmd: "gemini" }], + usage: { 80: { cpu: 0, memory: 0 } }, + }); + const graph = buildAgentGraph(snapshot); + + assert.equal(graph.coverage.providers.gemini.agents, 1); + assert.equal(graph.coverage.providers.gemini.agentsWithEvents, 0); + assert.equal(graph.coverage.providers.gemini.history, "unavailable"); + assert.match( + graph.coverage.providers.gemini.note ?? "", + /process-level coverage only/i + ); + }); + + it("distinguishes tool integrations from full agent loops", async () => { + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + processes: [{ pid: 90, name: "mmx", cmd: "mmx image generate" }], + usage: { 90: { cpu: 0, memory: 0 } }, + }); + const graph = buildAgentGraph(snapshot); + + assert.equal(graph.coverage.providers.minimax.history, "unavailable"); + assert.match( + graph.coverage.providers.minimax.note ?? "", + /tool\/model integration/i + ); + }); +}); From 8a14e780fb8fea2d61fc149318f08be0e0a756e1 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:43:35 -0400 Subject: [PATCH 31/72] refactor(ui): add a neutral harness palette type --- public/src/types.ts | 91 ++++++++++++++++++--------------------------- 1 file changed, 36 insertions(+), 55 deletions(-) diff --git a/public/src/types.ts b/public/src/types.ts index 306368f..d080dbf 100644 --- a/public/src/types.ts +++ b/public/src/types.ts @@ -1,5 +1,5 @@ export type AgentState = 'active' | 'idle' | 'error'; -export type CliType = 'codex' | 'opencode' | 'claude'; +export type CliType = 'codex' | 'opencode' | 'claude' | 'other'; export interface AgentSummary { current?: string; @@ -43,23 +43,24 @@ export interface AgentSnapshot { } export interface ActivityCounts { - active?: number; - idle?: number; - error?: number; + active: number; + idle: number; + error: number; } export interface ActivityTransitionSummary { - total?: number; - byReason?: Record; - byState?: Record; + total: number; + byReason: Record; + byState: Record; } export interface SnapshotMeta { + pollMs?: number; opencode?: { - ok?: boolean; + ok: boolean; reachable?: boolean; - error?: string; status?: number; + error?: string; }; activity?: { counts?: Record; @@ -74,34 +75,47 @@ export interface SnapshotPayload { meta?: SnapshotMeta; } -export type DeltaOp = - | { op: 'upsert'; id: string; value: AgentSnapshot } - | { op: 'remove'; id: string } - | { op: 'meta'; value: SnapshotMeta | null } - | { op: 'ts'; value: number }; +export interface TileColors { + top: string; + left: string; + right: string; + stroke: string; +} + +export interface CliPalette { + agent: Record; + server: Record; + accent: string; + accentStrong: string; + accentSoft: string; + glow: string; +} export interface WsHelloMessage { v: 1; t: 'hello'; - role: 'viewer'; - enc: 'json'; - lastSeq?: number; + protocol: 'json'; } export interface WsWelcomeMessage { v: 1; t: 'welcome'; - enc: 'json'; - serverTime: number; + seq: number; } export interface WsSnapshotMessage { v: 1; t: 'snapshot'; seq: number; - data: SnapshotPayload; + payload: SnapshotPayload; } +export type DeltaOp = + | { op: 'upsert'; id: string; value: AgentSnapshot } + | { op: 'remove'; id: string } + | { op: 'meta'; value: SnapshotMeta | null } + | { op: 'ts'; value: number }; + export interface WsDeltaMessage { v: 1; t: 'delta'; @@ -112,12 +126,13 @@ export interface WsDeltaMessage { export interface WsPingMessage { v: 1; t: 'ping'; + seq: number; } export interface WsPongMessage { v: 1; t: 'pong'; - ts: number; + seq: number; } export type WsClientMessage = WsHelloMessage | WsPongMessage; @@ -126,37 +141,3 @@ export type WsServerMessage = | WsSnapshotMessage | WsDeltaMessage | WsPingMessage; - -export interface TileColors { - top: string; - left: string; - right: string; - stroke: string; -} - -export interface CliPalette { - agent: Record; - server: Record; - accent: string; - accentStrong: string; - accentSoft: string; - glow: string; -} - -export type WsStatus = 'connecting' | 'live' | 'stale' | 'error' | 'disconnected'; - -export interface ViewState { - x: number; - y: number; - scale: number; -} - -export interface Coordinate { - x: number; - y: number; -} - -export interface ScreenPoint { - x: number; - y: number; -} From 01bf732408882aadf9ed6bb4eb3fa8ff4df2a4b5 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:44:10 -0400 Subject: [PATCH 32/72] fix(ui): stop rendering unknown harnesses as Codex --- public/src/lib/palette.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/public/src/lib/palette.ts b/public/src/lib/palette.ts index 9d85f29..d89dfa9 100644 --- a/public/src/lib/palette.ts +++ b/public/src/lib/palette.ts @@ -49,6 +49,22 @@ export const cliPalettes: Record = { accentSoft: 'rgba(127, 183, 255, 0.35)', glow: '127, 183, 255', }, + other: { + agent: { + active: { top: '#6f5b91', left: '#55456f', right: '#46395c', stroke: '#b89be8' }, + idle: { top: '#3d3a49', left: '#302d3a', right: '#282530', stroke: '#756d88' }, + error: { top: '#814143', left: '#693436', right: '#562b2d', stroke: '#dc6c72' }, + }, + server: { + active: { top: '#665d78', left: '#4f485e', right: '#423c4e', stroke: '#aa9bc3' }, + idle: { top: '#393742', left: '#2d2b34', right: '#25232b', stroke: '#6b6578' }, + error: { top: '#814143', left: '#693436', right: '#562b2d', stroke: '#dc6c72' }, + }, + accent: '#b89be8', + accentStrong: 'rgba(184, 155, 232, 0.6)', + accentSoft: 'rgba(184, 155, 232, 0.35)', + glow: '184, 155, 232', + }, }; export const stateOpacity: Record = { @@ -61,7 +77,8 @@ export function cliForAgent(agent: AgentSnapshot): CliType { const kind = agent.kind || ''; if (kind.startsWith('opencode')) return 'opencode'; if (kind.startsWith('claude')) return 'claude'; - return 'codex'; + if (kind === 'tui' || kind === 'exec' || kind === 'app-server') return 'codex'; + return 'other'; } export function isServerKind(kind: string): boolean { @@ -71,29 +88,29 @@ export function isServerKind(kind: string): boolean { export function paletteFor(agent: AgentSnapshot): TileColors { const cli = cliForAgent(agent); - const palette = cliPalettes[cli] ?? cliPalettes.codex; + const palette = cliPalettes[cli] ?? cliPalettes.other; const scope = isServerKind(agent.kind) ? palette.server : palette.agent; return scope[agent.state] ?? scope.idle; } export function accentFor(agent: AgentSnapshot): string { const cli = cliForAgent(agent); - return (cliPalettes[cli] ?? cliPalettes.codex).accent; + return (cliPalettes[cli] ?? cliPalettes.other).accent; } export function accentStrongFor(agent: AgentSnapshot): string { const cli = cliForAgent(agent); - return (cliPalettes[cli] ?? cliPalettes.codex).accentStrong; + return (cliPalettes[cli] ?? cliPalettes.other).accentStrong; } export function accentSoftFor(agent: AgentSnapshot): string { const cli = cliForAgent(agent); - return (cliPalettes[cli] ?? cliPalettes.codex).accentSoft; + return (cliPalettes[cli] ?? cliPalettes.other).accentSoft; } export function accentGlow(agent: AgentSnapshot, alpha: number): string { const cli = cliForAgent(agent); - const tint = (cliPalettes[cli] ?? cliPalettes.codex).glow; + const tint = (cliPalettes[cli] ?? cliPalettes.other).glow; return `rgba(${tint}, ${alpha})`; } From 79abfe3b59371be94badcfcb6dac55ea3654bc0a Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:45:23 -0400 Subject: [PATCH 33/72] docs: define multi-harness coverage and adapter order --- docs/harness-providers.md | 98 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/harness-providers.md diff --git a/docs/harness-providers.md b/docs/harness-providers.md new file mode 100644 index 0000000..afc535e --- /dev/null +++ b/docs/harness-providers.md @@ -0,0 +1,98 @@ +# Harness providers + +Consensus models the **agent harness or runtime** separately from the model provider. + +Examples: + +- `openai`, `anthropic`, `minimax`, and `moonshot` may provide models or tools. +- Codex, Claude Code, OpenClaw, Hermes, Kimi Code, Cursor Agent, Factory Droid, and Warp Oz provide execution loops or agent surfaces. + +This distinction prevents a MiniMax model used inside OpenClaw from being reported as a separate agent loop unless a distinct MiniMax runtime is actually running. + +## Coverage levels + +Consensus reports coverage by evidence source rather than a single supported flag: + +- **Native events:** structured local runtime, server, RPC, or log events. +- **Hooks:** lifecycle callbacks configured in the harness. +- **Structured stream:** machine-readable CLI output attached to a run. +- **Remote API:** run status and history fetched from a hosted agent service. +- **Process only:** the local process is visible, but no step history is attached. +- **Tool only:** the process supplies a model or tool to another harness rather than owning the loop. +- **Manual:** an IDE or extension adapter is required; process matching would be unreliable. + +## Current registry + +| Harness | Class | Best evidence source | Current Consensus depth | +| --- | --- | --- | --- | +| Codex | Local CLI/runtime | Notify events and bounded local session records | Event graph | +| OpenCode | Local server/TUI | HTTP API and SSE event streams | Event graph | +| Claude Code | Local CLI | Lifecycle hooks | Event graph | +| OpenClaw | Local server/runtime | Gateway RPC run, tool, assistant, and lifecycle streams | Process discovery; native adapter next | +| Hermes Agent | Local CLI/gateway | Gateway, plugin, and shell hooks | Process discovery; hook adapter next | +| Kimi Code | Local CLI | Lifecycle hooks | Process discovery; hook adapter next | +| MiniMax CLI | Tool/model integration | CLI invocation inside another harness | Tool-only discovery | +| Cursor Agent | Local CLI/remote agent | Partial hooks and `stream-json` CLI output | Process discovery; stream adapter next | +| Warp Oz | Local and remote agent | Oz API/SDK run status and transcripts | Process discovery; remote API adapter next | +| Factory Droid | Local CLI | Lifecycle hooks | Process discovery; hook adapter next | +| Qwen Code | Local CLI | Command or HTTP lifecycle hooks | Process discovery; hook adapter next | +| Gemini CLI | Local CLI | No Consensus event adapter yet | Process discovery | +| Antigravity CLI | Local CLI | No Consensus event adapter yet | Process discovery | +| GitHub Copilot CLI | Local CLI | No Consensus event adapter yet | Process discovery | +| Aider | Local CLI | No Consensus event adapter yet | Process discovery | +| Goose | Local CLI | No Consensus event adapter yet | Process discovery | +| Amp | Local CLI | No Consensus event adapter yet | Process discovery | +| Amazon Q Developer | Local CLI | No Consensus event adapter yet | Process discovery | +| Kiro | Local CLI/IDE | No Consensus event adapter yet | Process discovery | +| OpenHands | Local or hosted runtime | Runtime/API integration needed | Process discovery | +| Cline | IDE extension | Extension adapter | Registry only | +| Roo Code | IDE extension | Extension adapter | Registry only | +| Windsurf Cascade | IDE agent | IDE adapter | Registry only | +| JetBrains Junie | IDE agent | IDE adapter | Registry only | +| Replit Agent | Remote agent | Remote API adapter | Registry only | +| Zed Agent | IDE agent | IDE adapter | Registry only | + +## Process discovery contract + +The one-shot snapshot and graph commands detect exact executable names. They do not search arbitrary prompt text for provider names. + +Examples: + +- `openclaw gateway` becomes `openclaw-server`. +- `openclaw agent` becomes `openclaw-cli`. +- `hermes gateway` becomes `hermes-server`. +- `oz agent run` becomes `warp-cli`; unrelated `oz` commands are ignored. +- `q chat` becomes `amazon-q-cli`; unrelated `q` processes are ignored. +- Cursor detection uses `cursor-agent`; the generic executable name `agent` is not matched. + +Generic detections carry no synthetic step events. Their graph coverage is therefore explicit about being process-only, tool-only, hook-ready, stream-ready, or remote-API-ready. + +## Adapter order + +1. **Shared hook adapter:** Claude-style lifecycle JSON covers Kimi Code, Factory Droid, and Qwen Code with small provider maps. Hermes uses a related hook surface and can feed the same normalized event model through a provider-specific translator. +2. **OpenClaw adapter:** consume Gateway RPC lifecycle, assistant, and tool streams keyed by `runId` and session. +3. **Cursor adapter:** ingest `--output-format stream-json` for headless runs and use supported CLI hooks where available. +4. **Warp adapter:** read Oz run status, transcripts, and metadata through the official API/SDK. +5. **IDE adapters:** add explicit extension bridges for Cline, Roo Code, Windsurf, Junie, and Zed rather than guessing from Electron helper processes. +6. **Remote adapters:** add authenticated, opt-in connectors for Replit Agent and other hosted runtimes. + +## Product boundary + +Consensus does not claim event-level support merely because it recognizes a process. It also does not infer that a model vendor owns the active loop. Parent, delegation, handoff, retry, approval, and cross-harness causal edges require provider evidence. + +## Sources checked + +Provider contracts were checked on July 19, 2026: + +- Codex configuration: +- Claude Code hooks: +- OpenCode server and SSE: +- OpenClaw agent loop and runtimes: and +- Hermes hooks: +- Kimi Code hooks: +- MiniMax CLI: +- Cursor CLI: +- Warp Oz CLI/API: +- Factory Droid hooks: +- Qwen Code hooks: +- Gemini CLI: From 322452a020ad4b9cccbb8604990d7a63d70218ca Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:50:32 -0400 Subject: [PATCH 34/72] feat(harnesses): normalize shared hook payloads --- src/harnessHookModel.ts | 387 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 src/harnessHookModel.ts diff --git a/src/harnessHookModel.ts b/src/harnessHookModel.ts new file mode 100644 index 0000000..e0f54bd --- /dev/null +++ b/src/harnessHookModel.ts @@ -0,0 +1,387 @@ +import { createHash } from "node:crypto"; +import type { HarnessId } from "./harnesses.js"; +import type { EventSummary } from "./types.js"; + +export const hookHarnessIds = [ + "claude", + "hermes", + "kimi", + "factory", + "qwen", +] as const satisfies readonly HarnessId[]; + +export type HookHarnessId = (typeof hookHarnessIds)[number]; +export type CanonicalHarnessHookType = + | "SessionStart" + | "SessionEnd" + | "UserPromptSubmit" + | "UserPromptExpansion" + | "MessageDisplay" + | "PreToolUse" + | "PostToolUse" + | "PostToolUseFailure" + | "PostToolBatch" + | "PermissionRequest" + | "PermissionResult" + | "PermissionDenied" + | "SubagentStart" + | "SubagentStop" + | "TaskCreated" + | "TaskCompleted" + | "PreVerify" + | "Stop" + | "StopFailure" + | "Interrupt" + | "PreCompact" + | "PostCompact" + | "Notification"; + +export interface NormalizedHarnessHookEvent { + version: 1; + harnessId: HookHarnessId; + type: CanonicalHarnessHookType; + sessionKey: string; + timestamp: number; + cwdKey?: string; + turnKey?: string; + toolName?: string; + agentType?: string; + notificationType?: string; + final?: boolean; +} + +const MAX_LABEL_LENGTH = 160; +const MAX_IDENTIFIER_LENGTH = 512; +const SAFE_TEXT_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g; +const DIRECT_TYPES = new Map([ + ["sessionstart", "SessionStart"], + ["sessionend", "SessionEnd"], + ["userpromptsubmit", "UserPromptSubmit"], + ["userpromptexpansion", "UserPromptExpansion"], + ["messagedisplay", "MessageDisplay"], + ["pretooluse", "PreToolUse"], + ["posttooluse", "PostToolUse"], + ["posttoolusefailure", "PostToolUseFailure"], + ["posttoolbatch", "PostToolBatch"], + ["permissionrequest", "PermissionRequest"], + ["permissionresult", "PermissionResult"], + ["permissiondenied", "PermissionDenied"], + ["subagentstart", "SubagentStart"], + ["subagentstop", "SubagentStop"], + ["taskcreated", "TaskCreated"], + ["taskcompleted", "TaskCompleted"], + ["stop", "Stop"], + ["stopfailure", "StopFailure"], + ["interrupt", "Interrupt"], + ["precompact", "PreCompact"], + ["postcompact", "PostCompact"], + ["notification", "Notification"], +]); +const HERMES_TYPES = new Map([ + ["pretoolcall", "PreToolUse"], + ["posttoolcall", "PostToolUse"], + ["prellmcall", "UserPromptSubmit"], + ["postllmcall", "Stop"], + ["preverify", "PreVerify"], + ["onsessionstart", "SessionStart"], + ["onsessionend", "SessionEnd"], + ["onsessionfinalize", "SessionEnd"], + ["onsessionreset", "SessionEnd"], + ["subagentstart", "SubagentStart"], + ["subagentstop", "SubagentStop"], + ["agentstart", "UserPromptSubmit"], + ["agentend", "Stop"], +]); + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function compactName(value: string): string { + return value.replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function safeText(value: unknown, maxLength = MAX_LABEL_LENGTH): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value + .replace(SAFE_TEXT_RE, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); + return normalized || undefined; +} + +function identifier(value: unknown): string | undefined { + return safeText(value, MAX_IDENTIFIER_LENGTH); +} + +function firstString( + payload: Record, + keys: readonly string[] +): string | undefined { + for (const key of keys) { + const value = identifier(payload[key]); + if (value) return value; + } + return undefined; +} + +function firstLabel( + payload: Record, + keys: readonly string[] +): string | undefined { + for (const key of keys) { + const value = safeText(payload[key]); + if (value) return value; + } + return undefined; +} + +function nestedLabel( + payload: Record, + containerKey: string, + keys: readonly string[] +): string | undefined { + const container = payload[containerKey]; + if (!isRecord(container)) return undefined; + return firstLabel(container, keys); +} + +function parseTimestamp(value: unknown, receivedAt: number): number | undefined { + let parsed: number | undefined; + if (typeof value === "number" && Number.isFinite(value)) { + parsed = value < 100_000_000_000 ? value * 1000 : value; + } else if (typeof value === "string") { + const asNumber = Number(value); + if (Number.isFinite(asNumber) && value.trim() !== "") { + parsed = asNumber < 100_000_000_000 ? asNumber * 1000 : asNumber; + } else { + const asDate = Date.parse(value); + if (!Number.isNaN(asDate)) parsed = asDate; + } + } + const fallback = Number.isFinite(receivedAt) && receivedAt >= 0 ? receivedAt : Date.now(); + if (parsed === undefined) return fallback; + return parsed >= 0 ? parsed : undefined; +} + +function opaqueKey(scope: string, value: string): string { + return createHash("sha256") + .update(scope) + .update("\0") + .update(value) + .digest("hex") + .slice(0, 24); +} + +function canonicalType( + harnessId: HookHarnessId, + rawType: string +): CanonicalHarnessHookType | undefined { + const compact = compactName(rawType); + if (harnessId === "hermes") return HERMES_TYPES.get(compact); + return DIRECT_TYPES.get(compact); +} + +function extractRawType(payload: Record): string | undefined { + return firstString(payload, [ + "hook_event_name", + "hookEventName", + "event_name", + "eventName", + "event", + "type", + ]); +} + +function extractSessionId( + harnessId: HookHarnessId, + payload: Record +): string | undefined { + const direct = firstString(payload, [ + "session_id", + "sessionId", + "conversation_id", + "conversationId", + ]); + if (direct) return direct; + if (harnessId !== "hermes") return undefined; + return firstString(payload, ["task_id", "taskId"]); +} + +function extractTurnId(payload: Record): string | undefined { + const direct = firstString(payload, [ + "turn_id", + "turnId", + "api_request_id", + "apiRequestId", + ]); + if (direct) return direct; + const extra = payload.extra; + if (!isRecord(extra)) return undefined; + return firstString(extra, [ + "turn_id", + "turnId", + "api_request_id", + "apiRequestId", + ]); +} + +function extractBoolean( + payload: Record, + keys: readonly string[] +): boolean | undefined { + for (const key of keys) { + if (typeof payload[key] === "boolean") return payload[key] as boolean; + } + return undefined; +} + +export function isHookHarnessId(value: unknown): value is HookHarnessId { + return ( + typeof value === "string" && + (hookHarnessIds as readonly string[]).includes(value) + ); +} + +export function normalizeHarnessHookPayload( + harnessId: HookHarnessId, + input: unknown, + receivedAt: number = Date.now() +): NormalizedHarnessHookEvent | undefined { + if (!isRecord(input)) return undefined; + const rawType = extractRawType(input); + const sessionId = extractSessionId(harnessId, input); + if (!rawType || !sessionId) return undefined; + const type = canonicalType(harnessId, rawType); + if (!type) return undefined; + const timestamp = parseTimestamp(input.timestamp, receivedAt); + if (timestamp === undefined) return undefined; + + const cwd = firstString(input, ["cwd", "working_directory", "workingDirectory"]); + const turnId = extractTurnId(input); + const toolName = + firstLabel(input, ["tool_name", "toolName", "matcher"]) ?? + nestedLabel(input, "extra", ["tool_name", "toolName"]); + const agentType = + firstLabel(input, ["agent_type", "agentType", "subagent_type", "subagentType"]) ?? + nestedLabel(input, "extra", [ + "agent_type", + "agentType", + "child_role", + "childRole", + ]); + const notificationType = firstLabel(input, [ + "notification_type", + "notificationType", + "reason", + ]); + + return { + version: 1, + harnessId, + type, + sessionKey: opaqueKey(`session:${harnessId}`, sessionId), + timestamp, + ...(cwd ? { cwdKey: opaqueKey(`cwd:${harnessId}`, cwd) } : {}), + ...(turnId ? { turnKey: opaqueKey(`turn:${harnessId}`, `${sessionId}\0${turnId}`) } : {}), + ...(toolName ? { toolName } : {}), + ...(agentType ? { agentType } : {}), + ...(notificationType ? { notificationType } : {}), + ...(() => { + const final = extractBoolean(input, ["final", "is_final", "isFinal"]); + return final === undefined ? {} : { final }; + })(), + }; +} + +export function harnessHookEventSummary( + event: NormalizedHarnessHookEvent +): EventSummary | undefined { + let type: string = event.type; + let summary = `event: ${event.type}`; + let isError = false; + + switch (event.type) { + case "UserPromptSubmit": + summary = "prompt"; + break; + case "UserPromptExpansion": + return undefined; + case "MessageDisplay": + if (event.final === false) return undefined; + summary = "message"; + break; + case "PreToolUse": + case "PostToolUse": + case "PostToolBatch": + case "PermissionRequest": + case "PermissionResult": + case "PermissionDenied": + summary = `tool: ${event.toolName || event.type}`; + break; + case "PostToolUseFailure": + summary = `tool: ${event.toolName || event.type}`; + isError = true; + break; + case "SubagentStart": + case "SubagentStop": + summary = event.agentType + ? `tool: subagent ${event.agentType}` + : "tool: subagent"; + break; + case "TaskCreated": + case "TaskCompleted": + summary = "tool: task"; + break; + case "PreVerify": + summary = "tool: verify"; + break; + case "StopFailure": + type = "turn.failed"; + isError = true; + break; + case "Interrupt": + type = "turn.interrupted"; + break; + case "Stop": + type = "turn.completed"; + break; + case "SessionStart": + type = "session.started"; + break; + case "SessionEnd": + type = "session.ended"; + break; + case "PreCompact": + case "PostCompact": + case "Notification": + return undefined; + } + + return { + ts: event.timestamp, + type, + summary, + ...(isError ? { isError: true } : {}), + ...(event.turnKey ? { turnId: event.turnKey } : {}), + }; +} + +export function harnessHookEventKey( + event: NormalizedHarnessHookEvent +): string { + return JSON.stringify([ + event.version, + event.harnessId, + event.type, + event.sessionKey, + event.timestamp, + event.cwdKey ?? null, + event.turnKey ?? null, + event.toolName ?? null, + event.agentType ?? null, + event.notificationType ?? null, + event.final ?? null, + ]); +} From e22f34206a5d0229833cf4483cc550109e7c44bc Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:51:18 -0400 Subject: [PATCH 35/72] test(harnesses): cover shared hook normalization --- tests/unit/harnessHookModel.test.ts | 181 ++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/unit/harnessHookModel.test.ts diff --git a/tests/unit/harnessHookModel.test.ts b/tests/unit/harnessHookModel.test.ts new file mode 100644 index 0000000..72fbb94 --- /dev/null +++ b/tests/unit/harnessHookModel.test.ts @@ -0,0 +1,181 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + harnessHookEventKey, + harnessHookEventSummary, + normalizeHarnessHookPayload, +} from "../../src/harnessHookModel.js"; + +describe("shared harness hook model", () => { + it("normalizes Claude-style hooks without retaining prompt or tool input", () => { + const event = normalizeHarnessHookPayload( + "kimi", + { + hook_event_name: "PreToolUse", + session_id: "session-secret", + cwd: "/Users/alice/private/project", + timestamp: "2026-07-19T01:00:00.000Z", + tool_name: "Bash", + tool_input: { command: "rm -rf private" }, + prompt: "private prompt", + }, + 1 + ); + + assert.ok(event); + assert.equal(event?.harnessId, "kimi"); + assert.equal(event?.type, "PreToolUse"); + assert.equal(event?.toolName, "Bash"); + assert.match(event?.sessionKey ?? "", /^[a-f0-9]{24}$/); + assert.match(event?.cwdKey ?? "", /^[a-f0-9]{24}$/); + const serialized = JSON.stringify(event); + assert.equal(serialized.includes("session-secret"), false); + assert.equal(serialized.includes("/Users/alice"), false); + assert.equal(serialized.includes("rm -rf"), false); + assert.equal(serialized.includes("private prompt"), false); + }); + + it("maps Hermes loop hooks to canonical turn and tool events", () => { + const payloads = [ + ["pre_llm_call", "UserPromptSubmit", "prompt"], + ["pre_tool_call", "PreToolUse", "tool: terminal"], + ["post_tool_call", "PostToolUse", "tool: terminal"], + ["pre_verify", "PreVerify", "tool: verify"], + ["post_llm_call", "Stop", "event: Stop"], + ["subagent_stop", "SubagentStop", "tool: subagent explore"], + ] as const; + + for (const [rawType, canonicalType, summary] of payloads) { + const normalized = normalizeHarnessHookPayload("hermes", { + hook_event_name: rawType, + session_id: "hermes-session", + timestamp: 1_000, + tool_name: "terminal", + extra: { child_role: "explore", turn_id: "turn-1" }, + }); + assert.equal(normalized?.type, canonicalType, rawType); + assert.equal(harnessHookEventSummary(normalized!)?.summary, summary, rawType); + } + }); + + it("normalizes Kimi interrupt and failure events into turn boundaries", () => { + const interrupted = normalizeHarnessHookPayload("kimi", { + hook_event_name: "Interrupt", + session_id: "session", + timestamp: 1, + }); + const failed = normalizeHarnessHookPayload("kimi", { + hook_event_name: "StopFailure", + session_id: "session", + timestamp: 2, + }); + + assert.equal(harnessHookEventSummary(interrupted!)?.type, "turn.interrupted"); + assert.equal(harnessHookEventSummary(failed!)?.type, "turn.failed"); + assert.equal(harnessHookEventSummary(failed!)?.isError, true); + }); + + it("drops non-final stream fragments and activity-only events from graph history", () => { + const partial = normalizeHarnessHookPayload("claude", { + hook_event_name: "MessageDisplay", + session_id: "session", + timestamp: 1, + final: false, + }); + const expansion = normalizeHarnessHookPayload("claude", { + hook_event_name: "UserPromptExpansion", + session_id: "session", + timestamp: 2, + }); + const notification = normalizeHarnessHookPayload("factory", { + hook_event_name: "Notification", + session_id: "session", + timestamp: 3, + notification_type: "idle_prompt", + }); + + assert.equal(harnessHookEventSummary(partial!), undefined); + assert.equal(harnessHookEventSummary(expansion!), undefined); + assert.equal(harnessHookEventSummary(notification!), undefined); + }); + + it("uses opaque turn correlation keys consistently", () => { + const first = normalizeHarnessHookPayload("qwen", { + hook_event_name: "UserPromptSubmit", + session_id: "session", + turn_id: "turn-secret", + timestamp: 1, + }); + const second = normalizeHarnessHookPayload("qwen", { + hook_event_name: "PreToolUse", + session_id: "session", + turn_id: "turn-secret", + timestamp: 2, + tool_name: "Bash", + }); + + assert.equal(first?.turnKey, second?.turnKey); + assert.match(first?.turnKey ?? "", /^[a-f0-9]{24}$/); + assert.equal(JSON.stringify(first).includes("turn-secret"), false); + assert.equal(harnessHookEventSummary(first!)?.turnId, first?.turnKey); + }); + + it("parses seconds, milliseconds, ISO timestamps, and received-time fallback", () => { + const seconds = normalizeHarnessHookPayload("factory", { + hook_event_name: "SessionStart", + session_id: "s1", + timestamp: 1_700_000_000, + }); + const milliseconds = normalizeHarnessHookPayload("factory", { + hook_event_name: "SessionStart", + session_id: "s2", + timestamp: 1_700_000_000_000, + }); + const iso = normalizeHarnessHookPayload("factory", { + hook_event_name: "SessionStart", + session_id: "s3", + timestamp: "2026-07-19T00:00:00.000Z", + }); + const fallback = normalizeHarnessHookPayload( + "factory", + { hook_event_name: "SessionStart", session_id: "s4" }, + 1234 + ); + + assert.equal(seconds?.timestamp, 1_700_000_000_000); + assert.equal(milliseconds?.timestamp, 1_700_000_000_000); + assert.equal(iso?.timestamp, Date.parse("2026-07-19T00:00:00.000Z")); + assert.equal(fallback?.timestamp, 1234); + }); + + it("rejects unknown events and malformed payloads", () => { + assert.equal(normalizeHarnessHookPayload("kimi", null), undefined); + assert.equal( + normalizeHarnessHookPayload("kimi", { + hook_event_name: "UnknownFutureEvent", + session_id: "session", + }), + undefined + ); + assert.equal( + normalizeHarnessHookPayload("kimi", { + hook_event_name: "PreToolUse", + }), + undefined + ); + }); + + it("creates stable deduplication keys from retained metadata only", () => { + const event = normalizeHarnessHookPayload("factory", { + hook_event_name: "PostToolUse", + session_id: "session", + timestamp: 1, + tool_name: "Execute", + tool_output: "secret", + }); + const key = harnessHookEventKey(event!); + + assert.equal(key.includes("secret"), false); + assert.equal(key, harnessHookEventKey(event!)); + }); +}); From c0542186d3bda12dfd9dffc657a053661f70758c Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:52:15 -0400 Subject: [PATCH 36/72] feat(harnesses): centralize opaque correlation keys --- src/harnessKeys.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/harnessKeys.ts diff --git a/src/harnessKeys.ts b/src/harnessKeys.ts new file mode 100644 index 0000000..a571a16 --- /dev/null +++ b/src/harnessKeys.ts @@ -0,0 +1,27 @@ +import { createHash } from "node:crypto"; +import type { HarnessId } from "./harnesses.js"; + +function opaqueHarnessKey(scope: string, value: string): string { + return createHash("sha256") + .update(scope) + .update("\0") + .update(value) + .digest("hex") + .slice(0, 24); +} + +export function harnessSessionKey(harnessId: HarnessId, sessionId: string): string { + return opaqueHarnessKey(`session:${harnessId}`, sessionId); +} + +export function harnessCwdKey(harnessId: HarnessId, cwd: string): string { + return opaqueHarnessKey(`cwd:${harnessId}`, cwd); +} + +export function harnessTurnKey( + harnessId: HarnessId, + sessionId: string, + turnId: string +): string { + return opaqueHarnessKey(`turn:${harnessId}`, `${sessionId}\0${turnId}`); +} From d7dcf22ebd93cd78369b2b9b9ac0c837a5a1f869 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:52:59 -0400 Subject: [PATCH 37/72] feat(harnesses): persist normalized hook metadata --- src/services/harnessEventLog.ts | 229 ++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 src/services/harnessEventLog.ts diff --git a/src/services/harnessEventLog.ts b/src/services/harnessEventLog.ts new file mode 100644 index 0000000..70056a1 --- /dev/null +++ b/src/services/harnessEventLog.ts @@ -0,0 +1,229 @@ +import { + appendFile, + chmod, + mkdir, + readFile, + rename, + stat, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { + harnessHookEventKey, + isHookHarnessId, + type CanonicalHarnessHookType, + type NormalizedHarnessHookEvent, +} from "../harnessHookModel.js"; + +const DEFAULT_LOG_MAX_BYTES = 2 * 1024 * 1024; +const MIN_LOG_MAX_BYTES = 64 * 1024; +const DEFAULT_RETENTION_MS = 30 * 60 * 1000; +const MAX_SEEN_EVENTS = 10_000; +const KEY_RE = /^[a-f0-9]{24}$/; +const LABEL_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/; +const CANONICAL_TYPES = new Set([ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "UserPromptExpansion", + "MessageDisplay", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "PermissionRequest", + "PermissionResult", + "PermissionDenied", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "PreVerify", + "Stop", + "StopFailure", + "Interrupt", + "PreCompact", + "PostCompact", + "Notification", +]); +const seenEvents = new Set(); +let seenPath: string | undefined; +let persistenceQueue: Promise = Promise.resolve(); + +function resolveLogPath(): string | undefined { + const configured = process.env.CONSENSUS_HARNESS_EVENT_LOG?.trim(); + if (configured) { + const lowered = configured.toLowerCase(); + if (lowered === "0" || lowered === "false" || lowered === "off") { + return undefined; + } + return path.resolve(configured); + } + return path.join(homedir(), ".consensus", "harness-events.jsonl"); +} + +function resolveLogMaxBytes(): number { + const parsed = Number(process.env.CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LOG_MAX_BYTES; + return Math.max(MIN_LOG_MAX_BYTES, Math.floor(parsed)); +} + +function resolveRetentionMs(): number { + const parsed = Number(process.env.CONSENSUS_HARNESS_EVENT_TTL_MS); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_RETENTION_MS; + return Math.floor(parsed); +} + +function useSeenPath(filePath: string): void { + if (seenPath === filePath) return; + seenEvents.clear(); + seenPath = filePath; +} + +function rememberEvent(key: string): boolean { + if (seenEvents.has(key)) return false; + seenEvents.add(key); + while (seenEvents.size > MAX_SEEN_EVENTS) { + const oldest = seenEvents.values().next().value; + if (typeof oldest !== "string") break; + seenEvents.delete(oldest); + } + return true; +} + +function newestCompleteLines(buffer: Buffer, keepBytes: number): Buffer { + if (buffer.length <= keepBytes) return buffer; + const tail = buffer.subarray(Math.max(0, buffer.length - keepBytes)); + const firstNewline = tail.indexOf(0x0a); + return firstNewline >= 0 ? tail.subarray(firstNewline + 1) : Buffer.alloc(0); +} + +async function trimLog(filePath: string, maxBytes: number): Promise { + let info; + try { + info = await stat(filePath); + } catch { + return; + } + if (info.size <= maxBytes) return; + + const content = await readFile(filePath); + const keepBytes = Math.max(32 * 1024, Math.floor(maxBytes / 2)); + const trimmed = newestCompleteLines(content, keepBytes); + const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + await writeFile(tempPath, trimmed, { mode: 0o600 }); + await rename(tempPath, filePath); + await chmod(filePath, 0o600).catch(() => undefined); +} + +function isSafeLabel(value: unknown): boolean { + return ( + value === undefined || + (typeof value === "string" && + value.length <= 160 && + !LABEL_CONTROL_RE.test(value)) + ); +} + +export function isStoredHarnessHookEvent( + value: unknown +): value is NormalizedHarnessHookEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const event = value as Record; + return ( + event.version === 1 && + isHookHarnessId(event.harnessId) && + typeof event.type === "string" && + CANONICAL_TYPES.has(event.type as CanonicalHarnessHookType) && + typeof event.sessionKey === "string" && + KEY_RE.test(event.sessionKey) && + typeof event.timestamp === "number" && + Number.isFinite(event.timestamp) && + event.timestamp >= 0 && + (event.cwdKey === undefined || + (typeof event.cwdKey === "string" && KEY_RE.test(event.cwdKey))) && + (event.turnKey === undefined || + (typeof event.turnKey === "string" && KEY_RE.test(event.turnKey))) && + isSafeLabel(event.toolName) && + isSafeLabel(event.agentType) && + isSafeLabel(event.notificationType) && + (event.final === undefined || typeof event.final === "boolean") + ); +} + +async function persistEvent( + filePath: string, + event: NormalizedHarnessHookEvent +): Promise { + await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + await appendFile(filePath, `${JSON.stringify(event)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await chmod(filePath, 0o600).catch(() => undefined); + await trimLog(filePath, resolveLogMaxBytes()); +} + +export function queueHarnessEventPersistence( + event: NormalizedHarnessHookEvent +): Promise { + const filePath = resolveLogPath(); + if (!filePath || !isStoredHarnessHookEvent(event)) return persistenceQueue; + useSeenPath(filePath); + const key = harnessHookEventKey(event); + if (!rememberEvent(key)) return persistenceQueue; + + persistenceQueue = persistenceQueue + .then(() => persistEvent(filePath, event)) + .catch(() => { + if (seenPath === filePath) seenEvents.delete(key); + }); + return persistenceQueue; +} + +export function flushHarnessEventPersistence(): Promise { + return persistenceQueue; +} + +export async function readStoredHarnessEvents(): Promise< + NormalizedHarnessHookEvent[] +> { + const filePath = resolveLogPath(); + if (!filePath) return []; + useSeenPath(filePath); + + let input: string; + try { + input = await readFile(filePath, "utf8"); + } catch { + return []; + } + + const cutoff = Date.now() - resolveRetentionMs(); + const events: NormalizedHarnessHookEvent[] = []; + for (const line of input.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const parsed: unknown = JSON.parse(line); + if (!isStoredHarnessHookEvent(parsed) || parsed.timestamp < cutoff) continue; + events.push(parsed); + } catch { + // Ignore malformed and partially written lines. + } + } + events.sort( + (a, b) => + a.timestamp - b.timestamp || harnessHookEventKey(a).localeCompare(harnessHookEventKey(b)) + ); + + const result: NormalizedHarnessHookEvent[] = []; + for (const event of events) { + const key = harnessHookEventKey(event); + if (!rememberEvent(key)) continue; + result.push(event); + } + return result; +} From 81d0cc97575d3f30631ff41d77795fb678d7509a Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:53:16 -0400 Subject: [PATCH 38/72] feat(harnesses): add shared metadata-only hook command --- src/harnessHook.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/harnessHook.ts diff --git a/src/harnessHook.ts b/src/harnessHook.ts new file mode 100644 index 0000000..ea7e341 --- /dev/null +++ b/src/harnessHook.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { + isHookHarnessId, + normalizeHarnessHookPayload, +} from "./harnessHookModel.js"; +import { + flushHarnessEventPersistence, + queueHarnessEventPersistence, +} from "./services/harnessEventLog.js"; + +const MAX_HOOK_INPUT_BYTES = 1024 * 1024; + +async function readStdin(): Promise { + if (process.stdin.isTTY) return ""; + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of process.stdin) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > MAX_HOOK_INPUT_BYTES) return ""; + chunks.push(buffer); + } + return Buffer.concat(chunks, totalBytes).toString("utf8"); +} + +async function main(): Promise { + const harnessId = process.argv[2]; + if (!isHookHarnessId(harnessId)) return; + const input = await readStdin(); + if (!input.trim()) return; + + let payload: unknown; + try { + payload = JSON.parse(input); + } catch { + return; + } + + const event = normalizeHarnessHookPayload(harnessId, payload); + if (!event) return; + await queueHarnessEventPersistence(event); + await flushHarnessEventPersistence(); +} + +void main().catch(() => undefined); From fbbc2b47c69f0b40ac26162d4d0b0d1a2690a999 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:54:05 -0400 Subject: [PATCH 39/72] feat(harnesses): reduce normalized hook events into session state --- src/services/harnessEvents.ts | 253 ++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 src/services/harnessEvents.ts diff --git a/src/services/harnessEvents.ts b/src/services/harnessEvents.ts new file mode 100644 index 0000000..49bfb9a --- /dev/null +++ b/src/services/harnessEvents.ts @@ -0,0 +1,253 @@ +import type { HarnessId } from "../harnesses.js"; +import { + harnessHookEventKey, + harnessHookEventSummary, + type HookHarnessId, + type NormalizedHarnessHookEvent, +} from "../harnessHookModel.js"; +import type { EventSummary } from "../types.js"; +import { + flushHarnessEventPersistence, + queueHarnessEventPersistence, + readStoredHarnessEvents, +} from "./harnessEventLog.js"; + +const DEFAULT_RETENTION_MS = 30 * 60 * 1000; +const DEFAULT_INFLIGHT_TIMEOUT_MS = 30_000; +const MAX_EVENTS = 50; +const INFLIGHT_TYPES = new Set([ + "UserPromptSubmit", + "MessageDisplay", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "PermissionRequest", + "PermissionResult", + "PermissionDenied", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "PreVerify", +]); +const IDLE_TYPES = new Set([ + "Stop", + "StopFailure", + "Interrupt", + "SessionEnd", +]); +const ERROR_TYPES = new Set([ + "PostToolUseFailure", + "StopFailure", +]); +const NON_ACTIVITY_TYPES = new Set([ + "SessionStart", + "SessionEnd", + "PreCompact", + "PostCompact", + "Notification", +]); + +export interface HarnessSessionState { + harnessId: HookHarnessId; + sessionKey: string; + cwdKey?: string; + inFlight: boolean; + hasError: boolean; + lastSeenAt: number; + lastActivityAt?: number; + lastEventType?: NormalizedHarnessHookEvent["type"]; + events: EventSummary[]; +} + +type StateMap = Map; +const stateBySession = new Map(); + +function stateKey(harnessId: HarnessId, sessionKey: string): string { + return `${harnessId}:${sessionKey}`; +} + +function retentionMs(): number { + const parsed = Number(process.env.CONSENSUS_HARNESS_EVENT_TTL_MS); + return Number.isFinite(parsed) && parsed > 0 + ? Math.floor(parsed) + : DEFAULT_RETENTION_MS; +} + +function inFlightTimeoutMs(): number { + const parsed = Number(process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS); + return Number.isFinite(parsed) && parsed > 0 + ? Math.floor(parsed) + : DEFAULT_INFLIGHT_TIMEOUT_MS; +} + +function eventSummaryKey(event: EventSummary): string { + return JSON.stringify([ + event.ts, + event.type, + event.summary, + event.isError === true, + event.turnId ?? null, + ]); +} + +function mergeEvents( + existing: EventSummary[], + next: EventSummary | undefined +): EventSummary[] { + if (!next) return existing; + const seen = new Set(); + return [...existing, next] + .map((event, index) => ({ event, index })) + .filter(({ event }) => { + const key = eventSummaryKey(event); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => a.event.ts - b.event.ts || a.index - b.index) + .slice(-MAX_EVENTS) + .map(({ event }) => event); +} + +function isActivityEvent(event: NormalizedHarnessHookEvent): boolean { + if (event.type === "Notification") { + return event.notificationType?.toLowerCase() !== "idle_prompt"; + } + return !NON_ACTIVITY_TYPES.has(event.type); +} + +function expireState( + state: HarnessSessionState, + now: number +): HarnessSessionState { + if (!state.inFlight || typeof state.lastActivityAt !== "number") return state; + return now - state.lastActivityAt > inFlightTimeoutMs() + ? { ...state, inFlight: false } + : state; +} + +function pruneState(map: StateMap, now: number): StateMap { + const cutoff = now - retentionMs(); + const next = new Map(); + for (const [key, state] of map) { + if (state.lastSeenAt < cutoff) continue; + next.set(key, expireState(state, now)); + } + return next; +} + +export function applyHarnessEvent( + map: StateMap, + event: NormalizedHarnessHookEvent +): StateMap { + const key = stateKey(event.harnessId, event.sessionKey); + const previous = map.get(key); + const summary = harnessHookEventSummary(event); + const events = mergeEvents(previous?.events ?? [], summary); + + if (previous && event.timestamp < previous.lastSeenAt) { + const nextMap = new Map(map); + nextMap.set(key, { + ...previous, + cwdKey: previous.cwdKey ?? event.cwdKey, + events, + }); + return nextMap; + } + + const idleNotification = + event.type === "Notification" && + event.notificationType?.toLowerCase() === "idle_prompt"; + const activity = isActivityEvent(event); + const hasError = ERROR_TYPES.has(event.type) + ? true + : activity + ? false + : previous?.hasError ?? false; + let inFlight = previous?.inFlight ?? false; + if (INFLIGHT_TYPES.has(event.type)) inFlight = true; + if (IDLE_TYPES.has(event.type) || idleNotification) inFlight = false; + + const nextState: HarnessSessionState = { + harnessId: event.harnessId, + sessionKey: event.sessionKey, + cwdKey: event.cwdKey ?? previous?.cwdKey, + inFlight, + hasError, + lastSeenAt: event.timestamp, + lastActivityAt: + IDLE_TYPES.has(event.type) || idleNotification + ? undefined + : activity + ? event.timestamp + : previous?.lastActivityAt, + lastEventType: event.type, + events, + }; + const nextMap = new Map(map); + nextMap.set(key, nextState); + return nextMap; +} + +function replaceState(next: StateMap): void { + stateBySession.clear(); + for (const [key, value] of next) stateBySession.set(key, value); +} + +export async function hydrateHarnessEventsFromDisk(): Promise { + const storedEvents = await readStoredHarnessEvents(); + let next = pruneState(new Map(stateBySession), Date.now()); + for (const event of storedEvents) next = applyHarnessEvent(next, event); + replaceState(next); +} + +export function handleNormalizedHarnessEvent( + event: NormalizedHarnessHookEvent, + persist = true +): void { + const now = Math.max(Date.now(), event.timestamp); + const next = applyHarnessEvent(pruneState(new Map(stateBySession), now), event); + replaceState(next); + if (persist) void queueHarnessEventPersistence(event); +} + +export function getHarnessActivityBySession( + harnessId: HookHarnessId, + sessionKey: string, + now: number = Date.now() +): HarnessSessionState | undefined { + replaceState(pruneState(new Map(stateBySession), now)); + return stateBySession.get(stateKey(harnessId, sessionKey)); +} + +export function getHarnessActivityByCwd( + harnessId: HookHarnessId, + cwdKey: string, + now: number = Date.now() +): HarnessSessionState | undefined { + replaceState(pruneState(new Map(stateBySession), now)); + let best: HarnessSessionState | undefined; + for (const state of stateBySession.values()) { + if (state.harnessId !== harnessId || state.cwdKey !== cwdKey) continue; + if (!best || state.lastSeenAt > best.lastSeenAt) best = state; + } + return best; +} + +export function listHarnessActivity( + harnessId: HookHarnessId, + now: number = Date.now() +): HarnessSessionState[] { + replaceState(pruneState(new Map(stateBySession), now)); + return [...stateBySession.values()] + .filter((state) => state.harnessId === harnessId) + .sort((a, b) => b.lastSeenAt - a.lastSeenAt); +} + +export function resetHarnessActivityForTests(): void { + stateBySession.clear(); +} + +export { flushHarnessEventPersistence, harnessHookEventKey }; From 776ab0004eeb446d3b33279afa3ab5bc1b087a78 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:54:51 -0400 Subject: [PATCH 40/72] feat(harnesses): add opaque snapshot correlation keys --- src/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types.ts b/src/types.ts index 30ad3a6..bf5168c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,8 @@ export interface AgentSnapshot { model?: string; summary?: WorkSummary; events?: EventSummary[]; + harnessSessionKey?: string; + harnessCwdKey?: string; } export interface SnapshotMeta { From fb8dc08e86f1c1fd37ab0491f2487c2a5a52cb7c Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:56:13 -0400 Subject: [PATCH 41/72] fix(harnesses): stabilize generic process identity and correlation --- src/genericHarnessSnapshot.ts | 54 ++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/genericHarnessSnapshot.ts b/src/genericHarnessSnapshot.ts index ec624d8..998ed1f 100644 --- a/src/genericHarnessSnapshot.ts +++ b/src/genericHarnessSnapshot.ts @@ -5,6 +5,7 @@ import { detectGenericHarnessProcess, type DetectedHarnessProcess, } from "./harnesses.js"; +import { harnessCwdKey, harnessSessionKey } from "./harnessKeys.js"; import { redactText } from "./redact.js"; import type { AgentSnapshot, SnapshotPayload } from "./types.js"; @@ -41,9 +42,10 @@ function commandTokens(command: string): string[] { ); } -function extractCwd(command: string): string | undefined { - const tokens = commandTokens(command); - const flags = new Set(["--cwd", "--working-dir", "--workdir", "--dir"]); +function extractFlagValue( + tokens: string[], + flags: ReadonlySet +): string | undefined { for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]; if (flags.has(token)) { @@ -60,6 +62,31 @@ function extractCwd(command: string): string | undefined { return undefined; } +function extractCwd(command: string): string | undefined { + return extractFlagValue( + commandTokens(command), + new Set(["--cwd", "--working-dir", "--workdir", "--dir"]) + ); +} + +function extractSessionId(command: string): string | undefined { + const tokens = commandTokens(command); + const direct = extractFlagValue( + tokens, + new Set(["--session", "--session-id", "--conversation-id", "-s"]) + ); + if (direct) return direct; + + const resumeIndex = tokens.findIndex( + (token) => token === "resume" || token === "--resume" + ); + if (resumeIndex >= 0) { + const value = tokens[resumeIndex + 1]; + if (value && !value.startsWith("-")) return value; + } + return undefined; +} + async function loadUsage( pids: number[] ): Promise> { @@ -74,7 +101,7 @@ async function loadUsage( ) { return { [pids[0]]: result as GenericHarnessUsage }; } - return result as Record; + return result as unknown as Record; } catch { return {}; } @@ -94,6 +121,7 @@ function toAgentSnapshot( const commandRaw = process.cmd || process.name || detected.harness.displayName; const command = redactText(commandRaw) || commandRaw; const cwdRaw = extractCwd(commandRaw); + const sessionId = extractSessionId(commandRaw); const cwd = redactText(cwdRaw) || cwdRaw; const cpu = typeof usage.cpu === "number" && Number.isFinite(usage.cpu) ? usage.cpu : 0; const mem = @@ -101,16 +129,22 @@ function toAgentSnapshot( ? usage.memory : 0; const elapsed = - typeof usage.elapsed === "number" && Number.isFinite(usage.elapsed) + typeof usage.elapsed === "number" && + Number.isFinite(usage.elapsed) && + usage.elapsed >= 0 ? usage.elapsed : undefined; const startedAt = - typeof elapsed === "number" ? Math.floor((now - elapsed) / 1000) : undefined; + typeof elapsed === "number" + ? Math.max(0, Math.floor((now - elapsed) / 1000)) + : undefined; const state = processState(cpu); const role = detected.kind.endsWith("server") ? "server" : "agent"; + const startIdentity = + typeof startedAt === "number" ? `:start:${startedAt}` : ""; return { - identity: `${detected.harness.id}:pid:${process.pid}`, + identity: `${detected.harness.id}:pid:${process.pid}${startIdentity}`, id: String(process.pid), pid: process.pid, startedAt, @@ -125,6 +159,12 @@ function toAgentSnapshot( doing: `${detected.harness.displayName} ${role}`, cwd, repo: cwdRaw ? path.basename(path.resolve(cwdRaw)) : undefined, + harnessCwdKey: cwdRaw + ? harnessCwdKey(detected.harness.id, cwdRaw) + : undefined, + harnessSessionKey: sessionId + ? harnessSessionKey(detected.harness.id, sessionId) + : undefined, }; } From 284f06dc6259aaef99eb57dba23c2d518e1d43a2 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:57:21 -0400 Subject: [PATCH 42/72] feat(harnesses): attach hook history to detected agents --- src/harnessSnapshot.ts | 168 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/harnessSnapshot.ts diff --git a/src/harnessSnapshot.ts b/src/harnessSnapshot.ts new file mode 100644 index 0000000..04a732f --- /dev/null +++ b/src/harnessSnapshot.ts @@ -0,0 +1,168 @@ +import { + harnessIdForKind, + type HarnessId, +} from "./harnesses.js"; +import { isHookHarnessId } from "./harnessHookModel.js"; +import { + listHarnessActivity, + type HarnessSessionState, +} from "./services/harnessEvents.js"; +import type { + AgentSnapshot, + EventSummary, + SnapshotPayload, + WorkSummary, +} from "./types.js"; + +const MAX_EVENTS = 50; + +function eventKey(event: EventSummary): string { + return JSON.stringify([ + event.ts, + event.type, + event.summary, + event.isError === true, + event.turnId ?? null, + ]); +} + +function mergeEvents( + existing: EventSummary[] | undefined, + observed: EventSummary[] +): EventSummary[] { + const seen = new Set(); + return [...(existing ?? []), ...observed] + .map((event, index) => ({ event, index })) + .filter(({ event }) => { + const key = eventKey(event); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => a.event.ts - b.event.ts || a.index - b.index) + .slice(-MAX_EVENTS) + .map(({ event }) => event); +} + +function summaryFromEvents( + existing: WorkSummary | undefined, + events: EventSummary[] +): WorkSummary | undefined { + if (!existing && events.length === 0) return undefined; + const summary: WorkSummary = { ...(existing ?? {}) }; + for (const event of events) { + if (event.summary === "prompt" || event.summary.startsWith("prompt:")) { + summary.lastPrompt = event.summary; + } + if (event.summary === "message") summary.lastMessage = event.summary; + if (event.summary.startsWith("tool:")) summary.lastTool = event.summary; + if (event.summary.startsWith("cmd:")) summary.lastCommand = event.summary; + if (event.summary.startsWith("edit:")) summary.lastEdit = event.summary; + if (!event.summary.startsWith("event:")) summary.current = event.summary; + } + return summary; +} + +function maxDefined( + left: number | undefined, + right: number | undefined +): number | undefined { + if (typeof left !== "number") return right; + if (typeof right !== "number") return left; + return Math.max(left, right); +} + +function stateForHarnessSession( + state: HarnessSessionState +): AgentSnapshot["state"] { + if (state.hasError) return "error"; + return state.inFlight ? "active" : "idle"; +} + +function selectState( + agent: AgentSnapshot, + states: HarnessSessionState[], + singleAgent: boolean +): HarnessSessionState | undefined { + if (agent.harnessSessionKey) { + const exact = states.find( + (state) => state.sessionKey === agent.harnessSessionKey + ); + if (exact) return exact; + } + if (agent.harnessCwdKey) { + const cwdMatches = states.filter( + (state) => state.cwdKey === agent.harnessCwdKey + ); + if (cwdMatches.length > 0) return cwdMatches[0]; + } + return singleAgent && states.length === 1 ? states[0] : undefined; +} + +function attachState( + agent: AgentSnapshot, + state: HarnessSessionState +): AgentSnapshot { + const events = mergeEvents(agent.events, state.events); + const summary = summaryFromEvents(agent.summary, events); + const nextState = stateForHarnessSession(state); + return { + ...agent, + state: nextState, + activityReason: + nextState === "error" + ? "harness_hook_error" + : nextState === "active" + ? "harness_hook_in_flight" + : "harness_hook_idle", + lastEventAt: maxDefined(agent.lastEventAt, state.lastSeenAt), + lastActivityAt: + nextState === "idle" + ? undefined + : maxDefined(agent.lastActivityAt, state.lastActivityAt), + doing: summary?.current ?? agent.doing, + summary, + events, + harnessSessionKey: state.sessionKey, + harnessCwdKey: state.cwdKey ?? agent.harnessCwdKey, + }; +} + +export function attachHarnessEvents(snapshot: SnapshotPayload): SnapshotPayload { + const agentsByHarness = new Map(); + for (const agent of snapshot.agents) { + const harnessId = harnessIdForKind(agent.kind); + if (!isHookHarnessId(harnessId)) continue; + const current = agentsByHarness.get(harnessId) ?? []; + current.push(agent); + agentsByHarness.set(harnessId, current); + } + if (agentsByHarness.size === 0) return snapshot; + + const stateByIdentity = new Map(); + for (const [harnessId, agents] of agentsByHarness) { + const states = listHarnessActivity(harnessId, snapshot.ts); + const singleAgent = agents.length === 1; + const claimedSessions = new Set(); + + for (const agent of agents) { + const selected = selectState( + agent, + states.filter((state) => !claimedSessions.has(state.sessionKey)), + singleAgent + ); + if (!selected) continue; + claimedSessions.add(selected.sessionKey); + stateByIdentity.set(agent.identity || agent.id, selected); + } + } + + if (stateByIdentity.size === 0) return snapshot; + return { + ...snapshot, + agents: snapshot.agents.map((agent) => { + const state = stateByIdentity.get(agent.identity || agent.id); + return state ? attachState(agent, state) : agent; + }), + }; +} From 406833359b18c54ba19a6f6f23d543e36871562e Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:58:43 -0400 Subject: [PATCH 43/72] fix(harnesses): type and order hook snapshot attachment --- src/harnessSnapshot.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/harnessSnapshot.ts b/src/harnessSnapshot.ts index 04a732f..6216821 100644 --- a/src/harnessSnapshot.ts +++ b/src/harnessSnapshot.ts @@ -2,7 +2,10 @@ import { harnessIdForKind, type HarnessId, } from "./harnesses.js"; -import { isHookHarnessId } from "./harnessHookModel.js"; +import { + isHookHarnessId, + type HookHarnessId, +} from "./harnessHookModel.js"; import { listHarnessActivity, type HarnessSessionState, @@ -105,19 +108,21 @@ function attachState( ): AgentSnapshot { const events = mergeEvents(agent.events, state.events); const summary = summaryFromEvents(agent.summary, events); - const nextState = stateForHarnessSession(state); + const hookIsCurrent = state.lastSeenAt >= (agent.lastEventAt ?? 0); + const nextState = hookIsCurrent ? stateForHarnessSession(state) : agent.state; return { ...agent, state: nextState, - activityReason: - nextState === "error" + activityReason: hookIsCurrent + ? nextState === "error" ? "harness_hook_error" : nextState === "active" ? "harness_hook_in_flight" - : "harness_hook_idle", + : "harness_hook_idle" + : agent.activityReason, lastEventAt: maxDefined(agent.lastEventAt, state.lastSeenAt), lastActivityAt: - nextState === "idle" + hookIsCurrent && nextState === "idle" ? undefined : maxDefined(agent.lastActivityAt, state.lastActivityAt), doing: summary?.current ?? agent.doing, @@ -129,9 +134,9 @@ function attachState( } export function attachHarnessEvents(snapshot: SnapshotPayload): SnapshotPayload { - const agentsByHarness = new Map(); + const agentsByHarness = new Map(); for (const agent of snapshot.agents) { - const harnessId = harnessIdForKind(agent.kind); + const harnessId: HarnessId = harnessIdForKind(agent.kind); if (!isHookHarnessId(harnessId)) continue; const current = agentsByHarness.get(harnessId) ?? []; current.push(agent); From ddb3257da57be0cdad1da51a9ef349b2bca8b23c Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:59:06 -0400 Subject: [PATCH 44/72] feat(harnesses): hydrate hook history into one-shot scans --- src/scanSnapshot.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/scanSnapshot.ts b/src/scanSnapshot.ts index 743020f..cf694aa 100644 --- a/src/scanSnapshot.ts +++ b/src/scanSnapshot.ts @@ -2,16 +2,22 @@ import type { ScanOptions } from "./scan.js"; import { scanCodexProcesses } from "./scan.js"; import { attachClaudeEvents } from "./claudeSnapshot.js"; import { attachGenericHarnessProcesses } from "./genericHarnessSnapshot.js"; +import { attachHarnessEvents } from "./harnessSnapshot.js"; import { hydrateClaudeEventsFromDisk } from "./services/claudeEvents.js"; +import { hydrateHarnessEventsFromDisk } from "./services/harnessEvents.js"; import type { SnapshotPayload } from "./types.js"; export async function scanSnapshot( options: ScanOptions = { mode: "full" } ): Promise { - await hydrateClaudeEventsFromDisk(); + await Promise.all([ + hydrateClaudeEventsFromDisk(), + hydrateHarnessEventsFromDisk(), + ]); const snapshot = await scanCodexProcesses(options); const withClaudeEvents = attachClaudeEvents(snapshot); - return attachGenericHarnessProcesses(withClaudeEvents); + const withGenericHarnesses = await attachGenericHarnessProcesses(withClaudeEvents); + return attachHarnessEvents(withGenericHarnesses); } const isDirectRun = From 8cb6a6c174f1f8700eda2fa68d84add578222423 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 21:59:55 -0400 Subject: [PATCH 45/72] test(harnesses): cover normalized session reduction --- tests/unit/harnessEvents.test.ts | 132 +++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/unit/harnessEvents.test.ts diff --git a/tests/unit/harnessEvents.test.ts b/tests/unit/harnessEvents.test.ts new file mode 100644 index 0000000..ec0ba34 --- /dev/null +++ b/tests/unit/harnessEvents.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + applyHarnessEvent, + getHarnessActivityBySession, + handleNormalizedHarnessEvent, + listHarnessActivity, + resetHarnessActivityForTests, + type HarnessSessionState, +} from "../../src/services/harnessEvents.js"; +import type { NormalizedHarnessHookEvent } from "../../src/harnessHookModel.js"; + +function event( + type: NormalizedHarnessHookEvent["type"], + timestamp: number, + extra: Partial = {} +): NormalizedHarnessHookEvent { + return { + version: 1, + harnessId: "kimi", + type, + sessionKey: "a".repeat(24), + timestamp, + ...extra, + }; +} + +beforeEach(() => { + resetHarnessActivityForTests(); +}); + +describe("harness event state", () => { + it("builds bounded turn history and clears in-flight state on stop", () => { + let map = new Map(); + map = applyHarnessEvent(map, event("UserPromptSubmit", 1, { turnKey: "b".repeat(24) })); + map = applyHarnessEvent( + map, + event("PreToolUse", 2, { + turnKey: "b".repeat(24), + toolName: "Bash", + }) + ); + map = applyHarnessEvent(map, event("Stop", 3, { turnKey: "b".repeat(24) })); + const state = [...map.values()][0]; + + assert.equal(state.inFlight, false); + assert.equal(state.hasError, false); + assert.equal(state.lastActivityAt, undefined); + assert.deepEqual( + state.events.map((entry) => [entry.type, entry.summary]), + [ + ["UserPromptSubmit", "prompt"], + ["PreToolUse", "tool: Bash"], + ["turn.completed", "event: Stop"], + ] + ); + }); + + it("deduplicates repeated hook deliveries", () => { + const duplicate = event("PreToolUse", 1, { toolName: "Bash" }); + let map = applyHarnessEvent(new Map(), duplicate); + map = applyHarnessEvent(map, duplicate); + const state = [...map.values()][0]; + + assert.equal(state.events.length, 1); + assert.equal(state.inFlight, true); + }); + + it("merges older history without overwriting newer live state", () => { + let map = applyHarnessEvent(new Map(), event("UserPromptSubmit", 200)); + map = applyHarnessEvent(map, event("StopFailure", 100)); + const state = [...map.values()][0]; + + assert.equal(state.inFlight, true); + assert.equal(state.hasError, false); + assert.equal(state.lastSeenAt, 200); + assert.equal(state.lastEventType, "UserPromptSubmit"); + assert.deepEqual( + state.events.map((entry) => entry.type), + ["turn.failed", "UserPromptSubmit"] + ); + }); + + it("marks current failures and clears them on later successful activity", () => { + let map = applyHarnessEvent(new Map(), event("PostToolUseFailure", 1)); + let state = [...map.values()][0]; + assert.equal(state.hasError, true); + assert.equal(state.inFlight, true); + + map = applyHarnessEvent(map, event("PostToolUse", 2)); + state = [...map.values()][0]; + assert.equal(state.hasError, false); + assert.equal(state.inFlight, true); + }); + + it("expires an in-flight session after the configured timeout", () => { + const previous = process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS = "10"; + try { + handleNormalizedHarnessEvent(event("UserPromptSubmit", 100), false); + assert.equal( + getHarnessActivityBySession("kimi", "a".repeat(24), 105)?.inFlight, + true + ); + assert.equal( + getHarnessActivityBySession("kimi", "a".repeat(24), 111)?.inFlight, + false + ); + } finally { + if (previous === undefined) { + delete process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS = previous; + } + } + }); + + it("keeps sessions isolated by harness and opaque session key", () => { + handleNormalizedHarnessEvent(event("UserPromptSubmit", 1), false); + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 2, { + harnessId: "factory", + sessionKey: "c".repeat(24), + }), + false + ); + + assert.equal(listHarnessActivity("kimi", 2).length, 1); + assert.equal(listHarnessActivity("factory", 2).length, 1); + assert.equal(listHarnessActivity("qwen", 2).length, 0); + }); +}); From 2b805c3dc0fe3f87cd0adae8f5fa81439b3a0246 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:00:39 -0400 Subject: [PATCH 46/72] test(harnesses): cover hook-to-snapshot correlation --- tests/unit/harnessSnapshot.test.ts | 217 +++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/unit/harnessSnapshot.test.ts diff --git a/tests/unit/harnessSnapshot.test.ts b/tests/unit/harnessSnapshot.test.ts new file mode 100644 index 0000000..a2db179 --- /dev/null +++ b/tests/unit/harnessSnapshot.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { buildAgentGraph } from "../../src/graph.js"; +import { attachHarnessEvents } from "../../src/harnessSnapshot.js"; +import type { NormalizedHarnessHookEvent } from "../../src/harnessHookModel.js"; +import { + handleNormalizedHarnessEvent, + resetHarnessActivityForTests, +} from "../../src/services/harnessEvents.js"; +import type { AgentSnapshot, SnapshotPayload } from "../../src/types.js"; + +function event( + type: NormalizedHarnessHookEvent["type"], + timestamp: number, + extra: Partial = {} +): NormalizedHarnessHookEvent { + return { + version: 1, + harnessId: "factory", + type, + sessionKey: "a".repeat(24), + cwdKey: "b".repeat(24), + timestamp, + ...extra, + }; +} + +function agent(overrides: Partial = {}): AgentSnapshot { + return { + identity: "factory:pid:10:start:1", + id: "10", + pid: 10, + cmd: "droid", + cmdShort: "droid", + kind: "factory-cli", + cpu: 0, + mem: 0, + state: "idle", + harnessSessionKey: "a".repeat(24), + harnessCwdKey: "b".repeat(24), + ...overrides, + }; +} + +function snapshot(agents: AgentSnapshot[], ts = 100): SnapshotPayload { + return { ts, agents }; +} + +beforeEach(() => { + resetHarnessActivityForTests(); +}); + +describe("harness snapshot attachment", () => { + it("attaches exact session history and active state", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { turnKey: "c".repeat(24) }), + false + ); + handleNormalizedHarnessEvent( + event("PreToolUse", 91, { + turnKey: "c".repeat(24), + toolName: "Execute", + }), + false + ); + + const attached = attachHarnessEvents(snapshot([agent()])); + const current = attached.agents[0]; + assert.equal(current.state, "active"); + assert.equal(current.activityReason, "harness_hook_in_flight"); + assert.deepEqual( + current.events?.map((entry) => entry.summary), + ["prompt", "tool: Execute"] + ); + assert.equal(current.summary?.lastPrompt, "prompt"); + assert.equal(current.summary?.lastTool, "tool: Execute"); + assert.equal(current.harnessSessionKey, "a".repeat(24)); + }); + + it("uses cwd matching when the process command has no session id", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { sessionKey: "d".repeat(24) }), + false + ); + const attached = attachHarnessEvents( + snapshot([ + agent({ harnessSessionKey: undefined, harnessCwdKey: "b".repeat(24) }), + ]) + ); + + assert.equal(attached.agents[0].harnessSessionKey, "d".repeat(24)); + assert.equal(attached.agents[0].state, "active"); + }); + + it("uses one-to-one fallback only when assignment is unambiguous", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { + sessionKey: "e".repeat(24), + cwdKey: undefined, + }), + false + ); + const single = attachHarnessEvents( + snapshot([agent({ harnessSessionKey: undefined, harnessCwdKey: undefined })]) + ); + assert.equal(single.agents[0].harnessSessionKey, "e".repeat(24)); + + resetHarnessActivityForTests(); + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { + sessionKey: "e".repeat(24), + cwdKey: undefined, + }), + false + ); + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 91, { + sessionKey: "f".repeat(24), + cwdKey: undefined, + }), + false + ); + const ambiguous = attachHarnessEvents( + snapshot([agent({ harnessSessionKey: undefined, harnessCwdKey: undefined })]) + ); + assert.equal(ambiguous.agents[0].events, undefined); + }); + + it("does not overwrite newer specialized activity with stale hook state", () => { + handleNormalizedHarnessEvent(event("Stop", 80), false); + const attached = attachHarnessEvents( + snapshot([ + agent({ + state: "active", + lastEventAt: 95, + lastActivityAt: 95, + activityReason: "native_event", + }), + ]) + ); + + assert.equal(attached.agents[0].state, "active"); + assert.equal(attached.agents[0].activityReason, "native_event"); + assert.equal(attached.agents[0].lastEventAt, 95); + assert.deepEqual( + attached.agents[0].events?.map((entry) => entry.type), + ["turn.completed"] + ); + }); + + it("keeps sessions isolated when several agents share a harness", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { + sessionKey: "1".repeat(24), + cwdKey: "3".repeat(24), + }), + false + ); + handleNormalizedHarnessEvent( + event("Stop", 91, { + sessionKey: "2".repeat(24), + cwdKey: "4".repeat(24), + }), + false + ); + const attached = attachHarnessEvents( + snapshot([ + agent({ + identity: "factory:1", + id: "1", + pid: 1, + harnessSessionKey: "1".repeat(24), + harnessCwdKey: "3".repeat(24), + }), + agent({ + identity: "factory:2", + id: "2", + pid: 2, + harnessSessionKey: "2".repeat(24), + harnessCwdKey: "4".repeat(24), + }), + ]) + ); + + assert.equal(attached.agents[0].state, "active"); + assert.equal(attached.agents[1].state, "idle"); + }); + + it("turns attached hook metadata into provider-scoped graph phases", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { turnKey: "c".repeat(24) }), + false + ); + handleNormalizedHarnessEvent( + event("PreToolUse", 91, { + turnKey: "c".repeat(24), + toolName: "Execute", + }), + false + ); + handleNormalizedHarnessEvent( + event("Stop", 92, { turnKey: "c".repeat(24) }), + false + ); + + const graph = buildAgentGraph(attachHarnessEvents(snapshot([agent()], 100))); + assert.equal(graph.coverage.providers.factory.history, "available"); + assert.deepEqual( + graph.nodes + .filter((node) => node.kind === "step") + .map((node) => node.phase) + .sort(), + ["prompt", "tool"] + ); + assert.equal(graph.stats.loops, 0); + }); +}); From 912f60d012c5b10c783d9e6a0e98ca049fb5d01e Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:01:38 -0400 Subject: [PATCH 47/72] fix(harnesses): prune direct events on their own timeline --- src/services/harnessEvents.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/harnessEvents.ts b/src/services/harnessEvents.ts index 49bfb9a..6e77d60 100644 --- a/src/services/harnessEvents.ts +++ b/src/services/harnessEvents.ts @@ -207,8 +207,10 @@ export function handleNormalizedHarnessEvent( event: NormalizedHarnessHookEvent, persist = true ): void { - const now = Math.max(Date.now(), event.timestamp); - const next = applyHarnessEvent(pruneState(new Map(stateBySession), now), event); + const next = applyHarnessEvent( + pruneState(new Map(stateBySession), event.timestamp), + event + ); replaceState(next); if (persist) void queueHarnessEventPersistence(event); } From 85cde0d7e4bcb7966eb28d23a945d8a832096950 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:02:58 -0400 Subject: [PATCH 48/72] test(harnesses): cover bounded hook persistence --- tests/unit/harnessEventLog.test.ts | 161 +++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/unit/harnessEventLog.test.ts diff --git a/tests/unit/harnessEventLog.test.ts b/tests/unit/harnessEventLog.test.ts new file mode 100644 index 0000000..3dfa09b --- /dev/null +++ b/tests/unit/harnessEventLog.test.ts @@ -0,0 +1,161 @@ +import { + appendFile, + mkdtemp, + readFile, + rm, + stat, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { NormalizedHarnessHookEvent } from "../../src/harnessHookModel.js"; +import { + flushHarnessEventPersistence, + isStoredHarnessHookEvent, + queueHarnessEventPersistence, + readStoredHarnessEvents, +} from "../../src/services/harnessEventLog.js"; + +function event( + index: number, + extra: Partial = {} +): NormalizedHarnessHookEvent { + return { + version: 1, + harnessId: "kimi", + type: "PreToolUse", + sessionKey: index.toString(16).padStart(24, "0").slice(-24), + timestamp: Date.now() + index, + toolName: "Bash", + ...extra, + }; +} + +async function withLog( + run: (directory: string, logPath: string) => Promise +): Promise { + const previousPath = process.env.CONSENSUS_HARNESS_EVENT_LOG; + const previousMax = process.env.CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES; + const previousTtl = process.env.CONSENSUS_HARNESS_EVENT_TTL_MS; + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-harness-log-")); + const logPath = path.join(directory, "events.jsonl"); + process.env.CONSENSUS_HARNESS_EVENT_LOG = logPath; + + try { + await run(directory, logPath); + } finally { + await flushHarnessEventPersistence(); + if (previousPath === undefined) { + delete process.env.CONSENSUS_HARNESS_EVENT_LOG; + } else { + process.env.CONSENSUS_HARNESS_EVENT_LOG = previousPath; + } + if (previousMax === undefined) { + delete process.env.CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES; + } else { + process.env.CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES = previousMax; + } + if (previousTtl === undefined) { + delete process.env.CONSENSUS_HARNESS_EVENT_TTL_MS; + } else { + process.env.CONSENSUS_HARNESS_EVENT_TTL_MS = previousTtl; + } + await rm(directory, { recursive: true, force: true }); + } +} + +beforeEach(async () => { + await flushHarnessEventPersistence(); +}); + +describe("harness event log", { concurrency: false }, () => { + it("persists duplicate deliveries only once", async () => { + await withLog(async (_directory, logPath) => { + const duplicate = event(1); + void queueHarnessEventPersistence(duplicate); + void queueHarnessEventPersistence(duplicate); + await flushHarnessEventPersistence(); + + const lines = (await readFile(logPath, "utf8")) + .split(/\r?\n/) + .filter(Boolean); + assert.equal(lines.length, 1); + }); + }); + + it("captures the configured path when writes are queued", async () => { + await withLog(async (directory, firstPath) => { + const secondPath = path.join(directory, "later.jsonl"); + const queued = queueHarnessEventPersistence(event(2)); + process.env.CONSENSUS_HARNESS_EVENT_LOG = secondPath; + await queued; + + assert.match(await readFile(firstPath, "utf8"), /PreToolUse/); + await assert.rejects(() => readFile(secondPath, "utf8")); + }); + }); + + it("enforces UTF-8 byte bounds while retaining complete JSON lines", async () => { + await withLog(async (_directory, logPath) => { + process.env.CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES = String(64 * 1024); + for (let index = 0; index < 600; index += 1) { + void queueHarnessEventPersistence( + event(index + 100, { toolName: "🚀".repeat(80) }) + ); + } + await flushHarnessEventPersistence(); + + const info = await stat(logPath); + assert.ok(info.size <= 64 * 1024, `log was ${info.size} bytes`); + const lines = (await readFile(logPath, "utf8")) + .split(/\r?\n/) + .filter(Boolean); + assert.ok(lines.length > 0); + for (const line of lines) assert.doesNotThrow(() => JSON.parse(line)); + }); + }); + + it("ignores malformed, expired, and duplicate disk records", async () => { + await withLog(async (_directory, logPath) => { + process.env.CONSENSUS_HARNESS_EVENT_TTL_MS = "1000"; + const current = event(900); + const expired = event(901, { timestamp: Date.now() - 2_000 }); + await appendFile( + logPath, + [ + JSON.stringify(current), + JSON.stringify(current), + JSON.stringify(expired), + JSON.stringify({ ...current, sessionKey: "raw-session" }), + "{", + "", + ].join("\n") + ); + + const events = await readStoredHarnessEvents(); + assert.deepEqual(events, [current]); + }); + }); + + it("validates the complete persisted metadata boundary", () => { + const valid = event(1); + assert.equal(isStoredHarnessHookEvent(valid), true); + assert.equal( + isStoredHarnessHookEvent({ ...valid, harnessId: "unknown" }), + false + ); + assert.equal( + isStoredHarnessHookEvent({ ...valid, timestamp: -1 }), + false + ); + assert.equal( + isStoredHarnessHookEvent({ ...valid, sessionKey: "session-secret" }), + false + ); + assert.equal( + isStoredHarnessHookEvent({ ...valid, toolName: "Bash\nforged" }), + false + ); + }); +}); From a6fbfa833c3fd85851377bbda442efc398711ba0 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:04:14 -0400 Subject: [PATCH 49/72] feat(cli): expose harness registry and setup guidance --- src/cli/harnesses.ts | 169 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/cli/harnesses.ts diff --git a/src/cli/harnesses.ts b/src/cli/harnesses.ts new file mode 100644 index 0000000..f82314f --- /dev/null +++ b/src/cli/harnesses.ts @@ -0,0 +1,169 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + harnessDefinitions, + harnessForId, + harnessIds, + type HarnessDefinition, + type HarnessId, +} from "../harnesses.js"; +import { isHookHarnessId } from "../harnessHookModel.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function isHarnessId(value: string): value is HarnessId { + return (harnessIds as readonly string[]).includes(value); +} + +function help(): string { + return [ + "consensus harnesses", + "", + "List registered coding-agent harnesses and their evidence sources.", + "", + "Usage:", + " consensus harnesses [--json]", + " consensus harnesses --setup ", + "", + "Options:", + " --json Print the registry as JSON", + " --setup Print hook or adapter setup guidance", + " -h, --help Show this help", + "", + ].join("\n"); +} + +function setupCommand(scriptPath: string, harnessId: HarnessId): string { + return `node ${JSON.stringify(scriptPath)} ${harnessId}`; +} + +export function harnessSetupText( + harness: HarnessDefinition, + scriptPath: string +): string { + const lines = [ + `${harness.displayName} (${harness.id})`, + `class=${harness.class} telemetry=${harness.telemetry}`, + ]; + if (harness.docs) lines.push(`docs=${harness.docs}`); + + if (isHookHarnessId(harness.id)) { + lines.push( + "", + "Configure each supported lifecycle event to run this command and pass the hook JSON on stdin:", + ` ${setupCommand(scriptPath, harness.id)}`, + "", + "Consensus stores only normalized event type, opaque session/cwd/turn keys, timestamp, and small tool/agent labels.", + "Prompt text, tool input/output, transcript paths, raw session IDs, and error details are discarded before persistence." + ); + } else if (harness.telemetry === "native-events") { + lines.push( + "", + "This harness needs a native event adapter. Process discovery is available where an exact binary rule exists." + ); + } else if (harness.telemetry === "structured-stream") { + lines.push( + "", + "Run history should be attached through the harness's structured output stream. Process discovery alone has no step graph." + ); + } else if (harness.telemetry === "remote-api") { + lines.push( + "", + "Run history requires an explicit, opt-in remote API adapter. Local process discovery does not fetch remote data." + ); + } else if (harness.telemetry === "tool-only") { + lines.push( + "", + "This entry represents a tool/model integration. Consensus attributes the execution loop to the owning harness." + ); + } else if (harness.telemetry === "manual") { + lines.push( + "", + "This IDE agent needs an extension bridge. Consensus does not identify it from generic Electron helper processes." + ); + } else { + lines.push( + "", + "Current depth is exact process discovery only; no step history is claimed." + ); + } + + if (harness.note) lines.push("", `note=${harness.note}`); + return `${lines.join("\n")}\n`; +} + +function formatRegistry(): string { + const rows = harnessDefinitions.map((harness) => ({ + name: harness.displayName, + id: harness.id, + class: harness.class, + telemetry: harness.telemetry, + discovery: harness.processRules?.length ? "process" : "adapter", + })); + const widths = { + name: Math.max("HARNESS".length, ...rows.map((row) => row.name.length)), + id: Math.max("ID".length, ...rows.map((row) => row.id.length)), + class: Math.max("CLASS".length, ...rows.map((row) => row.class.length)), + telemetry: Math.max( + "EVIDENCE".length, + ...rows.map((row) => row.telemetry.length) + ), + }; + const line = (row: (typeof rows)[number]): string => + `${row.name.padEnd(widths.name)} ${row.id.padEnd(widths.id)} ` + + `${row.class.padEnd(widths.class)} ${row.telemetry.padEnd(widths.telemetry)} ` + + row.discovery; + return [ + `${"HARNESS".padEnd(widths.name)} ${"ID".padEnd(widths.id)} ` + + `${"CLASS".padEnd(widths.class)} ${"EVIDENCE".padEnd(widths.telemetry)} DISCOVERY`, + ...rows.map(line), + "", + "Use `consensus harnesses --setup ` for the provider-specific next step.", + "", + ].join("\n"); +} + +export async function runHarnesses(args: string[]): Promise { + let json = false; + let setupId: string | undefined; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "-h" || arg === "--help") { + process.stdout.write(help()); + return; + } + if (arg === "--json") { + json = true; + continue; + } + if (arg === "--setup") { + setupId = args[index + 1]; + if (!setupId) throw new Error("--setup requires a harness id"); + index += 1; + continue; + } + if (arg.startsWith("--setup=")) { + setupId = arg.slice("--setup=".length); + if (!setupId) throw new Error("--setup requires a harness id"); + continue; + } + throw new Error(`unknown harnesses option: ${arg}`); + } + + if (setupId) { + if (!isHarnessId(setupId)) { + throw new Error(`unknown harness id: ${setupId}`); + } + const scriptPath = path.resolve(__dirname, "..", "harnessHook.js"); + process.stdout.write(harnessSetupText(harnessForId(setupId), scriptPath)); + return; + } + + process.stdout.write( + json + ? `${JSON.stringify({ version: 1, harnesses: harnessDefinitions }, null, 2)}\n` + : formatRegistry() + ); +} From a6a614065a0fd0a8513dff9facbea87be55857ff Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:04:54 -0400 Subject: [PATCH 50/72] feat(cli): add harness discovery command --- src/cli.ts | 245 +++++++++++++++++++++++++++-------------------------- 1 file changed, 127 insertions(+), 118 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 0df31c9..bb1e652 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,10 @@ #!/usr/bin/env node -import { spawn } from "child_process"; -import fs from "fs"; -import { fileURLToPath } from "url"; -import path from "path"; -import process from "process"; -import { Effect, Console } from "effect"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import process from "node:process"; +import { Effect } from "effect"; import { annotateSpan, disposeObservability, @@ -12,142 +12,151 @@ import { withSpan, } from "./observability/index.js"; import { runGraph } from "./cli/graph.js"; +import { runHarnesses } from "./cli/harnesses.js"; import { runSetup } from "./cli/setup.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - const args = process.argv.slice(2); -// Handle one-shot commands before starting the server. -if (args[0] === "setup") { - runSetup() - .then(() => { - process.exit(0); - }) - .catch(() => { - process.exit(1); - }); -} else if (args[0] === "graph") { - runGraph(args.slice(1)) +function runOneShot( + label: string, + action: () => Promise +): void { + action() .then(async () => { await disposeObservability().catch(() => undefined); process.exit(0); }) - .catch(async (err) => { - process.stderr.write(`[consensus] graph error: ${String(err)}\n`); + .catch(async (error) => { + process.stderr.write(`[consensus] ${label} error: ${String(error)}\n`); await disposeObservability().catch(() => undefined); process.exit(1); }); -} else { +} -function readArg(name: string): string | undefined { - const index = args.findIndex((arg) => arg === name || arg.startsWith(`${name}=`)); - if (index === -1) return undefined; - const arg = args[index]; - if (arg.includes("=")) { - return arg.split("=").slice(1).join("="); +// Handle one-shot commands before starting the server. +if (args[0] === "setup") { + runOneShot("setup", runSetup); +} else if (args[0] === "graph") { + runOneShot("graph", () => runGraph(args.slice(1))); +} else if (args[0] === "harnesses") { + runOneShot("harnesses", () => runHarnesses(args.slice(1))); +} else { + function readArg(name: string): string | undefined { + const index = args.findIndex((arg) => arg === name || arg.startsWith(`${name}=`)); + if (index === -1) return undefined; + const arg = args[index]; + if (arg.includes("=")) { + return arg.split("=").slice(1).join("="); + } + return args[index + 1]; } - return args[index + 1]; -} -function hasFlag(name: string): boolean { - return args.includes(name); -} + function hasFlag(name: string): boolean { + return args.includes(name); + } -function printHelp(): void { - process.stdout.write(`consensus\n\n`); - process.stdout.write(`Usage:\n consensus [command] [options]\n\n`); - process.stdout.write(`Commands:\n`); - process.stdout.write(` setup Configure Codex notify hook (recommended first step)\n`); - process.stdout.write(` graph Inspect agent transitions and detect loops\n`); - process.stdout.write(` (default) Start the consensus server\n\n`); - process.stdout.write(`Options:\n`); - process.stdout.write(` --host Bind address (default 127.0.0.1)\n`); - process.stdout.write(` --port Port (default 8787)\n`); - process.stdout.write(` --poll Poll interval in ms\n`); - process.stdout.write(` --codex-home Override CODEX_HOME\n`); - process.stdout.write(` --codex-notify Install Codex notify hook at path\n`); - process.stdout.write(` --opencode-host OpenCode host (default 127.0.0.1)\n`); - process.stdout.write(` --opencode-port

OpenCode port (default 4096)\n`); - process.stdout.write(` --no-opencode-autostart Disable OpenCode server autostart\n`); - process.stdout.write(` --process-match Regex for process matching\n`); - process.stdout.write(` --no-redact Disable PII redaction\n`); - process.stdout.write(` -h, --help Show help\n`); -} + function printHelp(): void { + process.stdout.write("consensus\n\n"); + process.stdout.write("Usage:\n consensus [command] [options]\n\n"); + process.stdout.write("Commands:\n"); + process.stdout.write( + " setup Configure Codex notify hook (recommended first step)\n" + ); + process.stdout.write( + " graph Inspect agent transitions and detect loops\n" + ); + process.stdout.write( + " harnesses List harnesses, evidence tiers, and setup guidance\n" + ); + process.stdout.write(" (default) Start the consensus server\n\n"); + process.stdout.write("Options:\n"); + process.stdout.write(" --host Bind address (default 127.0.0.1)\n"); + process.stdout.write(" --port Port (default 8787)\n"); + process.stdout.write(" --poll Poll interval in ms\n"); + process.stdout.write(" --codex-home Override CODEX_HOME\n"); + process.stdout.write(" --codex-notify Install Codex notify hook at path\n"); + process.stdout.write(" --opencode-host OpenCode host (default 127.0.0.1)\n"); + process.stdout.write(" --opencode-port

OpenCode port (default 4096)\n"); + process.stdout.write(" --no-opencode-autostart Disable OpenCode server autostart\n"); + process.stdout.write(" --process-match Regex for process matching\n"); + process.stdout.write(" --no-redact Disable PII redaction\n"); + process.stdout.write(" -h, --help Show help\n"); + } -if (hasFlag("--help") || hasFlag("-h")) { - printHelp(); - process.exit(0); -} + if (hasFlag("--help") || hasFlag("-h")) { + printHelp(); + process.exit(0); + } -const env = { ...process.env } as Record; + const env = { ...process.env } as Record; -const host = readArg("--host"); -const port = readArg("--port"); -const poll = readArg("--poll"); -const codexHome = readArg("--codex-home"); -const codexNotify = readArg("--codex-notify"); -const opencodeHost = readArg("--opencode-host"); -const opencodePort = readArg("--opencode-port"); -const noOpenCodeAutostart = hasFlag("--no-opencode-autostart"); -const match = readArg("--process-match"); -const noRedact = hasFlag("--no-redact"); + const host = readArg("--host"); + const port = readArg("--port"); + const poll = readArg("--poll"); + const codexHome = readArg("--codex-home"); + const codexNotify = readArg("--codex-notify"); + const opencodeHost = readArg("--opencode-host"); + const opencodePort = readArg("--opencode-port"); + const noOpenCodeAutostart = hasFlag("--no-opencode-autostart"); + const match = readArg("--process-match"); + const noRedact = hasFlag("--no-redact"); -if (host) env.CONSENSUS_HOST = host; -if (port) env.CONSENSUS_PORT = port; -if (poll) env.CONSENSUS_POLL_MS = poll; -if (codexHome) env.CONSENSUS_CODEX_HOME = codexHome; -const normalizeNotify = (value: string | undefined): string | undefined => { - if (!value) return undefined; - const lowered = value.trim().toLowerCase(); - if (!lowered || lowered === "0" || lowered === "false" || lowered === "off") { - return undefined; - } - return value; -}; -const defaultNotifyPath = path.join(__dirname, "codexNotify.js"); -const resolvedNotify = - normalizeNotify(codexNotify) || - normalizeNotify(env.CONSENSUS_CODEX_NOTIFY_INSTALL) || - (fs.existsSync(defaultNotifyPath) ? defaultNotifyPath : undefined); -if (resolvedNotify) env.CONSENSUS_CODEX_NOTIFY_INSTALL = resolvedNotify; -if (opencodeHost) env.CONSENSUS_OPENCODE_HOST = opencodeHost; -if (opencodePort) env.CONSENSUS_OPENCODE_PORT = opencodePort; -if (noOpenCodeAutostart) env.CONSENSUS_OPENCODE_AUTOSTART = "0"; -if (match) env.CONSENSUS_PROCESS_MATCH = match; -if (noRedact) env.CONSENSUS_REDACT_PII = "0"; + if (host) env.CONSENSUS_HOST = host; + if (port) env.CONSENSUS_PORT = port; + if (poll) env.CONSENSUS_POLL_MS = poll; + if (codexHome) env.CONSENSUS_CODEX_HOME = codexHome; + const normalizeNotify = (value: string | undefined): string | undefined => { + if (!value) return undefined; + const lowered = value.trim().toLowerCase(); + if (!lowered || lowered === "0" || lowered === "false" || lowered === "off") { + return undefined; + } + return value; + }; + const defaultNotifyPath = path.join(__dirname, "codexNotify.js"); + const resolvedNotify = + normalizeNotify(codexNotify) || + normalizeNotify(env.CONSENSUS_CODEX_NOTIFY_INSTALL) || + (fs.existsSync(defaultNotifyPath) ? defaultNotifyPath : undefined); + if (resolvedNotify) env.CONSENSUS_CODEX_NOTIFY_INSTALL = resolvedNotify; + if (opencodeHost) env.CONSENSUS_OPENCODE_HOST = opencodeHost; + if (opencodePort) env.CONSENSUS_OPENCODE_PORT = opencodePort; + if (noOpenCodeAutostart) env.CONSENSUS_OPENCODE_AUTOSTART = "0"; + if (match) env.CONSENSUS_PROCESS_MATCH = match; + if (noRedact) env.CONSENSUS_REDACT_PII = "0"; -const serverPath = path.join(__dirname, "server.js"); -const spanAttributes: Record = { - "cli.args_count": args.length, -}; -if (host) spanAttributes["consensus.host"] = host; -if (port) spanAttributes["consensus.port"] = Number(port); -if (poll) spanAttributes["consensus.poll_ms"] = Number(poll); -if (opencodeHost) spanAttributes["consensus.opencode_host"] = opencodeHost; -if (opencodePort) spanAttributes["consensus.opencode_port"] = Number(opencodePort); + const serverPath = path.join(__dirname, "server.js"); + const spanAttributes: Record = { + "cli.args_count": args.length, + }; + if (host) spanAttributes["consensus.host"] = host; + if (port) spanAttributes["consensus.port"] = Number(port); + if (poll) spanAttributes["consensus.poll_ms"] = Number(poll); + if (opencodeHost) spanAttributes["consensus.opencode_host"] = opencodeHost; + if (opencodePort) spanAttributes["consensus.opencode_port"] = Number(opencodePort); -const program = Effect.async((resume) => { - const child = spawn(process.execPath, [serverPath], { stdio: "inherit", env }); - child.on("exit", (code) => { - resume(Effect.succeed(code ?? 0)); + const program = Effect.async((resume) => { + const child = spawn(process.execPath, [serverPath], { stdio: "inherit", env }); + child.on("exit", (code) => { + resume(Effect.succeed(code ?? 0)); + }); }); -}); -const instrumented = program.pipe( - withSpan("cli.run", { attributes: spanAttributes }), - Effect.tap((code) => annotateSpan("process.exit_code", code)) -); + const instrumented = program.pipe( + withSpan("cli.run", { attributes: spanAttributes }), + Effect.tap((code) => annotateSpan("process.exit_code", code)) + ); -runPromise(instrumented) - .then(async (code) => { - await disposeObservability().catch(() => undefined); - process.exit(code); - }) - .catch(async (err) => { - process.stderr.write(`[consensus] cli error: ${String(err)}\n`); - await disposeObservability().catch(() => undefined); - process.exit(1); - }); + runPromise(instrumented) + .then(async (code) => { + await disposeObservability().catch(() => undefined); + process.exit(code); + }) + .catch(async (error) => { + process.stderr.write(`[consensus] cli error: ${String(error)}\n`); + await disposeObservability().catch(() => undefined); + process.exit(1); + }); } From 6ad87d34a90844537ceff5caa3a2158e7593a020 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:05:23 -0400 Subject: [PATCH 51/72] test(cli): cover harness setup guidance --- tests/unit/harnessCli.test.ts | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/unit/harnessCli.test.ts diff --git a/tests/unit/harnessCli.test.ts b/tests/unit/harnessCli.test.ts new file mode 100644 index 0000000..1be0c87 --- /dev/null +++ b/tests/unit/harnessCli.test.ts @@ -0,0 +1,53 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + harnessSetupText, +} from "../../src/cli/harnesses.js"; +import { harnessForId } from "../../src/harnesses.js"; + +describe("harness CLI", () => { + it("prints a privacy-safe hook command for hook-capable harnesses", () => { + const text = harnessSetupText( + harnessForId("kimi"), + "/Applications/Consensus/dist/harnessHook.js" + ); + + assert.match( + text, + /node "\/Applications\/Consensus\/dist\/harnessHook\.js" kimi/ + ); + assert.match(text, /Prompt text, tool input\/output/); + assert.match(text, /telemetry=hooks/); + }); + + it("does not claim hook setup for process-only providers", () => { + const text = harnessSetupText( + harnessForId("gemini"), + "/tmp/harnessHook.js" + ); + + assert.doesNotMatch(text, /node .*harnessHook/); + assert.match(text, /process discovery only/i); + }); + + it("distinguishes MiniMax tool integration from an owning loop", () => { + const text = harnessSetupText( + harnessForId("minimax"), + "/tmp/harnessHook.js" + ); + + assert.match(text, /tool\/model integration/i); + assert.doesNotMatch(text, /Configure each supported lifecycle event/); + }); + + it("requires explicit adapters for IDE and remote harnesses", () => { + assert.match( + harnessSetupText(harnessForId("cline"), "/tmp/harnessHook.js"), + /extension bridge/i + ); + assert.match( + harnessSetupText(harnessForId("warp"), "/tmp/harnessHook.js"), + /remote API adapter/i + ); + }); +}); From e5c87729742facffa4763cb50e7a65b68fef0fdf Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:13:12 -0400 Subject: [PATCH 52/72] test(harnesses): cover stable generic process correlation --- tests/unit/genericHarnessSnapshot.test.ts | 29 +++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/unit/genericHarnessSnapshot.test.ts b/tests/unit/genericHarnessSnapshot.test.ts index 72b01f5..cbb8860 100644 --- a/tests/unit/genericHarnessSnapshot.test.ts +++ b/tests/unit/genericHarnessSnapshot.test.ts @@ -2,6 +2,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { attachGenericHarnessProcesses } from "../../src/genericHarnessSnapshot.js"; import { buildAgentGraph } from "../../src/graph.js"; +import { harnessCwdKey, harnessSessionKey } from "../../src/harnessKeys.js"; import type { SnapshotPayload } from "../../src/types.js"; const emptySnapshot: SnapshotPayload = { @@ -14,7 +15,11 @@ describe("generic harness snapshot", () => { const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { now: 10_000, processes: [ - { pid: 10, name: "openclaw", cmd: "openclaw agent --cwd /tmp/project" }, + { + pid: 10, + name: "openclaw", + cmd: "openclaw agent --cwd /tmp/project --session session-1", + }, { pid: 11, name: "droid", cmd: "droid" }, { pid: 12, name: "node", cmd: "node server.js" }, ], @@ -30,11 +35,31 @@ describe("generic harness snapshot", () => { assert.equal(openclaw?.kind, "openclaw-cli"); assert.equal(openclaw?.state, "active"); assert.equal(openclaw?.repo, "project"); - assert.equal(openclaw?.identity, "openclaw:pid:10"); + assert.equal(openclaw?.startedAt, 8); + assert.equal(openclaw?.identity, "openclaw:pid:10:start:8"); + assert.equal( + openclaw?.harnessCwdKey, + harnessCwdKey("openclaw", "/tmp/project") + ); + assert.equal( + openclaw?.harnessSessionKey, + harnessSessionKey("openclaw", "session-1") + ); assert.equal(factory?.kind, "factory-cli"); assert.equal(factory?.state, "idle"); }); + it("clamps impossible process start times instead of exporting negatives", async () => { + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + now: 1_000, + processes: [{ pid: 15, name: "droid", cmd: "droid" }], + usage: { 15: { cpu: 0, memory: 0, elapsed: 10_000 } }, + }); + + assert.equal(snapshot.agents[0].startedAt, 0); + assert.equal(snapshot.agents[0].identity, "factory:pid:15:start:0"); + }); + it("does not duplicate a process already represented by a specialized adapter", async () => { const snapshot = await attachGenericHarnessProcesses( { From f38d99ac61994bbf388ce6ff05b111fec49f95fa Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:49:15 -0400 Subject: [PATCH 53/72] feat(harnesses): add Gemini and Copilot hook normalization --- src/harnessHookModel.ts | 51 ++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/src/harnessHookModel.ts b/src/harnessHookModel.ts index e0f54bd..6bcec96 100644 --- a/src/harnessHookModel.ts +++ b/src/harnessHookModel.ts @@ -7,7 +7,9 @@ export const hookHarnessIds = [ "hermes", "kimi", "factory", + "gemini", "qwen", + "copilot", ] as const satisfies readonly HarnessId[]; export type HookHarnessId = (typeof hookHarnessIds)[number]; @@ -29,6 +31,7 @@ export type CanonicalHarnessHookType = | "TaskCreated" | "TaskCompleted" | "PreVerify" + | "ErrorOccurred" | "Stop" | "StopFailure" | "Interrupt" @@ -52,11 +55,13 @@ export interface NormalizedHarnessHookEvent { const MAX_LABEL_LENGTH = 160; const MAX_IDENTIFIER_LENGTH = 512; +export const MAX_HOOK_FUTURE_SKEW_MS = 5 * 60_000; const SAFE_TEXT_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g; const DIRECT_TYPES = new Map([ ["sessionstart", "SessionStart"], ["sessionend", "SessionEnd"], ["userpromptsubmit", "UserPromptSubmit"], + ["userpromptsubmitted", "UserPromptSubmit"], ["userpromptexpansion", "UserPromptExpansion"], ["messagedisplay", "MessageDisplay"], ["pretooluse", "PreToolUse"], @@ -70,6 +75,9 @@ const DIRECT_TYPES = new Map([ ["subagentstop", "SubagentStop"], ["taskcreated", "TaskCreated"], ["taskcompleted", "TaskCompleted"], + ["preverify", "PreVerify"], + ["erroroccurred", "ErrorOccurred"], + ["agentstop", "Stop"], ["stop", "Stop"], ["stopfailure", "StopFailure"], ["interrupt", "Interrupt"], @@ -92,6 +100,17 @@ const HERMES_TYPES = new Map([ ["agentstart", "UserPromptSubmit"], ["agentend", "Stop"], ]); +const GEMINI_TYPES = new Map([ + ["sessionstart", "SessionStart"], + ["sessionend", "SessionEnd"], + ["beforeagent", "UserPromptSubmit"], + ["afteragent", "Stop"], + ["aftermodel", "MessageDisplay"], + ["beforetool", "PreToolUse"], + ["aftertool", "PostToolUse"], + ["precompress", "PreCompact"], + ["notification", "Notification"], +]); function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -160,9 +179,11 @@ function parseTimestamp(value: unknown, receivedAt: number): number | undefined if (!Number.isNaN(asDate)) parsed = asDate; } } - const fallback = Number.isFinite(receivedAt) && receivedAt >= 0 ? receivedAt : Date.now(); + const fallback = + Number.isFinite(receivedAt) && receivedAt >= 0 ? receivedAt : Date.now(); if (parsed === undefined) return fallback; - return parsed >= 0 ? parsed : undefined; + if (parsed < 0) return undefined; + return parsed > fallback + MAX_HOOK_FUTURE_SKEW_MS ? fallback : parsed; } function opaqueKey(scope: string, value: string): string { @@ -180,6 +201,7 @@ function canonicalType( ): CanonicalHarnessHookType | undefined { const compact = compactName(rawType); if (harnessId === "hermes") return HERMES_TYPES.get(compact); + if (harnessId === "gemini") return GEMINI_TYPES.get(compact); return DIRECT_TYPES.get(compact); } @@ -264,10 +286,19 @@ export function normalizeHarnessHookPayload( firstLabel(input, ["tool_name", "toolName", "matcher"]) ?? nestedLabel(input, "extra", ["tool_name", "toolName"]); const agentType = - firstLabel(input, ["agent_type", "agentType", "subagent_type", "subagentType"]) ?? + firstLabel(input, [ + "agent_type", + "agentType", + "agent_name", + "agentName", + "subagent_type", + "subagentType", + ]) ?? nestedLabel(input, "extra", [ "agent_type", "agentType", + "agent_name", + "agentName", "child_role", "childRole", ]); @@ -276,6 +307,7 @@ export function normalizeHarnessHookPayload( "notificationType", "reason", ]); + const final = extractBoolean(input, ["final", "is_final", "isFinal"]); return { version: 1, @@ -284,14 +316,13 @@ export function normalizeHarnessHookPayload( sessionKey: opaqueKey(`session:${harnessId}`, sessionId), timestamp, ...(cwd ? { cwdKey: opaqueKey(`cwd:${harnessId}`, cwd) } : {}), - ...(turnId ? { turnKey: opaqueKey(`turn:${harnessId}`, `${sessionId}\0${turnId}`) } : {}), + ...(turnId + ? { turnKey: opaqueKey(`turn:${harnessId}`, `${sessionId}\0${turnId}`) } + : {}), ...(toolName ? { toolName } : {}), ...(agentType ? { agentType } : {}), ...(notificationType ? { notificationType } : {}), - ...(() => { - const final = extractBoolean(input, ["final", "is_final", "isFinal"]); - return final === undefined ? {} : { final }; - })(), + ...(final === undefined ? {} : { final }), }; } @@ -337,6 +368,10 @@ export function harnessHookEventSummary( case "PreVerify": summary = "tool: verify"; break; + case "ErrorOccurred": + type = "error.occurred"; + isError = true; + break; case "StopFailure": type = "turn.failed"; isError = true; From 6ef838e157b609bee442c817724337f9a6f261cc Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:49:49 -0400 Subject: [PATCH 54/72] fix(harnesses): track expansion activity and Copilot errors --- src/services/harnessEvents.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/harnessEvents.ts b/src/services/harnessEvents.ts index 6e77d60..347cd12 100644 --- a/src/services/harnessEvents.ts +++ b/src/services/harnessEvents.ts @@ -17,6 +17,7 @@ const DEFAULT_INFLIGHT_TIMEOUT_MS = 30_000; const MAX_EVENTS = 50; const INFLIGHT_TYPES = new Set([ "UserPromptSubmit", + "UserPromptExpansion", "MessageDisplay", "PreToolUse", "PostToolUse", @@ -39,6 +40,7 @@ const IDLE_TYPES = new Set([ ]); const ERROR_TYPES = new Set([ "PostToolUseFailure", + "ErrorOccurred", "StopFailure", ]); const NON_ACTIVITY_TYPES = new Set([ From f034d6478716e79c7c8282bc5c263ea4463015d1 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:50:55 -0400 Subject: [PATCH 55/72] fix(harnesses): serialize hook log writes and bound replay --- src/services/harnessEventLog.ts | 105 +++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/src/services/harnessEventLog.ts b/src/services/harnessEventLog.ts index 70056a1..b995a3e 100644 --- a/src/services/harnessEventLog.ts +++ b/src/services/harnessEventLog.ts @@ -2,14 +2,18 @@ import { appendFile, chmod, mkdir, + open, readFile, rename, stat, + unlink, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { + MAX_HOOK_FUTURE_SKEW_MS, harnessHookEventKey, isHookHarnessId, type CanonicalHarnessHookType, @@ -20,6 +24,9 @@ const DEFAULT_LOG_MAX_BYTES = 2 * 1024 * 1024; const MIN_LOG_MAX_BYTES = 64 * 1024; const DEFAULT_RETENTION_MS = 30 * 60 * 1000; const MAX_SEEN_EVENTS = 10_000; +const LOCK_WAIT_MS = 1_000; +const LOCK_RETRY_MS = 10; +const LOCK_STALE_MS = 30_000; const KEY_RE = /^[a-f0-9]{24}$/; const LABEL_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/; const CANONICAL_TYPES = new Set([ @@ -40,6 +47,7 @@ const CANONICAL_TYPES = new Set([ "TaskCreated", "TaskCompleted", "PreVerify", + "ErrorOccurred", "Stop", "StopFailure", "Interrupt", @@ -119,6 +127,46 @@ async function trimLog(filePath: string, maxBytes: number): Promise { await chmod(filePath, 0o600).catch(() => undefined); } +async function removeStaleLock(lockPath: string): Promise { + try { + const info = await stat(lockPath); + if (Date.now() - info.mtimeMs <= LOCK_STALE_MS) return false; + await unlink(lockPath); + return true; + } catch { + return true; + } +} + +async function withLogLock( + filePath: string, + action: () => Promise +): Promise { + await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const lockPath = `${filePath}.lock`; + const deadline = Date.now() + LOCK_WAIT_MS; + + while (true) { + try { + const handle = await open(lockPath, "wx", 0o600); + try { + return await action(); + } finally { + await handle.close().catch(() => undefined); + await unlink(lockPath).catch(() => undefined); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw error; + if (await removeStaleLock(lockPath)) continue; + if (Date.now() >= deadline) { + throw new Error(`timed out acquiring harness event log lock: ${lockPath}`); + } + await delay(LOCK_RETRY_MS); + } + } +} + function isSafeLabel(value: unknown): boolean { return ( value === undefined || @@ -158,13 +206,14 @@ async function persistEvent( filePath: string, event: NormalizedHarnessHookEvent ): Promise { - await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); - await appendFile(filePath, `${JSON.stringify(event)}\n`, { - encoding: "utf8", - mode: 0o600, + await withLogLock(filePath, async () => { + await appendFile(filePath, `${JSON.stringify(event)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await chmod(filePath, 0o600).catch(() => undefined); + await trimLog(filePath, resolveLogMaxBytes()); }); - await chmod(filePath, 0o600).catch(() => undefined); - await trimLog(filePath, resolveLogMaxBytes()); } export function queueHarnessEventPersistence( @@ -188,6 +237,33 @@ export function flushHarnessEventPersistence(): Promise { return persistenceQueue; } +async function readLogText(filePath: string): Promise { + const maxReadBytes = Math.max( + DEFAULT_LOG_MAX_BYTES * 2, + resolveLogMaxBytes() * 2 + ); + const info = await stat(filePath); + if (info.size <= maxReadBytes) return readFile(filePath, "utf8"); + + const handle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(maxReadBytes); + const position = Math.max(0, info.size - maxReadBytes); + const { bytesRead } = await handle.read( + buffer, + 0, + maxReadBytes, + position + ); + const tail = buffer.subarray(0, bytesRead); + const firstNewline = tail.indexOf(0x0a); + if (firstNewline < 0) return ""; + return tail.subarray(firstNewline + 1).toString("utf8"); + } finally { + await handle.close(); + } +} + export async function readStoredHarnessEvents(): Promise< NormalizedHarnessHookEvent[] > { @@ -197,18 +273,26 @@ export async function readStoredHarnessEvents(): Promise< let input: string; try { - input = await readFile(filePath, "utf8"); + input = await readLogText(filePath); } catch { return []; } - const cutoff = Date.now() - resolveRetentionMs(); + const now = Date.now(); + const cutoff = now - resolveRetentionMs(); + const latestAcceptedAt = now + MAX_HOOK_FUTURE_SKEW_MS; const events: NormalizedHarnessHookEvent[] = []; for (const line of input.split(/\r?\n/)) { if (!line.trim()) continue; try { const parsed: unknown = JSON.parse(line); - if (!isStoredHarnessHookEvent(parsed) || parsed.timestamp < cutoff) continue; + if ( + !isStoredHarnessHookEvent(parsed) || + parsed.timestamp < cutoff || + parsed.timestamp > latestAcceptedAt + ) { + continue; + } events.push(parsed); } catch { // Ignore malformed and partially written lines. @@ -216,7 +300,8 @@ export async function readStoredHarnessEvents(): Promise< } events.sort( (a, b) => - a.timestamp - b.timestamp || harnessHookEventKey(a).localeCompare(harnessHookEventKey(b)) + a.timestamp - b.timestamp || + harnessHookEventKey(a).localeCompare(harnessHookEventKey(b)) ); const result: NormalizedHarnessHookEvent[] = []; From 01e074761719494b5a21d62add1781f95e80be8b Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:53:47 -0400 Subject: [PATCH 56/72] feat(harnesses): register Gemini and Copilot hooks --- src/harnesses.ts | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/harnesses.ts b/src/harnesses.ts index ef69bc3..e06b2e8 100644 --- a/src/harnesses.ts +++ b/src/harnesses.ts @@ -131,7 +131,7 @@ export const harnessDefinitions: readonly HarnessDefinition[] = [ kinds: ["openclaw-cli", "openclaw-server"], class: "local-server", telemetry: "native-events", - docs: "https://docs.openclaw.ai/agent-loop", + docs: "https://docs.openclaw.ai/concepts/agent-loop", processRules: [ { binaries: ["openclaw"], @@ -188,9 +188,7 @@ export const harnessDefinitions: readonly HarnessDefinition[] = [ class: "local-cli", telemetry: "structured-stream", docs: "https://docs.cursor.com/en/cli/using", - processRules: [ - { binaries: ["cursor-agent"], kind: "cursor-cli" }, - ], + processRules: [{ binaries: ["cursor-agent"], kind: "cursor-cli" }], note: "Cursor CLI exposes structured stream output and a partial hook set; coverage must state which source was observed.", }, @@ -223,8 +221,8 @@ export const harnessDefinitions: readonly HarnessDefinition[] = [ displayName: "Gemini CLI", kinds: ["gemini-cli"], class: "local-cli", - telemetry: "process-only", - docs: "https://developers.google.com/gemini-code-assist/docs/gemini-cli", + telemetry: "hooks", + docs: "https://geminicli.com/docs/hooks/", processRules: [{ binaries: ["gemini"], kind: "gemini-cli" }], }, { @@ -244,16 +242,15 @@ export const harnessDefinitions: readonly HarnessDefinition[] = [ class: "local-cli", telemetry: "hooks", docs: "https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/", - processRules: [ - { binaries: ["qwen", "qwen-code"], kind: "qwen-cli" }, - ], + processRules: [{ binaries: ["qwen", "qwen-code"], kind: "qwen-cli" }], }, { id: "copilot", displayName: "GitHub Copilot CLI", kinds: ["copilot-cli"], class: "local-cli", - telemetry: "process-only", + telemetry: "hooks", + docs: "https://docs.github.com/en/copilot/reference/hooks-reference", processRules: [{ binaries: ["copilot"], kind: "copilot-cli" }], }, { @@ -300,9 +297,7 @@ export const harnessDefinitions: readonly HarnessDefinition[] = [ kinds: ["kiro-cli"], class: "local-cli", telemetry: "process-only", - processRules: [ - { binaries: ["kiro", "kiro-cli"], kind: "kiro-cli" }, - ], + processRules: [{ binaries: ["kiro", "kiro-cli"], kind: "kiro-cli" }], }, { id: "openhands", @@ -380,7 +375,9 @@ function stripQuotes(value: string): string { function basename(value: string): string { const parts = stripQuotes(value).split(/[\\/]/); - return (parts[parts.length - 1] || value).toLowerCase(); + return (parts[parts.length - 1] || value) + .toLowerCase() + .replace(/\.exe$/, ""); } function commandTokens(command: string | undefined): string[] { @@ -398,12 +395,16 @@ function ruleMatches( const tokens = commandTokens(command); const executable = tokens[0] ? basename(tokens[0]) : undefined; const name = processName ? basename(processName) : undefined; - const binaries = new Set(rule.binaries.map((binary) => binary.toLowerCase())); + const binaries = new Set( + rule.binaries.map((binary) => binary.toLowerCase().replace(/\.exe$/, "")) + ); if (!((executable && binaries.has(executable)) || (name && binaries.has(name)))) { return false; } if (!rule.requiredAnyToken?.length) return true; - const commandTokensLower = new Set(tokens.slice(1).map((token) => token.toLowerCase())); + const commandTokensLower = new Set( + tokens.slice(1).map((token) => token.toLowerCase()) + ); return rule.requiredAnyToken.some((token) => commandTokensLower.has(token.toLowerCase()) ); @@ -435,7 +436,11 @@ export function detectGenericHarnessProcess( processName: string | undefined ): DetectedHarnessProcess | undefined { for (const harness of harnessDefinitions) { - if (harness.id === "codex" || harness.id === "opencode" || harness.id === "claude") { + if ( + harness.id === "codex" || + harness.id === "opencode" || + harness.id === "claude" + ) { continue; } for (const rule of harness.processRules ?? []) { From d1c2ed7fb6ed8cf93900608fc6dc2a6a71c8870b Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:54:46 -0400 Subject: [PATCH 57/72] test(harnesses): cover Gemini Copilot and clock skew --- tests/unit/harnessHookModel.test.ts | 165 ++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/tests/unit/harnessHookModel.test.ts b/tests/unit/harnessHookModel.test.ts index 72fbb94..1ab50b6 100644 --- a/tests/unit/harnessHookModel.test.ts +++ b/tests/unit/harnessHookModel.test.ts @@ -1,8 +1,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { + MAX_HOOK_FUTURE_SKEW_MS, harnessHookEventKey, harnessHookEventSummary, + isHookHarnessId, normalizeHarnessHookPayload, } from "../../src/harnessHookModel.js"; @@ -178,4 +180,167 @@ describe("shared harness hook model", () => { assert.equal(key.includes("secret"), false); assert.equal(key, harnessHookEventKey(event!)); }); + + it("normalizes Gemini agent, model, and tool hooks without payload content", () => { + const now = Date.parse("2026-07-19T01:00:00.000Z"); + const beforeAgent = normalizeHarnessHookPayload( + "gemini", + { + hook_event_name: "BeforeAgent", + session_id: "gemini-session", + cwd: "/Users/alice/private-project", + timestamp: new Date(now).toISOString(), + prompt: "private prompt", + }, + now + ); + const afterModel = normalizeHarnessHookPayload( + "gemini", + { + hook_event_name: "AfterModel", + session_id: "gemini-session", + timestamp: new Date(now + 1_000).toISOString(), + llm_response: { candidates: [{ content: "private response" }] }, + }, + now + 1_000 + ); + const beforeTool = normalizeHarnessHookPayload( + "gemini", + { + hook_event_name: "BeforeTool", + session_id: "gemini-session", + timestamp: new Date(now + 2_000).toISOString(), + tool_name: "write_file", + tool_input: { path: "/private/file" }, + }, + now + 2_000 + ); + const afterAgent = normalizeHarnessHookPayload( + "gemini", + { + hook_event_name: "AfterAgent", + session_id: "gemini-session", + timestamp: new Date(now + 3_000).toISOString(), + prompt_response: "private final response", + }, + now + 3_000 + ); + + assert.equal(isHookHarnessId("gemini"), true); + assert.equal(beforeAgent?.type, "UserPromptSubmit"); + assert.equal(afterModel?.type, "MessageDisplay"); + assert.equal(beforeTool?.type, "PreToolUse"); + assert.equal(beforeTool?.toolName, "write_file"); + assert.equal(afterAgent?.type, "Stop"); + const serialized = JSON.stringify([ + beforeAgent, + afterModel, + beforeTool, + afterAgent, + ]); + assert.equal(serialized.includes("private"), false); + }); + + it("normalizes Copilot camelCase and Claude-compatible hooks", () => { + const now = 1_800_000_000_000; + const prompt = normalizeHarnessHookPayload( + "copilot", + { + event: "userPromptSubmitted", + sessionId: "copilot-session", + timestamp: now, + cwd: "/private/repo", + prompt: "private prompt", + }, + now + ); + const tool = normalizeHarnessHookPayload( + "copilot", + { + hook_event_name: "PreToolUse", + session_id: "copilot-session", + timestamp: new Date(now + 1_000).toISOString(), + tool_name: "bash", + tool_input: { command: "cat ~/.ssh/id_ed25519" }, + }, + now + 1_000 + ); + const subagent = normalizeHarnessHookPayload( + "copilot", + { + event: "subagentStart", + sessionId: "copilot-session", + timestamp: now + 2_000, + agentName: "reviewer", + }, + now + 2_000 + ); + const failure = normalizeHarnessHookPayload( + "copilot", + { + event: "errorOccurred", + sessionId: "copilot-session", + timestamp: now + 3_000, + error: "private stack trace", + }, + now + 3_000 + ); + const stop = normalizeHarnessHookPayload( + "copilot", + { + event: "agentStop", + sessionId: "copilot-session", + timestamp: now + 4_000, + }, + now + 4_000 + ); + + assert.equal(isHookHarnessId("copilot"), true); + assert.equal(prompt?.type, "UserPromptSubmit"); + assert.equal(tool?.type, "PreToolUse"); + assert.equal(subagent?.agentType, "reviewer"); + assert.equal(failure?.type, "ErrorOccurred"); + assert.equal(harnessHookEventSummary(failure!)?.type, "error.occurred"); + assert.equal(harnessHookEventSummary(failure!)?.isError, true); + assert.equal(stop?.type, "Stop"); + const serialized = JSON.stringify([prompt, tool, subagent, failure, stop]); + assert.equal(serialized.includes("private prompt"), false); + assert.equal(serialized.includes("id_ed25519"), false); + assert.equal(serialized.includes("private stack trace"), false); + }); + + it("clamps hook timestamps beyond the allowed receive-time skew", () => { + const receivedAt = 1_800_000_000_000; + const tooFar = normalizeHarnessHookPayload( + "kimi", + { + hook_event_name: "UserPromptSubmit", + session_id: "future", + timestamp: receivedAt + MAX_HOOK_FUTURE_SKEW_MS + 1, + }, + receivedAt + ); + const allowed = normalizeHarnessHookPayload( + "kimi", + { + hook_event_name: "UserPromptSubmit", + session_id: "allowed", + timestamp: receivedAt + MAX_HOOK_FUTURE_SKEW_MS, + }, + receivedAt + ); + const negative = normalizeHarnessHookPayload( + "kimi", + { + hook_event_name: "UserPromptSubmit", + session_id: "negative", + timestamp: -1, + }, + receivedAt + ); + + assert.equal(tooFar?.timestamp, receivedAt); + assert.equal(allowed?.timestamp, receivedAt + MAX_HOOK_FUTURE_SKEW_MS); + assert.equal(negative, undefined); + }); }); From 99bfefcd952cda923aad68b31c0c50cfc9831b75 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:55:17 -0400 Subject: [PATCH 58/72] test(harnesses): cover expansion and Copilot error state --- tests/unit/harnessEvents.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/harnessEvents.test.ts b/tests/unit/harnessEvents.test.ts index ec0ba34..98d3b11 100644 --- a/tests/unit/harnessEvents.test.ts +++ b/tests/unit/harnessEvents.test.ts @@ -93,6 +93,36 @@ describe("harness event state", () => { assert.equal(state.inFlight, true); }); + it("keeps prompt expansion as activity without adding a graph event", () => { + const map = applyHarnessEvent( + new Map(), + event("UserPromptExpansion", 10) + ); + const state = [...map.values()][0]; + + assert.equal(state.inFlight, true); + assert.equal(state.lastActivityAt, 10); + assert.equal(state.events.length, 0); + }); + + it("marks Copilot errorOccurred metadata as an error without ending the turn", () => { + let map = applyHarnessEvent( + new Map(), + event("UserPromptSubmit", 1, { harnessId: "copilot" }) + ); + map = applyHarnessEvent( + map, + event("ErrorOccurred", 2, { harnessId: "copilot" }) + ); + const state = [...map.values()][0]; + + assert.equal(state.hasError, true); + assert.equal(state.inFlight, true); + assert.equal(state.lastEventType, "ErrorOccurred"); + assert.equal(state.events.at(-1)?.type, "error.occurred"); + assert.equal(state.events.at(-1)?.isError, true); + }); + it("expires an in-flight session after the configured timeout", () => { const previous = process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS; process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS = "10"; From b9c7809ef83d258b3881177c1d57db2721ad9388 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:55:54 -0400 Subject: [PATCH 59/72] test(harnesses): cover bounded replay and future timestamps --- tests/unit/harnessEventLog.test.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/unit/harnessEventLog.test.ts b/tests/unit/harnessEventLog.test.ts index 3dfa09b..cb57e5a 100644 --- a/tests/unit/harnessEventLog.test.ts +++ b/tests/unit/harnessEventLog.test.ts @@ -4,6 +4,7 @@ import { readFile, rm, stat, + writeFile, } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -116,17 +117,19 @@ describe("harness event log", { concurrency: false }, () => { }); }); - it("ignores malformed, expired, and duplicate disk records", async () => { + it("ignores malformed, expired, future-skewed, and duplicate disk records", async () => { await withLog(async (_directory, logPath) => { process.env.CONSENSUS_HARNESS_EVENT_TTL_MS = "1000"; const current = event(900); const expired = event(901, { timestamp: Date.now() - 2_000 }); + const future = event(902, { timestamp: Date.now() + 10 * 60_000 }); await appendFile( logPath, [ JSON.stringify(current), JSON.stringify(current), JSON.stringify(expired), + JSON.stringify(future), JSON.stringify({ ...current, sessionKey: "raw-session" }), "{", "", @@ -138,6 +141,21 @@ describe("harness event log", { concurrency: false }, () => { }); }); + it("reads only a bounded complete-line tail from an oversized log", async () => { + await withLog(async (_directory, logPath) => { + const current = event(950); + const oversizedPrefix = "x".repeat(5 * 1024 * 1024); + await writeFile( + logPath, + `${oversizedPrefix}\n${JSON.stringify(current)}\n`, + "utf8" + ); + + const events = await readStoredHarnessEvents(); + assert.deepEqual(events, [current]); + }); + }); + it("validates the complete persisted metadata boundary", () => { const valid = event(1); assert.equal(isStoredHarnessHookEvent(valid), true); @@ -157,5 +175,13 @@ describe("harness event log", { concurrency: false }, () => { isStoredHarnessHookEvent({ ...valid, toolName: "Bash\nforged" }), false ); + assert.equal( + isStoredHarnessHookEvent({ + ...valid, + harnessId: "copilot", + type: "ErrorOccurred", + }), + true + ); }); }); From 47603d05a2077c05cd6229d6c44fea8af5fb825e Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 22:56:19 -0400 Subject: [PATCH 60/72] test(harnesses): cover hook telemetry and Windows binaries --- tests/unit/harnesses.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit/harnesses.test.ts b/tests/unit/harnesses.test.ts index e123f8e..b17da69 100644 --- a/tests/unit/harnesses.test.ts +++ b/tests/unit/harnesses.test.ts @@ -4,6 +4,7 @@ import { agentKinds, detectGenericHarnessProcess, harnessDefinitions, + harnessForId, harnessIdForKind, isAgentKind, } from "../../src/harnesses.js"; @@ -38,6 +39,7 @@ describe("harness registry", () => { ["droid", "factory", "factory-cli"], ["gemini", "gemini", "gemini-cli"], ["qwen-code", "qwen", "qwen-cli"], + ["copilot", "copilot", "copilot-cli"], ["q chat", "amazon-q", "amazon-q-cli"], ["kiro-cli", "kiro", "kiro-cli"], ["openhands", "openhands", "openhands-cli"], @@ -50,11 +52,45 @@ describe("harness registry", () => { } }); + it("normalizes Windows executable aliases", () => { + assert.equal( + detectGenericHarnessProcess( + '"C:\\Tools\\gemini.exe"', + "gemini.exe" + )?.kind, + "gemini-cli" + ); + assert.equal( + detectGenericHarnessProcess( + "C:\\Tools\\copilot.exe", + "copilot.exe" + )?.kind, + "copilot-cli" + ); + }); + + it("declares Gemini and Copilot as hook-backed harnesses", () => { + assert.equal(harnessForId("gemini").telemetry, "hooks"); + assert.equal(harnessForId("copilot").telemetry, "hooks"); + assert.match( + harnessForId("gemini").docs ?? "", + /geminicli\.com\/docs\/hooks/ + ); + assert.match( + harnessForId("copilot").docs ?? "", + /docs\.github\.com/ + ); + }); + it("uses the executable token instead of matching names mentioned in prompts", () => { assert.equal( detectGenericHarnessProcess("node runner.js --prompt 'use openclaw'", "node"), undefined ); + assert.equal( + detectGenericHarnessProcess("node runner.js --prompt 'use gemini'", "node"), + undefined + ); assert.equal(detectGenericHarnessProcess("oz config list", "oz"), undefined); assert.equal(detectGenericHarnessProcess("q --version", "q"), undefined); assert.equal(detectGenericHarnessProcess("agent --print task", "agent"), undefined); From 7aaa0657fcc2eff794ba4fe741693c62b0655ec1 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:23:03 -0400 Subject: [PATCH 61/72] fix(harnesses): redact command payloads and cache process scans --- src/genericHarnessSnapshot.ts | 144 ++++++++++++++++++++++++++++------ 1 file changed, 120 insertions(+), 24 deletions(-) diff --git a/src/genericHarnessSnapshot.ts b/src/genericHarnessSnapshot.ts index 998ed1f..e0877a7 100644 --- a/src/genericHarnessSnapshot.ts +++ b/src/genericHarnessSnapshot.ts @@ -21,19 +21,34 @@ export interface GenericHarnessUsage { elapsed?: number; } +type ProcessLoader = () => Promise; +type UsageLoader = ( + pids: number[] +) => Promise>; + export interface GenericHarnessSnapshotOptions { processes?: readonly GenericHarnessProcess[]; usage?: Readonly>; now?: number; + cacheMs?: number; + processLoader?: ProcessLoader; + usageLoader?: UsageLoader; } -function shortenCommand(command: string, maxLength = 120): string { - const normalized = command.replace(/\s+/g, " ").trim(); - return normalized.length <= maxLength - ? normalized - : `${normalized.slice(0, maxLength - 3)}...`; +interface DetectedProcess { + process: GenericHarnessProcess; + detected: DetectedHarnessProcess; } +interface GenericProcessCache { + at: number; + matches: DetectedProcess[]; + usage: Record; +} + +const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g; +let genericProcessCache: GenericProcessCache | undefined; + function commandTokens(command: string): string[] { return ( command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => @@ -107,11 +122,88 @@ async function loadUsage( } } +function resolveCacheMs(override?: number): number { + if (typeof override === "number" && Number.isFinite(override)) { + return Math.max(0, Math.floor(override)); + } + const parsed = Number(process.env.CONSENSUS_GENERIC_PROCESS_CACHE_MS); + return Number.isFinite(parsed) && parsed >= 0 + ? Math.floor(parsed) + : 1_000; +} + function processState(cpu: number): AgentSnapshot["state"] { const threshold = Number(process.env.CONSENSUS_GENERIC_CPU_ACTIVE || 1); return Number.isFinite(threshold) && cpu > threshold ? "active" : "idle"; } +function safeSnapshotText( + value: string | undefined, + maxLength = 4_096 +): string | undefined { + if (!value) return undefined; + const sanitized = value + .replace(CONTROL_RE, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); + return sanitized || undefined; +} + +function detectProcesses( + processes: readonly GenericHarnessProcess[] +): DetectedProcess[] { + return processes.flatMap((process) => { + if (!Number.isInteger(process.pid) || process.pid < 0) return []; + const detected = detectGenericHarnessProcess(process.cmd, process.name); + return detected ? [{ process, detected }] : []; + }); +} + +async function loadDetectedProcesses( + options: GenericHarnessSnapshotOptions +): Promise<{ + matches: DetectedProcess[]; + usage: Readonly>; +}> { + if (options.processes) { + const matches = detectProcesses(options.processes); + const usage = + options.usage ?? + (await (options.usageLoader ?? loadUsage)( + matches.map(({ process }) => process.pid) + )); + return { matches, usage }; + } + + const cacheMs = resolveCacheMs(options.cacheMs); + const cacheNow = Date.now(); + if ( + genericProcessCache && + cacheMs > 0 && + cacheNow - genericProcessCache.at <= cacheMs + ) { + return { + matches: genericProcessCache.matches, + usage: genericProcessCache.usage, + }; + } + + const processes = await (options.processLoader ?? psList)(); + const matches = detectProcesses(processes); + const usage = + options.usage ?? + (await (options.usageLoader ?? loadUsage)( + matches.map(({ process }) => process.pid) + )); + genericProcessCache = { + at: cacheNow, + matches, + usage: { ...usage }, + }; + return { matches, usage }; +} + function toAgentSnapshot( process: GenericHarnessProcess, detected: DetectedHarnessProcess, @@ -119,14 +211,16 @@ function toAgentSnapshot( now: number ): AgentSnapshot { const commandRaw = process.cmd || process.name || detected.harness.displayName; - const command = redactText(commandRaw) || commandRaw; const cwdRaw = extractCwd(commandRaw); const sessionId = extractSessionId(commandRaw); - const cwd = redactText(cwdRaw) || cwdRaw; - const cpu = typeof usage.cpu === "number" && Number.isFinite(usage.cpu) ? usage.cpu : 0; + const cwd = safeSnapshotText(redactText(cwdRaw) || cwdRaw); + const cpu = + typeof usage.cpu === "number" && Number.isFinite(usage.cpu) + ? Math.max(0, usage.cpu) + : 0; const mem = typeof usage.memory === "number" && Number.isFinite(usage.memory) - ? usage.memory + ? Math.max(0, usage.memory) : 0; const elapsed = typeof usage.elapsed === "number" && @@ -142,6 +236,10 @@ function toAgentSnapshot( const role = detected.kind.endsWith("server") ? "server" : "agent"; const startIdentity = typeof startedAt === "number" ? `:start:${startedAt}` : ""; + const safeCommand = `${detected.harness.id} ${role}`; + const repo = cwdRaw + ? safeSnapshotText(path.basename(path.resolve(cwdRaw)), 255) + : undefined; return { identity: `${detected.harness.id}:pid:${process.pid}${startIdentity}`, @@ -149,8 +247,8 @@ function toAgentSnapshot( pid: process.pid, startedAt, title: detected.harness.displayName, - cmd: command, - cmdShort: shortenCommand(command), + cmd: safeCommand, + cmdShort: safeCommand, kind: detected.kind, cpu, mem, @@ -158,7 +256,7 @@ function toAgentSnapshot( activityReason: state === "active" ? "process_cpu" : "process_detected", doing: `${detected.harness.displayName} ${role}`, cwd, - repo: cwdRaw ? path.basename(path.resolve(cwdRaw)) : undefined, + repo, harnessCwdKey: cwdRaw ? harnessCwdKey(detected.harness.id, cwdRaw) : undefined, @@ -172,21 +270,15 @@ export async function attachGenericHarnessProcesses( snapshot: SnapshotPayload, options: GenericHarnessSnapshotOptions = {} ): Promise { - const processes = options.processes ?? (await psList()); + const { matches, usage } = await loadDetectedProcesses(options); const existingPids = new Set(snapshot.agents.map((agent) => agent.pid)); - const matches = processes.flatMap((process) => { - if (!Number.isInteger(process.pid) || process.pid < 0 || existingPids.has(process.pid)) { - return []; - } - const detected = detectGenericHarnessProcess(process.cmd, process.name); - return detected ? [{ process, detected }] : []; - }); - if (matches.length === 0) return snapshot; + const uniqueMatches = matches.filter( + ({ process }) => !existingPids.has(process.pid) + ); + if (uniqueMatches.length === 0) return snapshot; - const usage = - options.usage ?? (await loadUsage(matches.map(({ process }) => process.pid))); const now = options.now ?? snapshot.ts; - const additionalAgents = matches.map(({ process, detected }) => + const additionalAgents = uniqueMatches.map(({ process, detected }) => toAgentSnapshot(process, detected, usage[process.pid] ?? {}, now) ); @@ -195,3 +287,7 @@ export async function attachGenericHarnessProcesses( agents: [...snapshot.agents, ...additionalAgents], }; } + +export function resetGenericHarnessProcessCacheForTests(): void { + genericProcessCache = undefined; +} From a6290722d3e9977be45c33317d993a78761f7ae7 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:24:55 -0400 Subject: [PATCH 62/72] test(harnesses): cover command privacy and scan caching --- tests/unit/genericHarnessSnapshot.test.ts | 102 ++++++++++++++++++++-- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/tests/unit/genericHarnessSnapshot.test.ts b/tests/unit/genericHarnessSnapshot.test.ts index cbb8860..89c9359 100644 --- a/tests/unit/genericHarnessSnapshot.test.ts +++ b/tests/unit/genericHarnessSnapshot.test.ts @@ -1,6 +1,9 @@ -import { describe, it } from "node:test"; +import { beforeEach, describe, it } from "node:test"; import assert from "node:assert/strict"; -import { attachGenericHarnessProcesses } from "../../src/genericHarnessSnapshot.js"; +import { + attachGenericHarnessProcesses, + resetGenericHarnessProcessCacheForTests, +} from "../../src/genericHarnessSnapshot.js"; import { buildAgentGraph } from "../../src/graph.js"; import { harnessCwdKey, harnessSessionKey } from "../../src/harnessKeys.js"; import type { SnapshotPayload } from "../../src/types.js"; @@ -10,6 +13,10 @@ const emptySnapshot: SnapshotPayload = { agents: [], }; +beforeEach(() => { + resetGenericHarnessProcessCacheForTests(); +}); + describe("generic harness snapshot", () => { it("adds process-level agents for detected runtimes", async () => { const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { @@ -37,6 +44,8 @@ describe("generic harness snapshot", () => { assert.equal(openclaw?.repo, "project"); assert.equal(openclaw?.startedAt, 8); assert.equal(openclaw?.identity, "openclaw:pid:10:start:8"); + assert.equal(openclaw?.cmd, "openclaw agent"); + assert.equal(openclaw?.cmdShort, "openclaw agent"); assert.equal( openclaw?.harnessCwdKey, harnessCwdKey("openclaw", "/tmp/project") @@ -90,27 +99,104 @@ describe("generic harness snapshot", () => { assert.equal(snapshot.agents[0].kind, "tui"); }); - it("keeps command prompts out of titles and doing summaries", async () => { + it("does not export command prompts or session values", async () => { const secret = "private prompt contents"; + const sessionSecret = "session-secret-value"; const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { processes: [ { pid: 70, name: "cursor-agent", - cmd: `cursor-agent --print ${secret}`, + cmd: `cursor-agent --print ${secret} --session ${sessionSecret}`, }, ], usage: { 70: { cpu: 0, memory: 0 } }, }); const agent = snapshot.agents[0]; + const serialized = JSON.stringify(agent); assert.equal(agent.title, "Cursor Agent"); assert.equal(agent.doing, "Cursor Agent agent"); - assert.equal(agent.title?.includes(secret), false); - assert.equal(agent.doing?.includes(secret), false); + assert.equal(agent.cmd, "cursor agent"); + assert.equal(agent.cmdShort, "cursor agent"); + assert.equal(serialized.includes(secret), false); + assert.equal(serialized.includes(sessionSecret), false); + assert.match(agent.harnessSessionKey ?? "", /^[a-f0-9]{24}$/); + }); + + it("sanitizes control and bidi characters from exported paths", async () => { + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + processes: [ + { + pid: 71, + name: "droid", + cmd: "droid --cwd '/tmp/repo\u001b[2J\u202Eevil'", + }, + ], + usage: { 71: { cpu: 0, memory: 0 } }, + }); + const serialized = JSON.stringify(snapshot.agents[0]); + + assert.equal( + /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test( + serialized + ), + false + ); + }); + + it("caches generic process and usage discovery for short polling windows", async () => { + let processLoads = 0; + let usageLoads = 0; + const processLoader = async () => { + processLoads += 1; + return [{ pid: 72, name: "gemini", cmd: "gemini" }]; + }; + const usageLoader = async () => { + usageLoads += 1; + return { 72: { cpu: 0, memory: 0 } }; + }; + + const first = await attachGenericHarnessProcesses(emptySnapshot, { + cacheMs: 1_000, + processLoader, + usageLoader, + }); + const second = await attachGenericHarnessProcesses(emptySnapshot, { + cacheMs: 1_000, + processLoader, + usageLoader, + }); + + assert.equal(first.agents.length, 1); + assert.equal(second.agents.length, 1); + assert.equal(processLoads, 1); + assert.equal(usageLoads, 1); + }); + + it("can disable generic process caching for deterministic refreshes", async () => { + let processLoads = 0; + const processLoader = async () => { + processLoads += 1; + return [{ pid: 73, name: "copilot", cmd: "copilot" }]; + }; + const usageLoader = async () => ({ 73: { cpu: 0, memory: 0 } }); + + await attachGenericHarnessProcesses(emptySnapshot, { + cacheMs: 0, + processLoader, + usageLoader, + }); + await attachGenericHarnessProcesses(emptySnapshot, { + cacheMs: 0, + processLoader, + usageLoader, + }); + + assert.equal(processLoads, 2); }); - it("reports honest process-only coverage in the graph", async () => { + it("reports honest hook coverage when no hook history is attached", async () => { const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { processes: [{ pid: 80, name: "gemini", cmd: "gemini" }], usage: { 80: { cpu: 0, memory: 0 } }, @@ -122,7 +208,7 @@ describe("generic harness snapshot", () => { assert.equal(graph.coverage.providers.gemini.history, "unavailable"); assert.match( graph.coverage.providers.gemini.note ?? "", - /process-level coverage only/i + /no retained hook events/i ); }); From 058749c936d7c61d880f77f72764062f26db8f00 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:31:40 -0400 Subject: [PATCH 63/72] fix(harnesses): reject ambiguous cwd session matches --- src/harnessSnapshot.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/harnessSnapshot.ts b/src/harnessSnapshot.ts index 6216821..85f7ade 100644 --- a/src/harnessSnapshot.ts +++ b/src/harnessSnapshot.ts @@ -97,7 +97,8 @@ function selectState( const cwdMatches = states.filter( (state) => state.cwdKey === agent.harnessCwdKey ); - if (cwdMatches.length > 0) return cwdMatches[0]; + if (cwdMatches.length === 1) return cwdMatches[0]; + if (cwdMatches.length > 1) return undefined; } return singleAgent && states.length === 1 ? states[0] : undefined; } From 1ff26bc7eeb8367b0151ceec0ee2581ae3e912dc Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:32:33 -0400 Subject: [PATCH 64/72] test(harnesses): reject ambiguous cwd session assignment --- tests/unit/harnessSnapshot.test.ts | 64 +++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/unit/harnessSnapshot.test.ts b/tests/unit/harnessSnapshot.test.ts index a2db179..44bbc5c 100644 --- a/tests/unit/harnessSnapshot.test.ts +++ b/tests/unit/harnessSnapshot.test.ts @@ -30,8 +30,8 @@ function agent(overrides: Partial = {}): AgentSnapshot { identity: "factory:pid:10:start:1", id: "10", pid: 10, - cmd: "droid", - cmdShort: "droid", + cmd: "factory agent", + cmdShort: "factory agent", kind: "factory-cli", cpu: 0, mem: 0, @@ -92,6 +92,66 @@ describe("harness snapshot attachment", () => { assert.equal(attached.agents[0].state, "active"); }); + it("rejects cwd matching when several hook sessions share the directory", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { + sessionKey: "d".repeat(24), + cwdKey: "b".repeat(24), + }), + false + ); + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 91, { + sessionKey: "e".repeat(24), + cwdKey: "b".repeat(24), + }), + false + ); + + const attached = attachHarnessEvents( + snapshot([ + agent({ harnessSessionKey: undefined, harnessCwdKey: "b".repeat(24) }), + ]) + ); + + assert.equal(attached.agents[0].events, undefined); + assert.equal(attached.agents[0].harnessSessionKey, undefined); + assert.equal(attached.agents[0].state, "idle"); + }); + + it("uses an exact session key even when cwd matching is ambiguous", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { + sessionKey: "d".repeat(24), + cwdKey: "b".repeat(24), + }), + false + ); + handleNormalizedHarnessEvent( + event("Stop", 91, { + sessionKey: "e".repeat(24), + cwdKey: "b".repeat(24), + }), + false + ); + + const attached = attachHarnessEvents( + snapshot([ + agent({ + harnessSessionKey: "d".repeat(24), + harnessCwdKey: "b".repeat(24), + }), + ]) + ); + + assert.equal(attached.agents[0].harnessSessionKey, "d".repeat(24)); + assert.equal(attached.agents[0].state, "active"); + assert.deepEqual( + attached.agents[0].events?.map((entry) => entry.summary), + ["prompt"] + ); + }); + it("uses one-to-one fallback only when assignment is unambiguous", () => { handleNormalizedHarnessEvent( event("UserPromptSubmit", 90, { From a604985df79137d319ea229b79eca2534be4604c Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:36:48 -0400 Subject: [PATCH 65/72] fix(harnesses): deduplicate hook retries across processes --- src/services/harnessEventLog.ts | 55 +++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/services/harnessEventLog.ts b/src/services/harnessEventLog.ts index b995a3e..347579a 100644 --- a/src/services/harnessEventLog.ts +++ b/src/services/harnessEventLog.ts @@ -27,6 +27,7 @@ const MAX_SEEN_EVENTS = 10_000; const LOCK_WAIT_MS = 1_000; const LOCK_RETRY_MS = 10; const LOCK_STALE_MS = 30_000; +const DEDUP_TAIL_BYTES = 256 * 1024; const KEY_RE = /^[a-f0-9]{24}$/; const LABEL_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/; const CANONICAL_TYPES = new Set([ @@ -202,11 +203,61 @@ export function isStoredHarnessHookEvent( ); } +async function tailContainsEventKey( + filePath: string, + key: string +): Promise { + let info; + try { + info = await stat(filePath); + } catch { + return false; + } + if (info.size <= 0) return false; + + const bytesToRead = Math.min(info.size, DEDUP_TAIL_BYTES); + const handle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(bytesToRead); + const position = Math.max(0, info.size - bytesToRead); + const { bytesRead } = await handle.read( + buffer, + 0, + bytesToRead, + position + ); + let input = buffer.subarray(0, bytesRead).toString("utf8"); + if (position > 0) { + const firstNewline = input.indexOf("\n"); + input = firstNewline >= 0 ? input.slice(firstNewline + 1) : ""; + } + for (const line of input.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const parsed: unknown = JSON.parse(line); + if ( + isStoredHarnessHookEvent(parsed) && + harnessHookEventKey(parsed) === key + ) { + return true; + } + } catch { + // Ignore malformed or partially written lines. + } + } + return false; + } finally { + await handle.close(); + } +} + async function persistEvent( filePath: string, - event: NormalizedHarnessHookEvent + event: NormalizedHarnessHookEvent, + key: string ): Promise { await withLogLock(filePath, async () => { + if (await tailContainsEventKey(filePath, key)) return; await appendFile(filePath, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600, @@ -226,7 +277,7 @@ export function queueHarnessEventPersistence( if (!rememberEvent(key)) return persistenceQueue; persistenceQueue = persistenceQueue - .then(() => persistEvent(filePath, event)) + .then(() => persistEvent(filePath, event, key)) .catch(() => { if (seenPath === filePath) seenEvents.delete(key); }); From 312ab4da41488dd510a334801336969bfc87f875 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:37:30 -0400 Subject: [PATCH 66/72] test(harnesses): cover cross-process hook deduplication --- tests/unit/harnessEventLog.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/harnessEventLog.test.ts b/tests/unit/harnessEventLog.test.ts index cb57e5a..572702c 100644 --- a/tests/unit/harnessEventLog.test.ts +++ b/tests/unit/harnessEventLog.test.ts @@ -85,6 +85,20 @@ describe("harness event log", { concurrency: false }, () => { }); }); + it("deduplicates a retry already written by another hook process", async () => { + await withLog(async (_directory, logPath) => { + const duplicate = event(3); + await writeFile(logPath, `${JSON.stringify(duplicate)}\n`, "utf8"); + void queueHarnessEventPersistence(duplicate); + await flushHarnessEventPersistence(); + + const lines = (await readFile(logPath, "utf8")) + .split(/\r?\n/) + .filter(Boolean); + assert.equal(lines.length, 1); + }); + }); + it("captures the configured path when writes are queued", async () => { await withLog(async (directory, firstPath) => { const secondPath = path.join(directory, "later.jsonl"); From 479a627edfbaea5bdbd11c457d11df1a01dd556a Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:41:27 -0400 Subject: [PATCH 67/72] feat(harnesses): add provider-safe hook acknowledgements --- src/harnessHookOutput.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/harnessHookOutput.ts diff --git a/src/harnessHookOutput.ts b/src/harnessHookOutput.ts new file mode 100644 index 0000000..036199d --- /dev/null +++ b/src/harnessHookOutput.ts @@ -0,0 +1,9 @@ +import type { HookHarnessId } from "./harnessHookModel.js"; + +const JSON_ACK_HARNESSES = new Set(["gemini", "copilot"]); + +export function harnessHookAcknowledgement( + harnessId: HookHarnessId +): string { + return JSON_ACK_HARNESSES.has(harnessId) ? "{}\n" : ""; +} From 9c271e5f4c1da737c36f5b01bafe4fa2cef77cea Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:42:05 -0400 Subject: [PATCH 68/72] fix(harnesses): acknowledge Gemini and Copilot hooks with JSON --- src/harnessHook.ts | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/harnessHook.ts b/src/harnessHook.ts index ea7e341..0160a89 100644 --- a/src/harnessHook.ts +++ b/src/harnessHook.ts @@ -3,6 +3,7 @@ import { isHookHarnessId, normalizeHarnessHookPayload, } from "./harnessHookModel.js"; +import { harnessHookAcknowledgement } from "./harnessHookOutput.js"; import { flushHarnessEventPersistence, queueHarnessEventPersistence, @@ -26,20 +27,32 @@ async function readStdin(): Promise { async function main(): Promise { const harnessId = process.argv[2]; if (!isHookHarnessId(harnessId)) return; - const input = await readStdin(); - if (!input.trim()) return; - let payload: unknown; try { - payload = JSON.parse(input); + const input = await readStdin(); + if (input.trim()) { + let payload: unknown; + try { + payload = JSON.parse(input); + } catch { + payload = undefined; + } + const event = normalizeHarnessHookPayload( + harnessId, + payload, + Date.now() + ); + if (event) { + await queueHarnessEventPersistence(event); + await flushHarnessEventPersistence(); + } + } } catch { - return; + // Hooks must never block or fail the owning harness. + } finally { + const acknowledgement = harnessHookAcknowledgement(harnessId); + if (acknowledgement) process.stdout.write(acknowledgement); } - - const event = normalizeHarnessHookPayload(harnessId, payload); - if (!event) return; - await queueHarnessEventPersistence(event); - await flushHarnessEventPersistence(); } -void main().catch(() => undefined); +void main(); From 9855fc6f98bcc1a1b240b5de162598e7eb4550d8 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:42:21 -0400 Subject: [PATCH 69/72] test(harnesses): verify provider hook acknowledgements --- tests/unit/harnessHookOutput.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/unit/harnessHookOutput.test.ts diff --git a/tests/unit/harnessHookOutput.test.ts b/tests/unit/harnessHookOutput.test.ts new file mode 100644 index 0000000..b8228b2 --- /dev/null +++ b/tests/unit/harnessHookOutput.test.ts @@ -0,0 +1,18 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { harnessHookAcknowledgement } from "../../src/harnessHookOutput.js"; + +describe("harness hook acknowledgements", () => { + it("returns compact JSON for harnesses that require JSON stdout", () => { + assert.equal(harnessHookAcknowledgement("gemini"), "{}\n"); + assert.equal(harnessHookAcknowledgement("copilot"), "{}\n"); + }); + + it("keeps stdout empty for harnesses with passive command hooks", () => { + assert.equal(harnessHookAcknowledgement("claude"), ""); + assert.equal(harnessHookAcknowledgement("hermes"), ""); + assert.equal(harnessHookAcknowledgement("kimi"), ""); + assert.equal(harnessHookAcknowledgement("factory"), ""); + assert.equal(harnessHookAcknowledgement("qwen"), ""); + }); +}); From 4f75d5bbfc36c03effba7345b2403cdb12e25cb8 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:42:52 -0400 Subject: [PATCH 70/72] test(harnesses): run Gemini and Copilot collectors end to end --- tests/integration/harnessHookCli.test.ts | 115 +++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/integration/harnessHookCli.test.ts diff --git a/tests/integration/harnessHookCli.test.ts b/tests/integration/harnessHookCli.test.ts new file mode 100644 index 0000000..af1face --- /dev/null +++ b/tests/integration/harnessHookCli.test.ts @@ -0,0 +1,115 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(testDirectory, "../.."); +const hookPath = path.join(repositoryRoot, "src/harnessHook.ts"); + +async function runHook( + harnessId: string, + input: string, + logPath: string +): Promise<{ code: number | null; stdout: string; stderr: string }> { + const child = spawn( + process.execPath, + ["--import", "tsx", hookPath, harnessId], + { + cwd: repositoryRoot, + env: { + ...process.env, + CONSENSUS_HARNESS_EVENT_LOG: logPath, + }, + stdio: ["pipe", "pipe", "pipe"], + } + ); + child.stdin.end(input); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + const code = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("exit", resolve); + }); + return { code, stdout, stderr }; +} + +describe("shared harness hook CLI", { concurrency: false }, () => { + it("acknowledges Gemini with JSON and persists metadata only", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-gemini-hook-")); + const logPath = path.join(directory, "events.jsonl"); + try { + const result = await runHook( + "gemini", + JSON.stringify({ + hook_event_name: "BeforeTool", + session_id: "secret-session", + cwd: "/Users/alice/private-project", + timestamp: Date.now(), + tool_name: "write_file", + prompt: "private prompt", + tool_input: { path: "/private/file", content: "private content" }, + }), + logPath + ); + + assert.equal(result.code, 0); + assert.equal(result.stdout, "{}\n"); + assert.equal(result.stderr, ""); + const persisted = await readFile(logPath, "utf8"); + assert.match(persisted, /"harnessId":"gemini"/); + assert.match(persisted, /"type":"PreToolUse"/); + assert.match(persisted, /"toolName":"write_file"/); + assert.equal(persisted.includes("secret-session"), false); + assert.equal(persisted.includes("/Users/alice"), false); + assert.equal(persisted.includes("private prompt"), false); + assert.equal(persisted.includes("private content"), false); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("acknowledges malformed Copilot input without writing a record", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-copilot-hook-")); + const logPath = path.join(directory, "events.jsonl"); + try { + const result = await runHook("copilot", "{", logPath); + assert.equal(result.code, 0); + assert.equal(result.stdout, "{}\n"); + assert.equal(result.stderr, ""); + await assert.rejects(() => readFile(logPath, "utf8")); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("keeps passive Claude hook stdout empty", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-claude-hook-")); + const logPath = path.join(directory, "events.jsonl"); + try { + const result = await runHook( + "claude", + JSON.stringify({ + hook_event_name: "SessionStart", + session_id: "session", + timestamp: Date.now(), + }), + logPath + ); + assert.equal(result.code, 0); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, ""); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); From 5f9635e6abd677b6dbaf2c61e31399202e77f42b Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:53:51 -0400 Subject: [PATCH 71/72] docs(harnesses): update hook coverage and privacy contract --- docs/harness-providers.md | 73 ++++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/docs/harness-providers.md b/docs/harness-providers.md index afc535e..ae2a1ca 100644 --- a/docs/harness-providers.md +++ b/docs/harness-providers.md @@ -1,11 +1,11 @@ # Harness providers -Consensus models the **agent harness or runtime** separately from the model provider. +Consensus models the **agent harness or runtime** separately from the model or tool provider. Examples: - `openai`, `anthropic`, `minimax`, and `moonshot` may provide models or tools. -- Codex, Claude Code, OpenClaw, Hermes, Kimi Code, Cursor Agent, Factory Droid, and Warp Oz provide execution loops or agent surfaces. +- Codex, Claude Code, OpenClaw, Hermes, Kimi Code, Cursor Agent, Factory Droid, Gemini CLI, GitHub Copilot CLI, and Warp Oz provide execution loops or agent surfaces. This distinction prevents a MiniMax model used inside OpenClaw from being reported as a separate agent loop unless a distinct MiniMax runtime is actually running. @@ -29,16 +29,16 @@ Consensus reports coverage by evidence source rather than a single supported fla | OpenCode | Local server/TUI | HTTP API and SSE event streams | Event graph | | Claude Code | Local CLI | Lifecycle hooks | Event graph | | OpenClaw | Local server/runtime | Gateway RPC run, tool, assistant, and lifecycle streams | Process discovery; native adapter next | -| Hermes Agent | Local CLI/gateway | Gateway, plugin, and shell hooks | Process discovery; hook adapter next | -| Kimi Code | Local CLI | Lifecycle hooks | Process discovery; hook adapter next | +| Hermes Agent | Local CLI/gateway | Gateway, plugin, and shell hooks | Process discovery plus shared hook graph | +| Kimi Code | Local CLI | Lifecycle hooks | Process discovery plus shared hook graph | | MiniMax CLI | Tool/model integration | CLI invocation inside another harness | Tool-only discovery | | Cursor Agent | Local CLI/remote agent | Partial hooks and `stream-json` CLI output | Process discovery; stream adapter next | | Warp Oz | Local and remote agent | Oz API/SDK run status and transcripts | Process discovery; remote API adapter next | -| Factory Droid | Local CLI | Lifecycle hooks | Process discovery; hook adapter next | -| Qwen Code | Local CLI | Command or HTTP lifecycle hooks | Process discovery; hook adapter next | -| Gemini CLI | Local CLI | No Consensus event adapter yet | Process discovery | +| Factory Droid | Local CLI | Lifecycle hooks | Process discovery plus shared hook graph | +| Qwen Code | Local CLI | Command or HTTP lifecycle hooks | Process discovery plus shared hook graph | +| Gemini CLI | Local CLI | Lifecycle hooks | Process discovery plus shared hook graph | +| GitHub Copilot CLI | Local CLI | Lifecycle hooks | Process discovery plus shared hook graph | | Antigravity CLI | Local CLI | No Consensus event adapter yet | Process discovery | -| GitHub Copilot CLI | Local CLI | No Consensus event adapter yet | Process discovery | | Aider | Local CLI | No Consensus event adapter yet | Process discovery | | Goose | Local CLI | No Consensus event adapter yet | Process discovery | | Amp | Local CLI | No Consensus event adapter yet | Process discovery | @@ -54,7 +54,7 @@ Consensus reports coverage by evidence source rather than a single supported fla ## Process discovery contract -The one-shot snapshot and graph commands detect exact executable names. They do not search arbitrary prompt text for provider names. +The snapshot and graph scanners detect exact executable names. They do not search arbitrary prompt text for provider names. Examples: @@ -64,12 +64,58 @@ Examples: - `oz agent run` becomes `warp-cli`; unrelated `oz` commands are ignored. - `q chat` becomes `amazon-q-cli`; unrelated `q` processes are ignored. - Cursor detection uses `cursor-agent`; the generic executable name `agent` is not matched. +- Windows `.exe` names receive the same exact-token matching as Unix executables. -Generic detections carry no synthetic step events. Their graph coverage is therefore explicit about being process-only, tool-only, hook-ready, stream-ready, or remote-API-ready. +Generic process snapshots deliberately do not export raw command lines. Inline prompts, session arguments, and other command payloads are replaced by a stable harness label. Raw working-directory and session values are used only to derive redacted display fields and opaque correlation keys. + +Generic discovery uses a short process and usage cache so a fast dashboard poll does not issue a second full process query for every harness. Set `CONSENSUS_GENERIC_PROCESS_CACHE_MS=0` to disable this cache. + +## Shared hook collector + +The metadata-only collector is: + +```text +node /path/to/consensus-cli/dist/harnessHook.js +``` + +Supported hook IDs are: + +```text +claude +hermes +kimi +factory +gemini +qwen +copilot +``` + +The collector accepts provider JSON on standard input and writes normalized metadata to `~/.consensus/harness-events.jsonl` by default. It does not retain prompt text, assistant text, tool input or output, shell commands, raw paths, raw session identifiers, raw turn identifiers, or error details. + +Gemini CLI and GitHub Copilot CLI require valid JSON on hook stdout. Consensus returns a compact one-line `{}` acknowledgment for those two harnesses, including malformed or ignored input. Passive command-hook providers remain silent. + +The shared log: + +- Uses mode `0600` in a directory created with mode `0700`. +- Uses an inter-process lock for append and trim operations. +- Deduplicates repeat deliveries both in memory and against the recent on-disk tail. +- Bounds UTF-8 byte size and keeps complete JSON lines. +- Rejects malformed, expired, and far-future records during replay. +- Can be disabled with `CONSENSUS_HARNESS_EVENT_LOG=0`. + +## Session assignment + +Hook history attaches to a process in this order: + +1. Exact opaque session key. +2. A working-directory key only when exactly one unclaimed session matches. +3. A one-process/one-session fallback only when both sides are unambiguous. + +When several sessions share a directory, Consensus leaves them unattached rather than assigning the newest session to an arbitrary process. ## Adapter order -1. **Shared hook adapter:** Claude-style lifecycle JSON covers Kimi Code, Factory Droid, and Qwen Code with small provider maps. Hermes uses a related hook surface and can feed the same normalized event model through a provider-specific translator. +1. **Shared hook adapter:** Claude, Hermes, Kimi Code, Factory Droid, Gemini CLI, Qwen Code, and GitHub Copilot CLI feed one normalized, metadata-only event model. 2. **OpenClaw adapter:** consume Gateway RPC lifecycle, assistant, and tool streams keyed by `runId` and session. 3. **Cursor adapter:** ingest `--output-format stream-json` for headless runs and use supported CLI hooks where available. 4. **Warp adapter:** read Oz run status, transcripts, and metadata through the official API/SDK. @@ -87,12 +133,13 @@ Provider contracts were checked on July 19, 2026: - Codex configuration: - Claude Code hooks: - OpenCode server and SSE: -- OpenClaw agent loop and runtimes: and +- OpenClaw agent loop and runtimes: and - Hermes hooks: - Kimi Code hooks: - MiniMax CLI: - Cursor CLI: - Warp Oz CLI/API: - Factory Droid hooks: +- Gemini CLI hooks: - Qwen Code hooks: -- Gemini CLI: +- GitHub Copilot hooks: From 7fc5441f13fa1f1f073e88d7bc56496f1efd1ca7 Mon Sep 17 00:00:00 2001 From: 0x Date: Sat, 18 Jul 2026 23:54:47 -0400 Subject: [PATCH 72/72] docs: document multi-harness graph coverage --- README.md | 85 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 22d9967..c3e242c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![GitHub release](https://img.shields.io/github/v/release/integrate-your-mind/consensus-cli?display_name=tag&color=2563eb)](https://github.com/integrate-your-mind/consensus-cli/releases) [![License](https://img.shields.io/npm/l/consensus-cli.svg?color=6b7280)](LICENSE) -Local-first observability for coding-agent graphs, loops, and live activity across Codex, OpenCode, and Claude Code. +Local-first observability for coding-agent graphs, loops, and live activity. ## Status @@ -15,6 +15,7 @@ Beta. Local-only, with no hosted service. - Inspect observed prompt, model, tool, command, and edit transitions. - Detect cycles inside individual observed turns. - Track active, idle, and error states across local coding agents. +- Compare evidence depth across different agent harnesses. - Inspect recent activity without reading raw provider logs. ## Scope @@ -36,7 +37,7 @@ Run the published CLI: npx consensus-cli ``` -Consensus reads local Codex sessions and does not require an API key. OpenCode and Claude Code support depend on their local server and hook interfaces. +Consensus reads local runtime evidence and does not require an OpenAI API key. ## Graphs and loops @@ -71,24 +72,73 @@ The graph model: - Splits step nodes by observed turn so separate prompts cannot create a false cycle. - Filters lifecycle and transport events such as `turn.completed`, `session.status`, heartbeat, and token-count records. - Collapses adjacent fragments of the same phase. -- Detects strongly connected components only within a turn segment. +- Detects strongly connected components only within one agent and turn segment. - Applies live state only to the latest observed step. Historical loops remain idle. - Reports `transitionObservations`, which counts internal transition evidence rather than claiming completed loop iterations. Raw session paths are not included in graph IDs. See [`docs/graphs-and-loops.md`](docs/graphs-and-loops.md) for the full contract. +## Harness coverage + +List the registry and evidence tiers: + +```bash +npx consensus-cli harnesses +npx consensus-cli harnesses --json +npx consensus-cli harnesses --setup gemini +npx consensus-cli harnesses --setup copilot +``` + +The registry includes: + +- Codex, OpenCode, and Claude Code. +- OpenClaw, Hermes Agent, Kimi Code, Factory Droid, Gemini CLI, Qwen Code, and GitHub Copilot CLI. +- Cursor Agent, Warp Oz, MiniMax CLI, Antigravity, Aider, Goose, Amp, Amazon Q, Kiro, and OpenHands. +- Cline, Roo Code, Windsurf Cascade, JetBrains Junie, Replit Agent, and Zed Agent. + +Consensus distinguishes process discovery from event-level support. Exact process recognition does not imply a complete graph. Each harness reports one of these evidence tiers: native events, hooks, structured stream, remote API, process only, tool only, or manual bridge. + +The enriched snapshot path used by `npm run scan` and `consensus graph` performs exact executable discovery for supported local harnesses. It does not search prompt text for provider names. See [`docs/harness-providers.md`](docs/harness-providers.md) for the provider matrix and adapter limits. + +## Shared metadata-only hooks + +The shared collector supports Claude-compatible or mapped lifecycle hooks for: + +```text +claude +hermes +kimi +factory +gemini +qwen +copilot +``` + +Run it as the provider's command hook: + +```text +node /path/to/consensus-cli/dist/harnessHook.js +``` + +The collector accepts provider JSON on standard input and writes normalized metadata to `~/.consensus/harness-events.jsonl` by default. + +It does not retain prompt text, assistant text, tool input or output, shell commands, raw working directories, raw session or turn identifiers, or error details. Gemini CLI and GitHub Copilot CLI receive a compact one-line `{}` acknowledgment because their hook contracts require valid JSON on standard output; passive command-hook providers remain silent. + +Hook session assignment fails closed when several sessions share the same working directory. Exact opaque session keys take priority; cwd fallback is used only when one session matches. + ## Provider data flow -1. Scan local Codex, OpenCode, and Claude Code processes. +1. Scan specialized Codex, OpenCode, and Claude Code process/session sources. 2. Read bounded Codex JSONL summaries and notify events. 3. Read OpenCode sessions and SSE events from the local server. -4. Receive Claude Code hooks and retain bounded metadata-only event history. -5. Build live snapshots and stream them to the browser. -6. Derive per-turn execution graphs and detected cycles. +4. Receive Claude and shared multi-harness hook metadata. +5. Discover additional exact local harness executables without exporting command payloads. +6. Correlate processes and hook sessions with opaque keys. +7. Derive turn-scoped execution graphs and detected cycles. ### Codex -`consensus setup` writes the notify command to the user-level `~/.codex/config.toml`. Current Codex documentation defines `notify` as a command array and notes that project-local config does not support it. +`consensus setup` writes the notify command to the user-level `~/.codex/config.toml`. ```bash npx consensus-cli setup @@ -102,23 +152,17 @@ Current OpenCode server docs also expose `parentID` and `/session/:id/children`; ### Claude Code -Claude activity and graph history require hooks. Point each selected event at: +Claude activity and graph history require hooks. The existing HTTP collector remains available: ```text node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event ``` -Recommended graph events: - -- Events that do not support matchers: `UserPromptSubmit`, `MessageDisplay`, `PostToolBatch`, `TaskCreated`, `TaskCompleted`, and `Stop`. -- Configure `StopFailure` and `SessionEnd` without a matcher for full coverage, or use their supported matcher fields when narrowing coverage. -- Use matcher `"*"` for broad coverage of `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied`, `SubagentStart`, and `SubagentStop`. +The shared standalone collector can also normalize Claude metadata through `harnessHook.js claude`. Claude Code can emit several `MessageDisplay` calls for one assistant message. Consensus treats partial batches as activity but retains only the final batch marker. It does not retain the message delta. -`UserPromptSubmit` is the canonical turn boundary. `UserPromptExpansion` can still update live activity, but Consensus does not retain it as a second prompt node because direct slash-command expansion occurs inside the submitted turn. - -The local Claude metadata log stores event type, session ID, timestamp, an opaque working-directory key, and small hook labels such as tool or agent type. It does not store prompts, assistant text, message deltas, tool inputs, tool outputs, transcript paths, task descriptions, or error details. Disable it with `CONSENSUS_CLAUDE_EVENT_LOG=0`. +`UserPromptSubmit` is the canonical turn boundary. `UserPromptExpansion` can update live activity, but Consensus does not retain it as a second prompt node. ## Configuration @@ -132,7 +176,11 @@ The main settings are: - `CONSENSUS_OPENCODE_AUTOSTART=0` — disable dashboard autostart. - `CONSENSUS_REDACT_PII=0` — disable normal text redaction. Opaque graph IDs remain opaque. - `CONSENSUS_CLAUDE_EVENT_LOG` — Claude metadata log path, or `0` to disable. -- `CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES` — bounded log size, default 1 MiB. +- `CONSENSUS_HARNESS_EVENT_LOG` — shared harness metadata log path, or `0` to disable. +- `CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES` — shared log byte bound, default 2 MiB. +- `CONSENSUS_HARNESS_EVENT_TTL_MS` — shared event retention window. +- `CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS` — shared hook in-flight timeout. +- `CONSENSUS_GENERIC_PROCESS_CACHE_MS` — generic process discovery cache, default 1000 ms. See [`docs/configuration.md`](docs/configuration.md) for the complete list. @@ -158,6 +206,7 @@ npm run build - [`docs/configuration.md`](docs/configuration.md) - [`docs/cli.md`](docs/cli.md) - [`docs/graphs-and-loops.md`](docs/graphs-and-loops.md) +- [`docs/harness-providers.md`](docs/harness-providers.md) - [`docs/data-inventory.md`](docs/data-inventory.md) - [`docs/threat-model.md`](docs/threat-model.md) - [`docs/testing.md`](docs/testing.md)