diff --git a/.changeset/claude-activity-hooks-1941.md b/.changeset/claude-activity-hooks-1941.md new file mode 100644 index 000000000..54a20218e --- /dev/null +++ b/.changeset/claude-activity-hooks-1941.md @@ -0,0 +1,52 @@ +--- +"@aoagents/ao-core": minor +"@aoagents/ao-plugin-agent-claude-code": minor +--- + +Replace Claude Code terminal-regex activity detection with platform-event hooks (#1941). + +Claude Code emits a lifecycle hook on every state transition that matters +(`PermissionRequest`, `StopFailure`, `Notification`, `Stop`, `PreToolUse`, +…). Until now, AO ignored all but one of them and tried to infer the +same information by regex-matching Claude's rendered terminal output — +fragile by construction. Every Claude UI tweak (footer wording, status +verb, spinner glyph) broke a heuristic; PR #1932 spent 15 commits +patching the sharpest edges. + +This release pivots: + +**`@aoagents/ao-plugin-agent-claude-code`** now installs two scripts per +workspace: + +- `metadata-updater` — unchanged; PostToolUse(Bash) extracts gh/git + side-effects (PR URL, branch, merge status). +- `activity-updater` — new; registered on every hook that carries + activity information (SessionStart, UserPromptSubmit, PreToolUse, + PostToolUse, PostToolUseFailure, PostToolBatch, Notification, + PermissionRequest, Stop, StopFailure, SubagentStart, SubagentStop, + PreCompact, PostCompact). The script reads the JSON payload from + stdin, maps `hook_event_name` to an activity state, and appends a + JSONL entry to `{workspace}/.ao/activity.jsonl` with `source: "hook"`. + +Notification is filtered by `notification_type` so `auth_success` / +`elicitation_*` no longer false-fire `waiting_input` (the RFC's blanket +"Notification → waiting_input" would have regressed here). + +The terminal-regex layer (`classifyTerminalOutput`, ~80 LOC of +patterns + `agent.recordActivity`) is retired. `detectActivity` stays on +the Agent interface for other agents but is now a stable `return "idle"` +stub for Claude — the JSONL-backed cascade is the only source of truth +for active / ready / waiting_input / blocked. + +**`@aoagents/ao-core`** extends `ActivityLogEntry.source` and +`ActivitySignalSource` with a `"hook"` value so the new entries are +parseable and their provenance is visible in telemetry. No downstream +consumer needs changes — the cascade has always read whatever source +appeared in the JSONL, and the new tests assert hook-sourced entries +flow through `checkActivityLogState` / `getActivityFallbackState` +identically to terminal-sourced ones. + +Idempotent install: calling `setupWorkspaceHooks` twice keeps exactly +one entry per event and preserves user-installed hooks alongside ours. +Cross-platform: bash + Node (.cjs) variants behave identically against a +shared 52-case scenario table. diff --git a/packages/core/src/__tests__/activity-log.test.ts b/packages/core/src/__tests__/activity-log.test.ts index 6d8288b8d..675441475 100644 --- a/packages/core/src/__tests__/activity-log.test.ts +++ b/packages/core/src/__tests__/activity-log.test.ts @@ -240,6 +240,15 @@ describe("readLastActivityEntry", () => { expect(result).toBeNull(); }); + it("accepts entries with source: hook", async () => { + await appendActivityEntry(tmpDir, "waiting_input", "hook", "PermissionRequest"); + const result = await readLastActivityEntry(tmpDir); + expect(result).not.toBeNull(); + expect(result!.entry.state).toBe("waiting_input"); + expect(result!.entry.source).toBe("hook"); + expect(result!.entry.trigger).toBe("PermissionRequest"); + }); + it("falls back to the previous complete line when a read races a truncated tail", async () => { await mkdir(join(tmpDir, ".ao"), { recursive: true }); const completeEntry: ActivityLogEntry = { diff --git a/packages/core/src/activity-log.ts b/packages/core/src/activity-log.ts index 56a1e02c3..a661387eb 100644 --- a/packages/core/src/activity-log.ts +++ b/packages/core/src/activity-log.ts @@ -35,7 +35,7 @@ export function getActivityLogPath(workspacePath: string): string { export async function appendActivityEntry( workspacePath: string, state: ActivityState, - source: "terminal" | "native", + source: "terminal" | "native" | "hook", trigger?: string, ): Promise { const logPath = getActivityLogPath(workspacePath); @@ -98,7 +98,7 @@ export async function readLastActivityEntry( const record = parsed as Record; const validStates = new Set(["active", "ready", "idle", "waiting_input", "blocked", "exited"]); - const validSources = new Set(["terminal", "native"]); + const validSources = new Set(["terminal", "native", "hook"]); if ( typeof record.ts !== "string" || typeof record.state !== "string" || diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 66b1ec0ef..18009ccee 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -157,7 +157,7 @@ export const ACTIVITY_STATE = { export type ActivitySignalState = "valid" | "stale" | "null" | "unavailable" | "probe_failure"; -export type ActivitySignalSource = "native" | "terminal" | "runtime" | "none"; +export type ActivitySignalSource = "native" | "terminal" | "hook" | "runtime" | "none"; export interface ActivitySignal { /** Confidence bucket for the activity probe result. */ @@ -183,11 +183,16 @@ export interface ActivityDetection { export interface ActivityLogEntry { /** ISO 8601 timestamp */ ts: string; - /** Activity state derived from terminal output or agent-native data */ + /** Activity state derived from terminal output, agent-native data, or a platform-event hook */ state: ActivityState; - /** What triggered this state classification */ - source: "terminal" | "native"; - /** Raw terminal snippet that caused waiting_input/blocked (for debugging) */ + /** + * Provenance of this entry: + * - "terminal": classified from terminal output (regex/heuristic; deprecated for hook-capable agents) + * - "native": read from the agent's own JSONL/API + * - "hook": emitted by an agent lifecycle hook (e.g. Claude Code's PermissionRequest, Stop, StopFailure) + */ + source: "terminal" | "native" | "hook"; + /** Raw terminal snippet, hook event name, or other context that caused waiting_input/blocked (for debugging) */ trigger?: string; } diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index 332018d41..a93f7d74d 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -14,7 +14,6 @@ import { toClaudeProjectPath, create } from "../index.js"; import { resetWarnedReaddirPaths } from "../activity-detection.js"; import { createActivitySignal, - readLastActivityEntry, type ActivityState, type Session, type RuntimeHandle, @@ -72,13 +71,20 @@ function writeJsonl( } } -function writeActivityLog(state: ActivityState, ageMs = 0): void { +function writeActivityLog( + state: ActivityState, + ageMs = 0, + source: "terminal" | "native" | "hook" = "terminal", + trigger?: string, +): void { const ts = new Date(Date.now() - ageMs).toISOString(); const aoDir = join(workspacePath, ".ao"); mkdirSync(aoDir, { recursive: true }); + const entry: Record = { ts, state, source }; + if (trigger !== undefined) entry.trigger = trigger; writeFileSync( join(aoDir, "activity.jsonl"), - JSON.stringify({ ts, state, source: "terminal" }) + "\n", + JSON.stringify(entry) + "\n", ); } @@ -288,21 +294,16 @@ describe("Claude Code Activity Detection", () => { expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull(); }); - it("recordActivity writes to .ao/activity.jsonl when workspacePath is set", async () => { - await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o"); - - const result = await readLastActivityEntry(workspacePath); - expect(result?.entry.state).toBe("waiting_input"); - expect(result?.entry.source).toBe("terminal"); - expect(result?.entry.trigger).toContain("Do you want to proceed?"); + it("recordActivity is intentionally not implemented (#1941 — hooks write activity-JSONL directly)", () => { + // Lifecycle manager calls agent.recordActivity? only if defined. + // For Claude, hooks are the source of truth so this method is + // omitted — guarding here surfaces accidental re-introduction. + expect(agent.recordActivity).toBeUndefined(); }); - it("recordActivity is a no-op when workspacePath is null", async () => { - await agent.recordActivity?.( - makeSession({ workspacePath: null }), - "Do you want to proceed?\n(Y)es / (N)o", - ); - + it("does NOT write to .ao/activity.jsonl on its own (hook-only producer)", () => { + // Without recordActivity, the plugin no longer derives anything from + // terminal output. .ao/activity.jsonl stays empty until a hook fires. expect(existsSync(join(workspacePath, ".ao", "activity.jsonl"))).toBe(false); }); @@ -313,8 +314,11 @@ describe("Claude Code Activity Detection", () => { expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); }); - it("falls back to AO JSONL waiting_input when native session lookup is unavailable", async () => { - await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o"); + it("falls back to AO JSONL waiting_input when native session lookup is unavailable (#1941 hook entry)", async () => { + // PermissionRequest hook fires → activity-updater appends a JSONL entry + // with source: "hook". The cascade picks it up exactly like the old + // terminal-derived entry. + writeActivityLog("waiting_input", 0, "hook", "PermissionRequest (Bash)"); expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); }); @@ -323,11 +327,21 @@ describe("Claude Code Activity Detection", () => { writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000); const session = makeSession({ createdAt: new Date() }); - await agent.recordActivity?.(session, "Do you want to proceed?\n(Y)es / (N)o"); + writeActivityLog("waiting_input", 0, "hook", "PermissionRequest"); expect((await agent.getActivityState(session))?.state).toBe("waiting_input"); }); + it("surfaces blocked from a StopFailure hook entry in AO JSONL", async () => { + // StopFailure → activity-updater appends `{state: blocked, source: hook, + // trigger: "StopFailure (rate_limit)"}`. With no Claude native JSONL + // present, the cascade must surface it through checkActivityLogState. + writeActivityLog("blocked", 0, "hook", "StopFailure (rate_limit)"); + + const result = await agent.getActivityState(makeSession()); + expect(result?.state).toBe("blocked"); + }); + it("returns idle for stale native session entry when AO JSONL is unavailable", async () => { writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000); const session = makeSession({ createdAt: new Date() }); diff --git a/packages/plugins/agent-claude-code/src/activity-detection.ts b/packages/plugins/agent-claude-code/src/activity-detection.ts index 940a4591f..af64c6cb5 100644 --- a/packages/plugins/agent-claude-code/src/activity-detection.ts +++ b/packages/plugins/agent-claude-code/src/activity-detection.ts @@ -276,86 +276,27 @@ export async function isClaudeProcessAlive(handle: RuntimeHandle): Promise$#]\s*$/.test(lastLine)) return "idle"; - - // Use a wider window (last 12 lines) than the bottom-of-buffer prompt - // check above because Claude's spinner+status line, ⎿ tool-result lines, - // and api-error text often sit 6-8 lines above the input area + footer. - // All multi-line state checks (blocked, active) use this window — full - // `terminalOutput` would let scrolled-off error text falsely return - // "blocked" forever after a successful retry pushes the error out of - // view but not out of scrollback. - const wideTail = lines.slice(-12).join("\n"); - - // Check for blocked. Claude's persistent UI footer contains - // "bypass permissions on (shift+tab to cycle)" on every session — the - // tightened waiting_input regex no longer matches it, and the blocked - // patterns below are specific to api-error retry text. - // - // Patterns observed empirically by capturing tmux output during a real - // api-blocked retry loop (see PR #1932 description): - // ⎿ Unable to connect to API (ConnectionRefused) - // Retrying in 19s · attempt 7/10 - if (/Unable to connect to API/i.test(wideTail)) return "blocked"; - if (/Retrying in \d+s.*attempt \d+\/\d+/i.test(wideTail)) return "blocked"; - - // Check the bottom of the buffer for permission prompts. Historical - // "Thinking"/"Reading" text earlier in the buffer must not override a - // current permission prompt at the bottom. - const tail = lines.slice(-5).join("\n"); - if (/Do you want to proceed\?/i.test(tail)) return "waiting_input"; - if (/\(Y\)es.*\(N\)o/i.test(tail)) return "waiting_input"; - // Match the ACTUAL permission-bypass prompt, NOT the static footer toggle. - if (/bypass\s+all\s+future\s+permissions/i.test(tail)) return "waiting_input"; - - // Active: only when an explicit active-work indicator is present. Default - // is IDLE — Claude's tmux pane has a persistent input area + footer that - // looks identical between "just finished" and "working". Treating - // unrecognized output as active caused dormant sessions (ao-160 etc.) to - // get an "active" written to AO activity-JSONL every poll cycle, which the - // age-decayed fallback then surfaced as ready forever. - - // Strongest active signal: gerund (present-participle) status verb - // followed by the trailing ellipsis "…". Claude cycles through many - // status words (Germinating, Fluttering, Pondering, Mulling, Crafting, - // Thinking, Reasoning, ...) and many spinner glyphs (✻ ✽ · ⠁ ⠈ etc.) - // depending on animation frame. The gerund+ellipsis combo is the - // consistent signal that survives glyph rotation. Past-tense lines like - // "✻ Worked for 11s" or "✻ Crunched for 11s" lack the ellipsis and are - // turn-complete summaries — they must NOT match (ao-143 repro). - if (/\b\w+ing…/.test(wideTail)) return "active"; - // NOTE — these patterns look "active-ish" but are NOT, and should never - // go back as active indicators: - // - /\bCrunched\s+for\s+\d+s/ — past-tense turn summary (ao-154 repro: - // "✻ Crunched for 22s" was making sessions perpetually "active") - // - /\bWorked\s+for\s+\d+s/ — past-tense turn summary (ao-143 repro) - // - /^\s*⎿\s+/ — prefixes past tool-results too - // - /esc to interrupt/ — in the persistent UI footer - // - bare /\bWorking\b/ — matches "working on issue #N" in recap - // The gerund+ellipsis above catches present-tense forms across all - // status words regardless of spinner glyph rotation. - - // Word-based fallbacks for synthetic test inputs and rare cases where - // the ellipsis isn't captured. Tight patterns to avoid false-firing on - // benign text. - if (/\bGerminating/i.test(wideTail)) return "active"; - if (/\b(?:Thinking|Working)\s*(?:…|\.\.\.)/i.test(wideTail)) return "active"; - if (/\bReading\s+file/i.test(wideTail)) return "active"; - if (/\bWriting\s+to\b/i.test(wideTail)) return "active"; - if (/\bSearching\s+codebase/i.test(wideTail)) return "active"; - if (/Press\s+up\s+to\s+edit\s+queued/i.test(wideTail)) return "active"; - +/** + * Retained as a stable no-signal stub for the deprecated + * `Agent.detectActivity` method on the Claude plugin. + * + * Claude activity is now derived from platform-event hooks + * (PermissionRequest / StopFailure / Notification / Stop / PreToolUse / ...) + * which write directly to `{workspace}/.ao/activity.jsonl`. The previous + * implementation regex-matched Claude's rendered terminal output, which + * regressed every time Claude's UI footer or status-line wording changed + * (15-commit churn in #1932 motivated the rewrite). + * + * The function is preserved so the Claude agent's `detectActivity` can + * delegate to a stable export rather than inlining `() => "idle"`, and + * because the hard-deprecated `detectActivity` method on the `Agent` + * interface still has callers outside this plugin (lifecycle-manager's + * terminal-output fallback, used by agents that haven't moved to hooks). + */ +export function classifyTerminalOutput(_terminalOutput: string): ActivityState { return "idle"; } diff --git a/packages/plugins/agent-claude-code/src/activity-updater.integration.test.ts b/packages/plugins/agent-claude-code/src/activity-updater.integration.test.ts new file mode 100644 index 000000000..56ee653d4 --- /dev/null +++ b/packages/plugins/agent-claude-code/src/activity-updater.integration.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { isWindows } from "@aoagents/ao-core"; +import { ACTIVITY_UPDATER_SCRIPT, ACTIVITY_UPDATER_SCRIPT_NODE } from "./index.js"; + +// --------------------------------------------------------------------------- +// Integration tests for the activity-updater hook script (#1941). +// Pipes synthetic Claude Code hook JSON payloads into the real script and +// asserts the JSONL line written to {workspace}/.ao/activity.jsonl matches. +// +// Both the bash variant (Unix) and the Node variant (Windows) are exercised +// against the same scenario table to keep them in lockstep. +// --------------------------------------------------------------------------- + +let scratchDir: string; +let bashScript: string; +let nodeScript: string; + +beforeAll(() => { + scratchDir = mkdtempSync(join(tmpdir(), "ao-activity-hook-")); + bashScript = join(scratchDir, "activity-updater.sh"); + nodeScript = join(scratchDir, "activity-updater.cjs"); + writeFileSync(bashScript, ACTIVITY_UPDATER_SCRIPT, { mode: 0o755 }); + writeFileSync(nodeScript, ACTIVITY_UPDATER_SCRIPT_NODE, "utf-8"); +}); + +afterAll(() => { + rmSync(scratchDir, { recursive: true, force: true }); +}); + +interface HookInput { + hook_event_name: string; + // Common-payload fields are optional in this synthetic shape; runtime payloads always have them. + session_id?: string; + cwd?: string; + notification_type?: string; + tool_name?: string; + error_type?: string; + error_message?: string; +} + +interface HookResult { + stdout: string; + lastEntry: Record | null; + rawJsonl: string; +} + +type Variant = "bash" | "node"; + +function runHook(variant: Variant, payload: HookInput): HookResult { + const workspace = mkdtempSync(join(scratchDir, "ws-")); + const input = JSON.stringify(payload); + let stdout: string; + try { + const cmd = variant === "bash" ? `bash "${bashScript}"` : `node "${nodeScript}"`; + stdout = execSync(cmd, { + input, + env: { ...process.env, CLAUDE_PROJECT_DIR: workspace }, + encoding: "utf-8", + timeout: 5000, + }); + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string }; + stdout = e.stdout ?? ""; + } + + const logFile = join(workspace, ".ao", "activity.jsonl"); + let rawJsonl = ""; + let lastEntry: Record | null = null; + if (existsSync(logFile)) { + rawJsonl = readFileSync(logFile, "utf-8"); + const lines = rawJsonl.split("\n").filter((l) => l.trim()); + if (lines.length > 0) { + try { + lastEntry = JSON.parse(lines[lines.length - 1]!) as Record; + } catch { + lastEntry = null; + } + } + } + return { stdout, lastEntry, rawJsonl }; +} + +// Each scenario is executed against both the bash and the Node variant so +// drift between the two implementations is caught immediately. The bash +// suite is skipped on Windows — bash isn't a native shell there, so +// `execSync('bash "..."')` would throw ENOENT for every case. The Node +// variant is the Windows-supported path (and is exercised on Unix too, +// guarding against drift). Matches the `describe.skipIf` pattern used by +// `packages/core/src/__tests__/migration-storage-v2.test.ts`. +const variants: Variant[] = ["bash", "node"]; + +for (const variant of variants) { + describe.skipIf(variant === "bash" && isWindows())(`activity-updater (${variant})`, () => { + // ----------------------------------------------------------------------- + // active states — turn-in-progress markers + // ----------------------------------------------------------------------- + it.each([ + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PreCompact", + "PostCompact", + "SubagentStart", + "PostToolBatch", + ])("writes active for %s", (event) => { + const { lastEntry } = runHook(variant, { hook_event_name: event }); + expect(lastEntry).not.toBeNull(); + expect(lastEntry!.state).toBe("active"); + expect(lastEntry!.source).toBe("hook"); + expect(lastEntry).not.toHaveProperty("trigger"); + }); + + // ----------------------------------------------------------------------- + // ready states — turn boundaries + // ----------------------------------------------------------------------- + it.each(["SessionStart", "Stop", "SubagentStop"])("writes ready for %s", (event) => { + const { lastEntry } = runHook(variant, { hook_event_name: event }); + expect(lastEntry!.state).toBe("ready"); + expect(lastEntry!.source).toBe("hook"); + }); + + // ----------------------------------------------------------------------- + // waiting_input — PermissionRequest is the authoritative signal + // ----------------------------------------------------------------------- + it("writes waiting_input for PermissionRequest with tool_name in trigger", () => { + const { lastEntry } = runHook(variant, { + hook_event_name: "PermissionRequest", + tool_name: "Bash", + }); + expect(lastEntry!.state).toBe("waiting_input"); + expect(lastEntry!.source).toBe("hook"); + expect(lastEntry!.trigger).toBe("PermissionRequest (Bash)"); + }); + + it("writes waiting_input for PermissionRequest without tool_name", () => { + const { lastEntry } = runHook(variant, { hook_event_name: "PermissionRequest" }); + expect(lastEntry!.state).toBe("waiting_input"); + expect(lastEntry!.trigger).toBe("PermissionRequest"); + }); + + // ----------------------------------------------------------------------- + // Notification — MUST filter by notification_type so auth_success / + // elicitation_* don't false-fire waiting_input. + // ----------------------------------------------------------------------- + it("writes waiting_input for Notification(permission_prompt)", () => { + const { lastEntry } = runHook(variant, { + hook_event_name: "Notification", + notification_type: "permission_prompt", + }); + expect(lastEntry!.state).toBe("waiting_input"); + expect(lastEntry!.trigger).toBe("Notification (permission_prompt)"); + }); + + it("writes waiting_input for Notification(idle_prompt)", () => { + const { lastEntry } = runHook(variant, { + hook_event_name: "Notification", + notification_type: "idle_prompt", + }); + expect(lastEntry!.state).toBe("waiting_input"); + expect(lastEntry!.trigger).toBe("Notification (idle_prompt)"); + }); + + it("skips Notification(auth_success) — not a stuck-on-the-user state", () => { + const { lastEntry, stdout } = runHook(variant, { + hook_event_name: "Notification", + notification_type: "auth_success", + }); + expect(lastEntry).toBeNull(); + expect(stdout.trim()).toBe("{}"); + }); + + it("skips Notification(elicitation_response)", () => { + const { lastEntry } = runHook(variant, { + hook_event_name: "Notification", + notification_type: "elicitation_response", + }); + expect(lastEntry).toBeNull(); + }); + + // ----------------------------------------------------------------------- + // blocked — StopFailure is the authoritative API-error signal + // ----------------------------------------------------------------------- + it("writes blocked for StopFailure with error_type in trigger", () => { + const { lastEntry } = runHook(variant, { + hook_event_name: "StopFailure", + error_type: "rate_limit", + error_message: "Rate limited", + }); + expect(lastEntry!.state).toBe("blocked"); + expect(lastEntry!.source).toBe("hook"); + expect(lastEntry!.trigger).toBe("StopFailure (rate_limit)"); + }); + + it("writes blocked for StopFailure without error_type", () => { + const { lastEntry } = runHook(variant, { hook_event_name: "StopFailure" }); + expect(lastEntry!.state).toBe("blocked"); + expect(lastEntry!.trigger).toBe("StopFailure"); + }); + + // ----------------------------------------------------------------------- + // No-ops — unknown event names + ignored events + // ----------------------------------------------------------------------- + it.each(["SessionEnd", "TaskCreated", "FileChanged", "UnknownFutureEvent"])( + "ignores unhandled event %s", + (event) => { + const { lastEntry, stdout } = runHook(variant, { hook_event_name: event }); + expect(lastEntry).toBeNull(); + expect(stdout.trim()).toBe("{}"); + }, + ); + + it("returns {} stdout so Claude doesn't surface a hook decision", () => { + const { stdout } = runHook(variant, { hook_event_name: "Stop" }); + expect(stdout.trim()).toBe("{}"); + }); + + it("creates .ao/ directory on first write", () => { + const { rawJsonl } = runHook(variant, { hook_event_name: "Stop" }); + expect(rawJsonl.length).toBeGreaterThan(0); + }); + + it("emits a parseable JSON line with a valid ISO timestamp", () => { + const { lastEntry } = runHook(variant, { hook_event_name: "Stop" }); + expect(lastEntry).not.toBeNull(); + const ts = lastEntry!.ts as string; + expect(typeof ts).toBe("string"); + // ISO 8601 with millisecond precision and Z suffix + expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/); + }); + + it("escapes control chars in trigger so the JSONL line stays parseable", () => { + // Bounded by Claude's enums today (`error_type`, `tool_name` never contain + // \n/\r/\t), but if a future hook payload field flows into `trigger` + // unescaped, a literal newline would split one entry into two and break + // the JSONL parser. This locks in JSON-style escaping across both + // bash and Node variants. + const { lastEntry, rawJsonl } = runHook(variant, { + hook_event_name: "StopFailure", + // Smuggle a multi-line value through error_type — covers \n/\t/\r/\\/". + error_type: 'multi\nline\twith\rmixed\\\\and"quotes', + }); + // Exactly one JSONL line (control chars escaped, not literal) + expect(rawJsonl.split("\n").filter((l) => l.trim())).toHaveLength(1); + // Parsed entry round-trips the original string + expect(lastEntry!.state).toBe("blocked"); + expect(lastEntry!.trigger).toBe( + 'StopFailure (multi\nline\twith\rmixed\\\\and"quotes)', + ); + }); + }); +} diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index be8955c55..8b65084b3 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -78,6 +78,8 @@ import { toClaudeProjectPath, METADATA_UPDATER_SCRIPT, METADATA_UPDATER_SCRIPT_NODE, + ACTIVITY_UPDATER_SCRIPT, + ACTIVITY_UPDATER_SCRIPT_NODE, } from "./index.js"; // --------------------------------------------------------------------------- @@ -493,7 +495,18 @@ describe("isProcessRunning", () => { // ========================================================================= // detectActivity — terminal output classification // ========================================================================= -describe("detectActivity", () => { +describe("detectActivity (retired — see #1941)", () => { + // Claude activity is derived from platform-event hooks (PermissionRequest, + // StopFailure, Notification, Stop, ...) which write directly to + // .ao/activity.jsonl with source: "hook". The terminal-regex layer was + // structurally fragile (every Claude UI tweak regressed it; #1932 spent + // 15 commits patching its sharpest edges) and has been retired. + // + // The `detectActivity` method is kept on the Agent interface for other + // plugins (Aider, OpenCode, Codex fallback) but is a stable no-signal + // stub for Claude — returns "idle" for every input so the lifecycle + // manager's terminal-output path stays neutral and the JSONL-backed + // cascade is the only source of truth for active/ready/waiting_input/blocked. const agent = create(); it("returns idle for empty terminal output", () => { @@ -504,210 +517,20 @@ describe("detectActivity", () => { expect(agent.detectActivity(" \n \n ")).toBe("idle"); }); - it("returns active when 'esc to interrupt' is visible", () => { - expect(agent.detectActivity("Working... esc to interrupt\n")).toBe("active"); - }); - - it("returns active when Thinking indicator is visible", () => { - expect(agent.detectActivity("Thinking...\n")).toBe("active"); - }); - - it("returns active when Reading indicator is visible", () => { - expect(agent.detectActivity("Reading file src/index.ts\n")).toBe("active"); - }); - - it("returns active when Writing indicator is visible", () => { - expect(agent.detectActivity("Writing to src/main.ts\n")).toBe("active"); - }); - - it("returns active when Searching indicator is visible", () => { - expect(agent.detectActivity("Searching codebase...\n")).toBe("active"); - }); - - it("returns waiting_input for permission prompt (Y/N)", () => { - expect(agent.detectActivity("Do you want to proceed? (Y)es / (N)o\n")).toBe("waiting_input"); - }); - - it("returns waiting_input for 'Do you want to proceed?' prompt", () => { - expect(agent.detectActivity("Do you want to proceed?\n")).toBe("waiting_input"); - }); - - it("returns waiting_input for bypass permissions prompt", () => { - expect(agent.detectActivity("bypass all future permissions for this session\n")).toBe( - "waiting_input", - ); - }); - - it("does NOT match Claude's persistent UI footer 'bypass permissions on (shift+tab to cycle)'", () => { - // Regression test: the old `/bypass.*permissions/i` regex matched this - // footer toggle (visible on EVERY Claude session) and falsely fired - // waiting_input for every session that fell through to the AO JSONL - // pipeline. ao-143/144/151 all flipped to waiting_input on dormant - // sessions until this was tightened to require "all future". - const footerOnly = [ - "✻ Crunched for 11s", - "", - "──────────────────────────────────────────────────────────", - "❯ ", - "──────────────────────────────────────────────────────────", - " ⏵⏵ bypass permissions on (shift+tab to cycle)", - ].join("\n"); - expect(agent.detectActivity(footerOnly)).not.toBe("waiting_input"); - }); - - it("returns active when queued message indicator is visible", () => { - expect(agent.detectActivity("Press up to edit queued messages\n")).toBe("active"); - }); - - it("returns idle when shell prompt is visible", () => { - expect(agent.detectActivity("some output\n> ")).toBe("idle"); - expect(agent.detectActivity("some output\n$ ")).toBe("idle"); - }); - - it("returns idle when prompt follows historical activity indicators", () => { - // Key regression test: historical "Reading file..." output in the buffer - // should NOT override an idle prompt on the last line. - expect(agent.detectActivity("Reading file src/index.ts\nWriting to out.ts\n❯ ")).toBe("idle"); - expect(agent.detectActivity("Thinking...\nSearching codebase...\n$ ")).toBe("idle"); - }); - - it("returns waiting_input when permission prompt follows historical activity", () => { - // Permission prompt at the bottom should NOT be overridden by historical - // "Reading"/"Thinking" output higher in the buffer. - expect( - agent.detectActivity("Reading file src/index.ts\nThinking...\nDo you want to proceed?\n"), - ).toBe("waiting_input"); - expect(agent.detectActivity("Searching codebase...\n(Y)es / (N)o\n")).toBe("waiting_input"); - expect( - agent.detectActivity("Writing to out.ts\nbypass all future permissions for this session\n"), - ).toBe("waiting_input"); - }); - - it("returns idle for non-empty output with no active-work indicators", () => { - // Default-to-idle (changed from default-to-active in this PR). Claude's - // tmux pane has a persistent input area + footer that looks identical - // between "just finished" and "currently working". Treating - // unrecognized output as active caused dormant sessions to get an - // "active" written to AO activity-JSONL every poll cycle, which the - // age-decayed fallback then surfaced as ready forever (ao-160 repro). - expect(agent.detectActivity("some random terminal output\n")).toBe("idle"); - }); - - it("returns idle for dormant session showing only Claude's input area + footer", () => { - // Real captured output from a dormant session (ao-143 style): assistant - // output above, separator, empty prompt line, separator, footer toggle. - // The empty prompt ❯ is NOT the LAST line (footer is) so the existing - // lastLine check misses it, and previously the default-to-active sent - // every dormant session into the AO-JSONL active-loop. - const dormant = [ - "※ recap: working on issue #143; next: wait for review", - "", - "──────────────────────────────────────────────────────────", - "❯ ", - "──────────────────────────────────────────────────────────", - " ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt", - ].join("\n"); - expect(agent.detectActivity(dormant)).toBe("idle"); - }); - - it("returns active when spinner+ellipsis is in the tail (✻ Fluttering…)", () => { - // Real captured output from ao-161 mid-active-turn. The ✻ spinner - // followed by a verb and trailing ellipsis is the canonical Claude - // active indicator across all turn-status words (Germinating, - // Fluttering, Thinking, Pondering, etc). - const active = [ - "✻ Fluttering… (6m 49s · ↓ 26.9k tokens)", - " ⎿ Tip: Use /feedback to help us improve!", - "", - "──────", - "❯ ", - "──────", - " ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt", - ].join("\n"); - expect(agent.detectActivity(active)).toBe("active"); - }); - - it("returns idle for past-tense spinner status like '✻ Worked for 11s' (no ellipsis)", () => { - // Real captured output from ao-143 dormant. The ✻ glyph appears in - // past-tense turn summaries too — without the trailing ellipsis, - // Claude is done, not active. - const dormant = [ - "⏺ Posted: https://github.com/owner/repo/pull/1#comment-1", - "", - "✻ Worked for 11s", - "", - "※ recap: working on issue #143; next: wait for review", - "──────", - "❯ ", - "──────", - " ⏵⏵ bypass permissions on (shift+tab to cycle)", - ].join("\n"); - expect(agent.detectActivity(dormant)).toBe("idle"); - }); - - // Blocked detection from terminal regex — empirically captured from real - // Claude output during api.anthropic.com block (see PR #1932). - it("returns blocked for 'Unable to connect to API' error line", () => { - const real = [ - "❯ what is 2+2? answer in one word.", - " ⎿ Unable to connect to API (ConnectionRefused)", - " Retrying in 19s · attempt 7/10", - "", - "✽ Germinating… (56s)", - "", - ].join("\n"); - expect(agent.detectActivity(real)).toBe("blocked"); - }); - - it("returns blocked for FailedToOpenSocket error variant", () => { - expect( - agent.detectActivity(" ⎿ Unable to connect to API (FailedToOpenSocket)\n"), - ).toBe("blocked"); - }); - - it("returns blocked for retry counter alone (Retrying in Ns · attempt N/M)", () => { - // If only the retry line is in the visible window (error scrolled off), - // the retry counter is still a sufficient signal. - expect(agent.detectActivity(" Retrying in 30s · attempt 9/10\n")).toBe("blocked"); - }); - - it("does NOT return blocked when API error has scrolled out of the visible window after a successful retry", () => { - // Regression test: blocked detection must be bounded to the last 12 - // lines (wideTail), NOT the full terminalOutput buffer. Otherwise an - // api_error that scrolled off the visible area after a successful - // retry but stayed in scrollback would falsely return "blocked" - // forever (Greptile review on PR #1932). - const recoveredAndContinued = [ - " ⎿ Unable to connect to API (ConnectionRefused)", - " Retrying in 1s · attempt 1/10", - " ⎿ ✓ Connected, retry succeeded", - "", - "(many lines of work output below pushing the error off the visible area)", - ...Array.from({ length: 15 }, (_, i) => ` line ${i + 1} of subsequent work`), - "", - "✻ Fluttering… (2m 14s)", - " ⎿ Tip: Use /feedback to help us improve!", - "", - "──────", - "❯ ", - "──────", - " ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt", - ].join("\n"); - expect(agent.detectActivity(recoveredAndContinued)).toBe("active"); - }); - - it("blocked takes precedence over waiting_input when both 'bypass permissions' footer and api-error are present", () => { - // Claude's static UI footer always contains "bypass permissions on …", - // which the existing waiting_input regex matches. A real blocked state - // must win over that incidental match. - const real = [ - " ⎿ Unable to connect to API (ConnectionRefused)", - " Retrying in 1s · attempt 5/10", - "", - "────────────────────────────────────────", - " ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt", - ].join("\n"); - expect(agent.detectActivity(real)).toBe("blocked"); + it.each([ + "Working... esc to interrupt\n", + "Thinking...\n", + "Reading file src/index.ts\n", + "Writing to src/main.ts\n", + "Searching codebase...\n", + "Do you want to proceed? (Y)es / (N)o\n", + "bypass all future permissions for this session\n", + " ⎿ Unable to connect to API (ConnectionRefused)\n", + " Retrying in 19s · attempt 7/10\n", + "✻ Fluttering… (6m 49s · ↓ 26.9k tokens)\n", + "some random terminal output\n", + ])("returns idle for ALL non-empty input (no terminal-regex active/waiting_input/blocked): %s", (input) => { + expect(agent.detectActivity(input)).toBe("idle"); }); }); @@ -1182,6 +1005,269 @@ describe("hook setup — relative path (symlink-safe)", () => { }); }); +// ========================================================================= +// setupWorkspaceHooks — activity-updater registration (#1941) +// ========================================================================= +describe("setupWorkspaceHooks — activity-updater (#1941)", () => { + const agent = create(); + + function getParsedSettings(): Record { + const settingsWrite = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"), + ); + expect(settingsWrite).toBeDefined(); + return JSON.parse(settingsWrite![1] as string) as Record; + } + + /** Activity-updater command paths (unix vs win32) */ + const ACTIVITY_CMD_UNIX = ".claude/activity-updater.sh"; + const ACTIVITY_CMD_WIN = "node .claude/activity-updater.cjs"; + + /** + * Every Claude Code hook event the script knows how to translate into an + * activity state. The dashboard / lifecycle reducer relies on these firing + * so platform events replace terminal-output regex. + */ + const ACTIVITY_EVENTS = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "Notification", + "PermissionRequest", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + ] as const; + + it("writes the activity-updater script to .claude/", async () => { + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const scriptWrite = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"), + ); + expect(scriptWrite).toBeDefined(); + expect(scriptWrite![1]).toBe(ACTIVITY_UPDATER_SCRIPT); + }); + + it("makes the activity-updater script executable on unix (chmod 0o755)", async () => { + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const chmodCall = mockChmod.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"), + ); + expect(chmodCall).toBeDefined(); + expect(chmodCall![1]).toBe(0o755); + }); + + it.each(ACTIVITY_EVENTS)( + "registers the activity-updater hook on %s", + async (event) => { + mockWriteFile.mockClear(); + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const settings = getParsedSettings(); + const hookGroup = (settings.hooks as Record)[event] as Array<{ + matcher: string; + hooks: Array<{ command: string; timeout?: number }>; + }>; + expect(hookGroup).toBeDefined(); + const activity = hookGroup.flatMap((g) => g.hooks).find((h) => h.command === ACTIVITY_CMD_UNIX); + expect(activity).toBeDefined(); + // The script does a single JSON parse + append — short timeout keeps a + // stuck hook from slowing the turn down. + expect(activity!.timeout).toBe(2000); + }, + ); + + it("registers activity-updater PostToolUse alongside metadata-updater", async () => { + mockWriteFile.mockClear(); + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const settings = getParsedSettings(); + const postToolUse = (settings.hooks as Record)["PostToolUse"] as Array<{ + matcher: string; + hooks: Array<{ command: string }>; + }>; + expect(postToolUse.length).toBeGreaterThanOrEqual(2); + + const metadataEntry = postToolUse.find((g) => + g.hooks.some((h) => h.command.includes("metadata-updater")), + ); + const activityEntry = postToolUse.find((g) => + g.hooks.some((h) => h.command.includes("activity-updater")), + ); + + expect(metadataEntry).toBeDefined(); + expect(metadataEntry!.matcher).toBe("Bash"); // unchanged from before #1941 + expect(activityEntry).toBeDefined(); + expect(activityEntry!.matcher).toBe(""); // fires on every PostToolUse, not just Bash + }); + + it("is idempotent — calling twice keeps exactly one activity-updater entry per event", async () => { + mockWriteFile.mockClear(); + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + const firstSettings = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"), + ); + mockExistsSync.mockReturnValueOnce(true); + mockReadFile.mockResolvedValueOnce(firstSettings![1] as string); + mockWriteFile.mockClear(); + + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const settings = getParsedSettings(); + for (const event of ACTIVITY_EVENTS) { + const hookGroup = (settings.hooks as Record)[event] as Array<{ + hooks: Array<{ command: string }>; + }>; + const activityHooks = hookGroup.flatMap((g) => g.hooks).filter( + (h) => h.command === ACTIVITY_CMD_UNIX, + ); + expect(activityHooks).toHaveLength(1); + } + }); + + it("preserves a user-installed Stop hook when adding our activity-updater", async () => { + const existingSettings = { + hooks: { + Stop: [ + { + matcher: "", + hooks: [{ type: "command", command: "echo user-hook", timeout: 1000 }], + }, + ], + }, + }; + mockExistsSync.mockReturnValueOnce(true); + mockReadFile.mockResolvedValueOnce(JSON.stringify(existingSettings)); + mockWriteFile.mockClear(); + + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const settings = getParsedSettings(); + const stopGroup = (settings.hooks as Record)["Stop"] as Array<{ + hooks: Array<{ command: string }>; + }>; + const commands = stopGroup.flatMap((g) => g.hooks).map((h) => h.command); + expect(commands).toContain("echo user-hook"); // user hook preserved + expect(commands).toContain(ACTIVITY_CMD_UNIX); // our hook added + }); + + it("tolerates malformed hooks. (object instead of array)", async () => { + // A user could hand-edit settings.json or an older plugin could have + // written a non-array shape there. We must not crash — start fresh. + const malformed = { + hooks: { + // Object where an array is expected + Stop: { matcher: "", command: "broken" }, + }, + }; + mockExistsSync.mockReturnValueOnce(true); + mockReadFile.mockResolvedValueOnce(JSON.stringify(malformed)); + mockWriteFile.mockClear(); + + await expect( + agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig), + ).resolves.not.toThrow(); + + const settings = getParsedSettings(); + const stopGroup = (settings.hooks as Record)["Stop"] as Array<{ + hooks: Array<{ command: string }>; + }>; + expect(Array.isArray(stopGroup)).toBe(true); + const commands = stopGroup.flatMap((g) => g.hooks).map((h) => h.command); + expect(commands).toContain(ACTIVITY_CMD_UNIX); + }); + + it("preserves matcher of an entry where user co-located their own def alongside ours", async () => { + // User has added their own hook def into the SAME { matcher, hooks: [...] } + // object that contains our activity-updater. If we naively reset + // entry.matcher to ours (""), the user's def starts firing on every + // PreToolUse event instead of only "Edit|Write". + const existingSettings = { + hooks: { + PreToolUse: [ + { + matcher: "Edit|Write", + hooks: [ + { type: "command", command: ".claude/activity-updater.sh", timeout: 2000 }, + { type: "command", command: "echo user-edits-only", timeout: 1000 }, + ], + }, + ], + }, + }; + mockExistsSync.mockReturnValueOnce(true); + mockReadFile.mockResolvedValueOnce(JSON.stringify(existingSettings)); + mockWriteFile.mockClear(); + + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const settings = getParsedSettings(); + const pre = (settings.hooks as Record)["PreToolUse"] as Array<{ + matcher: string; + hooks: Array<{ command: string }>; + }>; + const sharedEntry = pre.find((g) => + g.hooks.some((h) => h.command === "echo user-edits-only"), + ); + expect(sharedEntry).toBeDefined(); + // Matcher must NOT be overwritten — user's hook keeps firing on "Edit|Write" + expect(sharedEntry!.matcher).toBe("Edit|Write"); + // Both defs still present + expect(sharedEntry!.hooks.map((h) => h.command)).toEqual([ + ACTIVITY_CMD_UNIX, + "echo user-edits-only", + ]); + }); + + it("on Windows writes activity-updater.cjs (not .sh) and uses node invocation", async () => { + mockIsWindows.mockReturnValue(true); + mockWriteFile.mockClear(); + + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); + + const cjsWrite = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.cjs"), + ); + expect(cjsWrite).toBeDefined(); + expect(cjsWrite![1]).toBe(ACTIVITY_UPDATER_SCRIPT_NODE); + + const shWrite = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"), + ); + expect(shWrite).toBeUndefined(); + + const settings = getParsedSettings(); + const stopGroup = (settings.hooks as Record)["Stop"] as Array<{ + hooks: Array<{ command: string }>; + }>; + expect(stopGroup.flatMap((g) => g.hooks).some((h) => h.command === ACTIVITY_CMD_WIN)).toBe(true); + + mockIsWindows.mockReturnValue(false); + }); + + it("does not chmod on Windows (Windows uses extension for executability)", async () => { + mockIsWindows.mockReturnValue(true); + mockChmod.mockClear(); + + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); + + const chmodCalls = mockChmod.mock.calls.filter( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.cjs"), + ); + expect(chmodCalls).toHaveLength(0); + + mockIsWindows.mockReturnValue(false); + }); +}); + // ========================================================================= // setupWorkspaceHooks on win32 — Node.js hook script // ========================================================================= diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 29e5ce5e3..934fe3be2 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -2,7 +2,6 @@ import { shellEscape, normalizeAgentPermissionMode, isWindows, - recordTerminalActivity, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -385,6 +384,237 @@ process.stdout.write("{}\\n"); process.exit(0); `; +// ============================================================================= +// Activity Updater Hook Script +// ============================================================================= + +/** + * Bash hook script that translates Claude Code lifecycle hooks into AO activity + * JSONL entries. Registered on every event whose firing carries activity + * information (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, + * PermissionRequest, Notification, Stop, SubagentStop, StopFailure, PreCompact, + * PostCompact, SubagentStart, PostToolBatch). + * + * Reads the JSON payload from stdin, parses `hook_event_name`, maps it to an + * activity state, and appends a single JSONL entry to + * `$CLAUDE_PROJECT_DIR/.ao/activity.jsonl` with `source: "hook"`. + * + * Notification is filtered by `notification_type` — only `permission_prompt` + * and `idle_prompt` map to `waiting_input`; `auth_success`/`elicitation_*` etc. + * are skipped because they don't represent a stuck-on-the-user transition. + * + * The script always exits 0 (never blocks Claude). Unknown events exit + * silently. Exported for integration testing. + */ +export const ACTIVITY_UPDATER_SCRIPT = `#!/usr/bin/env bash +# Activity Updater Hook for Agent Orchestrator +# +# Records Claude Code lifecycle events to {workspace}/.ao/activity.jsonl so +# the dashboard / lifecycle reducer derives activity state from authoritative +# platform events instead of regex over rendered terminal output. (#1941) + +set -uo pipefail + +input=$(cat) + +if command -v jq &>/dev/null; then + event=$(printf '%s' "$input" | jq -r '.hook_event_name // empty') + notif_type=$(printf '%s' "$input" | jq -r '.notification_type // empty') + tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty') + error_type=$(printf '%s' "$input" | jq -r '.error_type // empty') +else + event=$(printf '%s' "$input" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4) + notif_type=$(printf '%s' "$input" | grep -o '"notification_type"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4) + tool_name=$(printf '%s' "$input" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4) + error_type=$(printf '%s' "$input" | grep -o '"error_type"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4) +fi + +state="" +trigger="" +case "$event" in + SessionStart|Stop|SubagentStop) + state="ready" + trigger="$event" + ;; + UserPromptSubmit|PreToolUse|PostToolUse|PostToolUseFailure|PreCompact|PostCompact|SubagentStart|PostToolBatch) + state="active" + trigger="$event" + ;; + PermissionRequest) + state="waiting_input" + if [[ -n "$tool_name" ]]; then + trigger="PermissionRequest ($tool_name)" + else + trigger="PermissionRequest" + fi + ;; + Notification) + if [[ "$notif_type" == "permission_prompt" || "$notif_type" == "idle_prompt" ]]; then + state="waiting_input" + trigger="Notification ($notif_type)" + else + # auth_success / elicitation_* / unrecognized — not an activity transition + echo '{}' + exit 0 + fi + ;; + StopFailure) + state="blocked" + if [[ -n "$error_type" ]]; then + trigger="StopFailure ($error_type)" + else + trigger="StopFailure" + fi + ;; + *) + echo '{}' + exit 0 + ;; +esac + +workspace="\${CLAUDE_PROJECT_DIR:-$(pwd)}" +log_dir="$workspace/.ao" +log_file="$log_dir/activity.jsonl" + +mkdir -p "$log_dir" 2>/dev/null || { echo '{}'; exit 0; } + +# Node is a hard runtime dep of Claude Code, so node -p is always available +# and gives millisecond-precision ISO timestamps matching the rest of the +# activity-JSONL log. Fall back to seconds-precision date for the unlikely +# case where node is unavailable (still valid ISO 8601). +ts=$(node -p 'new Date().toISOString()' 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Escape JSON-special characters in the trigger value. Triggers are bounded +# today to event/tool/error names (no control chars in practice) but escape +# defensively — \\ and " for content, plus the five common control chars +# (\\n \\r \\t \\b \\f) so the JSONL line stays parseable for any future +# trigger source. Matches what Node's JSON.stringify produces in the .cjs +# variant so both implementations stay in lockstep. +escape_json() { + local s="$1" + s="\${s//\\\\/\\\\\\\\}" + s="\${s//\\"/\\\\\\"}" + s="\${s//$'\\n'/\\\\n}" + s="\${s//$'\\r'/\\\\r}" + s="\${s//$'\\t'/\\\\t}" + s="\${s//$'\\b'/\\\\b}" + s="\${s//$'\\f'/\\\\f}" + printf '%s' "$s" +} + +if [[ "$state" == "waiting_input" || "$state" == "blocked" ]]; then + esc_trigger=$(escape_json "$trigger") + printf '{"ts":"%s","state":"%s","source":"hook","trigger":"%s"}\\n' "$ts" "$state" "$esc_trigger" >> "$log_file" +else + printf '{"ts":"%s","state":"%s","source":"hook"}\\n' "$ts" "$state" >> "$log_file" +fi + +echo '{}' +exit 0 +`; + +/** + * Node.js equivalent of ACTIVITY_UPDATER_SCRIPT for Windows. No bash, no jq, + * no shebang interpretation; relies only on Node built-ins. Exported for + * testing. + */ +export const ACTIVITY_UPDATER_SCRIPT_NODE = `#!/usr/bin/env node +// Activity Updater Hook for Agent Orchestrator (Node.js — Windows). See +// ACTIVITY_UPDATER_SCRIPT for the canonical bash version. (#1941) + +const { appendFileSync, mkdirSync, readFileSync } = require("node:fs"); +const { join } = require("node:path"); + +let inputRaw = ""; +try { + inputRaw = readFileSync(0, "utf-8"); +} catch { + process.stdout.write("{}\\n"); + process.exit(0); +} + +let payload; +try { + payload = JSON.parse(inputRaw || "{}"); +} catch { + process.stdout.write("{}\\n"); + process.exit(0); +} + +const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : ""; +const notifType = typeof payload.notification_type === "string" ? payload.notification_type : ""; +const toolName = typeof payload.tool_name === "string" ? payload.tool_name : ""; +const errorType = typeof payload.error_type === "string" ? payload.error_type : ""; + +let state = ""; +let trigger = ""; +switch (event) { + case "SessionStart": + case "Stop": + case "SubagentStop": + state = "ready"; + trigger = event; + break; + case "UserPromptSubmit": + case "PreToolUse": + case "PostToolUse": + case "PostToolUseFailure": + case "PreCompact": + case "PostCompact": + case "SubagentStart": + case "PostToolBatch": + state = "active"; + trigger = event; + break; + case "PermissionRequest": + state = "waiting_input"; + trigger = toolName ? \`PermissionRequest (\${toolName})\` : "PermissionRequest"; + break; + case "Notification": + if (notifType === "permission_prompt" || notifType === "idle_prompt") { + state = "waiting_input"; + trigger = \`Notification (\${notifType})\`; + } else { + process.stdout.write("{}\\n"); + process.exit(0); + } + break; + case "StopFailure": + state = "blocked"; + trigger = errorType ? \`StopFailure (\${errorType})\` : "StopFailure"; + break; + default: + process.stdout.write("{}\\n"); + process.exit(0); +} + +const workspace = process.env.CLAUDE_PROJECT_DIR || process.cwd(); +const logDir = join(workspace, ".ao"); +const logFile = join(logDir, "activity.jsonl"); + +try { + mkdirSync(logDir, { recursive: true }); +} catch { + process.stdout.write("{}\\n"); + process.exit(0); +} + +const ts = new Date().toISOString(); +const entry = + state === "waiting_input" || state === "blocked" + ? { ts, state, source: "hook", trigger } + : { ts, state, source: "hook" }; + +try { + appendFileSync(logFile, JSON.stringify(entry) + "\\n", "utf-8"); +} catch { + // Best-effort — never block Claude on log append failure +} + +process.stdout.write("{}\\n"); +process.exit(0); +`; + // ============================================================================= // Plugin Manifest // ============================================================================= @@ -567,41 +797,188 @@ function extractCost(lines: JsonlLine[]): CostEstimate | undefined { // ============================================================================= /** - * Shared helper to setup PostToolUse hooks in a workspace. - * Writes metadata-updater.sh script and updates settings.json. + * Single hook registration: which event, which variant (matcher), which + * command to invoke, and a substring used to find-and-update an existing + * entry so repeated setup calls are idempotent. + */ +interface HookRegistration { + event: string; + matcher: string; + command: string; + timeout: number; + /** Substring(s) of `command` that identify a pre-existing entry to update. */ + identifiers: ReadonlyArray; +} + +/** + * Set the registration's hook in the `event`'s hook array, updating any + * existing entry whose command contains one of `identifiers` (idempotent). * - * @param workspacePath - Path to the workspace directory - * @param hookCommand - Command string for the hook (can use variables like $CLAUDE_PROJECT_DIR) + * Tolerates malformed pre-existing settings: if `hooks[event]` is not an + * array (object, string, missing) we start a fresh array rather than + * throwing on `.push`. + * + * Only refreshes the entry-level `matcher` when the entry contains a single + * hook def (ours). When a user has co-located their own hook def in the + * same `{ matcher, hooks: [...] }` object, we leave their matcher alone and + * only update our def's `command`/`timeout` so their hook keeps firing on + * the matchers they chose. + */ +function upsertHookEntry( + hooks: Record, + reg: HookRegistration, +): void { + const existing = hooks[reg.event]; + const entries: Array = Array.isArray(existing) ? existing : []; + + let foundEntryIdx = -1; + let foundDefIdx = -1; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue; + const hooksList = (entry as Record)["hooks"]; + if (!Array.isArray(hooksList)) continue; + for (let j = 0; j < hooksList.length; j++) { + const def = hooksList[j]; + if (typeof def !== "object" || def === null || Array.isArray(def)) continue; + const cmd = (def as Record)["command"]; + if (typeof cmd === "string" && reg.identifiers.some((id) => cmd.includes(id))) { + foundEntryIdx = i; + foundDefIdx = j; + break; + } + } + if (foundEntryIdx >= 0) break; + } + + if (foundEntryIdx === -1) { + entries.push({ + matcher: reg.matcher, + hooks: [{ type: "command", command: reg.command, timeout: reg.timeout }], + }); + } else { + const entry = entries[foundEntryIdx] as Record; + const hooksList = entry["hooks"] as Array>; + hooksList[foundDefIdx]!["command"] = reg.command; + hooksList[foundDefIdx]!["timeout"] = reg.timeout; + // Only refresh the matcher when the entry is clearly owned by AO + // (single hook def == ours). With multiple defs the entry is shared + // with a user hook; changing the matcher would change when their hook + // fires. + if (hooksList.length === 1) { + entry["matcher"] = reg.matcher; + } + } + + hooks[reg.event] = entries; +} + +/** + * Build the list of hooks to register for this workspace. Two scripts are + * installed: + * - metadata-updater: PostToolUse(Bash) only — extracts gh/git side-effects. + * - activity-updater: every event that carries activity information, so + * dashboard / lifecycle reducer state derives from platform events + * instead of regex over rendered terminal output (#1941). + * + * Activity events use matcher "" — match every variant. PermissionRequest's + * tool-name and Notification's notification_type are filtered inside the + * script itself so the registered set stays small. + */ +function buildHookRegistrations( + metadataCommand: string, + activityCommand: string, +): HookRegistration[] { + const METADATA_IDS = [ + "metadata-updater.sh", + "metadata-updater.cjs", + "metadata-updater.js", + ] as const; + const ACTIVITY_IDS = ["activity-updater.sh", "activity-updater.cjs"] as const; + + const regs: HookRegistration[] = [ + { + event: "PostToolUse", + matcher: "Bash", + command: metadataCommand, + timeout: 5000, + identifiers: METADATA_IDS, + }, + ]; + + // Activity-updater events. Every event that the activity-updater script + // knows how to map (see ACTIVITY_UPDATER_SCRIPT) must be registered here; + // unregistered events fire no hook, so unrecognized hooks waste no time. + const activityEvents = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "Notification", + "PermissionRequest", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + ]; + for (const event of activityEvents) { + regs.push({ + event, + matcher: "", + command: activityCommand, + // Hook execution is best-effort and the activity-updater is intentionally + // O(few ms): JSON parse, one append, exit. A short timeout keeps a stuck + // hook from slowing a turn down. + timeout: 2000, + identifiers: ACTIVITY_IDS, + }); + } + + return regs; +} + +/** + * Install Claude Code workspace hooks. Writes both helper scripts + * (metadata-updater + activity-updater) and merges hook registrations into + * `.claude/settings.json` — preserving any user-installed hooks, updating our + * own in place on repeated calls. */ async function setupHookInWorkspace(workspacePath: string): Promise { const claudeDir = join(workspacePath, ".claude"); const settingsPath = join(claudeDir, "settings.json"); - // Create .claude directory if it doesn't exist try { await mkdir(claudeDir, { recursive: true }); } catch { - // Directory might already exist + // Directory may already exist; ignore } - // On Windows: write a Node.js hook script, skip chmod (not needed). - // On Unix: write the bash hook script and make it executable. - let hookCommand: string; + let metadataCommand: string; + let activityCommand: string; if (isWindows()) { - const hookScriptPath = join(claudeDir, "metadata-updater.cjs"); - await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT_NODE, "utf-8"); - // No chmod — Windows uses file extension for executability - // Use `node` to invoke the script (Windows won't run .js via shebang) - // Use .cjs extension to force CJS mode regardless of workspace package.json "type" field - hookCommand = "node .claude/metadata-updater.cjs"; + const metadataPath = join(claudeDir, "metadata-updater.cjs"); + const activityPath = join(claudeDir, "activity-updater.cjs"); + await writeFile(metadataPath, METADATA_UPDATER_SCRIPT_NODE, "utf-8"); + await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT_NODE, "utf-8"); + // .cjs forces CJS regardless of workspace package.json "type"; node + // invocation is required on Windows because shebangs aren't honoured. + metadataCommand = "node .claude/metadata-updater.cjs"; + activityCommand = "node .claude/activity-updater.cjs"; } else { - const hookScriptPath = join(claudeDir, "metadata-updater.sh"); - await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8"); - await chmod(hookScriptPath, 0o755); // Make executable - hookCommand = ".claude/metadata-updater.sh"; + const metadataPath = join(claudeDir, "metadata-updater.sh"); + const activityPath = join(claudeDir, "activity-updater.sh"); + await writeFile(metadataPath, METADATA_UPDATER_SCRIPT, "utf-8"); + await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT, "utf-8"); + await chmod(metadataPath, 0o755); + await chmod(activityPath, 0o755); + metadataCommand = ".claude/metadata-updater.sh"; + activityCommand = ".claude/activity-updater.sh"; } - // Read existing settings if present let existingSettings: Record = {}; if (existsSync(settingsPath)) { try { @@ -612,61 +989,12 @@ async function setupHookInWorkspace(workspacePath: string): Promise { } } - // Merge hooks configuration const hooks = (existingSettings["hooks"] as Record) ?? {}; - const postToolUse = (hooks["PostToolUse"] as Array) ?? []; - - // Check if our hook is already configured - let hookIndex = -1; - let hookDefIndex = -1; - for (let i = 0; i < postToolUse.length; i++) { - const hook = postToolUse[i]; - if (typeof hook !== "object" || hook === null || Array.isArray(hook)) continue; - const h = hook as Record; - const hooksList = h["hooks"]; - if (!Array.isArray(hooksList)) continue; - for (let j = 0; j < hooksList.length; j++) { - const hDef = hooksList[j]; - if (typeof hDef !== "object" || hDef === null || Array.isArray(hDef)) continue; - const def = hDef as Record; - if ( - typeof def["command"] === "string" && - (def["command"].includes("metadata-updater.sh") || - def["command"].includes("metadata-updater.js") || - def["command"].includes("metadata-updater.cjs")) - ) { - hookIndex = i; - hookDefIndex = j; - break; - } - } - if (hookIndex >= 0) break; + for (const reg of buildHookRegistrations(metadataCommand, activityCommand)) { + upsertHookEntry(hooks, reg); } - - // Add or update our hook - if (hookIndex === -1) { - // No metadata hook exists, add it - postToolUse.push({ - matcher: "Bash", - hooks: [ - { - type: "command", - command: hookCommand, - timeout: 5000, - }, - ], - }); - } else { - // Hook exists, update the command - const hook = postToolUse[hookIndex] as Record; - const hooksList = hook["hooks"] as Array>; - hooksList[hookDefIndex]["command"] = hookCommand; - } - - hooks["PostToolUse"] = postToolUse; existingSettings["hooks"] = hooks; - // Write updated settings await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8"); } @@ -741,15 +1069,28 @@ function createClaudeCodeAgent(): Agent { }, detectActivity(terminalOutput: string): ActivityState { + // #1941: Claude activity is derived from platform-event hooks + // (PermissionRequest / StopFailure / Notification / Stop / ...) which + // write directly to {workspace}/.ao/activity.jsonl. The terminal-regex + // layer was structurally fragile (every UI tweak in Claude regressed + // it; see the 15-commit churn in #1932) so it has been retired in + // favour of those authoritative events. + // + // detectActivity is kept on the Agent interface for other plugins + // (Aider, OpenCode, Codex fallback) that still rely on terminal output. + // For Claude, classifyTerminalOutput is a stable "idle" stub — the + // lifecycle manager only consults this method when getActivityState + // returned null (no Claude process / no JSONL / no hook entry yet), + // and in that no-signal case "idle" is the correct conservative + // answer (we don't write it back to JSONL — recordActivity is also + // intentionally omitted for Claude). return classifyTerminalOutput(terminalOutput); }, - async recordActivity(session: Session, terminalOutput: string): Promise { - if (!session.workspacePath) return; - await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => - this.detectActivity(output), - ); - }, + // recordActivity is intentionally NOT implemented for the Claude agent + // (#1941). Hooks write activity entries directly via the activity-updater + // script, so polling-driven terminal-output classification would only add + // stale duplicates to .ao/activity.jsonl. async isProcessRunning(handle: RuntimeHandle): Promise { return isClaudeProcessAlive(handle);