refactor(agent-claude-code): replace terminal-regex activity detection with hooks (closes #1941) (#1945)
* feat(core): allow source: "hook" in ActivityLogEntry (#1941) First step of the activity-detection hook refactor: extend the AO activity-JSONL schema so platform-event hooks (Claude Code's PermissionRequest / Stop / StopFailure / Notification / ...) can write entries with explicit provenance, distinct from terminal-derived ("terminal") and agent-native-JSONL ("native") writes. No behaviour change yet — hook writers come in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent-claude-code): add activity-updater hook scripts (#1941) Adds bash + Node script source strings (`ACTIVITY_UPDATER_SCRIPT` / `ACTIVITY_UPDATER_SCRIPT_NODE`) that translate Claude Code lifecycle hooks into AO activity-JSONL entries with `source: "hook"`. Event mapping is intentional and verified against the live Claude Code hooks reference (code.claude.com/docs/en/hooks, not the older anthropic.com URL the RFC referenced): - SessionStart / Stop / SubagentStop → ready - UserPromptSubmit / PreToolUse / PostToolUse / PostToolUseFailure / PreCompact / PostCompact / SubagentStart / PostToolBatch → active - PermissionRequest → waiting_input - Notification(permission_prompt | idle_prompt) → waiting_input - Notification(auth_success | elicitation_*) → no-op (the RFC's blanket "Notification → waiting_input" would false-fire here) - StopFailure → blocked - everything else (SessionEnd, TaskCreated, ...) → no-op Event name comes from the stdin JSON payload's `hook_event_name` field — the RFC's proposed `$CLAUDE_HOOK_EVENT_NAME` env var does not exist in Claude Code. The script never blocks Claude (`exit 0` on every path, including parse failures and disk-full). Bash variant uses `node -p 'new Date().toISOString()'` for the timestamp because BSD date doesn't support `%3N`. Node is a hard runtime dep of Claude Code so this is always available. Plugin wiring + regex-layer removal come in subsequent commits — this commit only adds the scripts and their 52-test (bash × node) parity suite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent-claude-code): register activity-updater on every relevant hook (#1941) `setupWorkspaceHooks` now installs the activity-updater script alongside the existing metadata-updater and registers it on every Claude Code event that carries activity information: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, PermissionRequest, Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact The metadata-updater stays registered on PostToolUse(Bash) only — git/gh side-effect detection is unrelated to activity classification, and splitting them keeps each script tight. Implementation notes: - Hook registration is now a declarative table (`HookRegistration[]`) fed through a shared `upsertHookEntry` helper. Calling `setupWorkspaceHooks` twice updates our entries in place; any user-installed Stop/PreToolUse/... hook is preserved alongside ours. - Activity-updater hooks register with matcher "" — Claude Code's empty-string matcher fires on every variant of the event (e.g. every Notification regardless of `notification_type`). Variant filtering happens inside the script. - Timeout for activity-updater is 2000ms (vs metadata-updater's 5000ms) — the script does a single JSON parse + append. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(agent-claude-code): retire terminal-regex layer (#1941) `classifyTerminalOutput` was the source of the 15-commit churn in #1932: every Claude UI tweak (footer wording, status verb, spinner glyph) broke a heuristic and needed a tightening pattern. With Claude lifecycle hooks now writing authoritative state directly to `.ao/activity.jsonl` (`source: "hook"`), the regex layer is structurally obsolete. This commit removes the patterns and reduces `classifyTerminalOutput` to a stable `return "idle"` stub: - `recordActivity` is no longer implemented on the Claude agent — the hooks ARE the activity producer. Lifecycle manager guards `agent.recordActivity?` so this is a clean drop. - `detectActivity` is kept on the Agent interface (still required by Aider/OpenCode/Codex fallback) but on Claude is now a constant "idle" — the lifecycle's terminal-output fallback path therefore records a neutral signal and the JSONL cascade is the only source of truth for active/ready/waiting_input/blocked. - Native Claude JSONL handling and `NOISE_JSONL_TYPES` are unchanged — those operate on Claude's own session files, not on terminal pixels. Tests that exercised the retired heuristics (~200 LOC of regex-pattern assertions in `detectActivity` + `recordActivity` integration tests) are replaced with: - One it.each guarding that every previously-classified input now returns "idle" (locks in the no-signal contract). - A direct assertion that `agent.recordActivity` is undefined. - New tests that write hook-sourced JSONL entries (`source: "hook"`, trigger like "PermissionRequest (Bash)") and verify the cascade surfaces them correctly. Net: −200 LOC of heuristic, ditto of tests, zero regression risk because the cascade already accepted any `source` value as long as the entry parsed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(changeset): claude activity hooks (#1941) ao-core: minor (extends ActivityLogEntry.source / ActivitySignalSource with "hook" — new value, no consumer break). ao-plugin-agent-claude-code: minor (new activity-updater hook scripts, terminal-regex layer retired). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(agent-claude-code): address copilot review on #1945 (#1941) Four reviewer concerns, all valid: 1. `upsertHookEntry` assumed `hooks[event]` is always an array. A malformed/legacy settings.json with a non-array value there would crash on `.push`. Normalize via `Array.isArray(existing) ? existing : []` and start fresh on bad input. New test: malformed object instead of array round-trips cleanly with our entry added. 2. `upsertHookEntry` unconditionally overwrote `entry.matcher` on updates. If a user has co-located their own hook def in the same `{ matcher, hooks: [...] }` object as ours, resetting the matcher changes when the user's def fires. Now only refresh matcher when the entry contains a single hook def (ours). New test: user hook sharing an entry with us keeps its `Edit|Write` matcher across re-setup calls. 3. Claude agent's `detectActivity` comment claimed the lifecycle manager would "override" its result via the JSONL cascade. Not accurate — lifecycle calls `detectActivity` only when `getActivityState` returned null, and that path doesn't write to .ao/activity.jsonl. Reworded to describe the actual behaviour: `detectActivity` is the no-signal fallback when there's no JSONL and no hook entry yet, and "idle" is the conservative answer. 4. `classifyTerminalOutput`'s docstring promised that the Claude agent's `detectActivity` would delegate to it "rather than inlining `() => "idle"`", but `detectActivity` actually inlined `return "idle"`. Restored the delegation so the rationale matches the code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(agent-claude-code): skip bash activity-updater suite on Windows (#1941) Greptile review on #1945: `execSync('bash "..."')` throws ENOENT on Windows because bash isn't a native shell there, and the catch block leaves `lastEntry = null` — making all ~26 bash-variant cases fail on Windows CI. Skip the bash suite on Windows via `describe.skipIf(... isWindows())` (matches the convention used in packages/core/src/__tests__/migration-storage-v2.test.ts). The Node variant suite runs on every platform and is the canonical Windows path for the activity-updater anyway, so parity between the two implementations is still verified on Linux/macOS CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-claude-code): escape control chars in bash trigger output (#1941) i-trytoohard's PR review flagged that bash's escape_json only handles \ and " — not \n / \r / \t / \b / \f — creating asymmetry with the Node variant where JSON.stringify covers everything. Bounded today by Claude's event/tool/error-name enums never containing control chars, but adds latent risk if a future trigger source isn't equally clean. Five-line fix: extend escape_json with the five common JSON control-char escapes so both implementations stay in lockstep against any future trigger payload shape. Locks the parity with a new round-trip test that smuggles \n / \t / \r / \\ / " through error_type — confirms exactly one JSONL line is written (no literal newline splitting one entry into two) and the parsed trigger round-trips bit-for-bit on both bash and Node. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-claude-code): drop useless \$ escape in bash heredoc (#1941) Lint job on #1945 failed with five `no-useless-escape` errors after the escape-control-chars fix. The five new lines in escape_json wrote `\$'\\n'` inside the JS template literal, but `$` is only special in JS template literals when followed by `{` — outside of interpolation it needs no backslash. Bash output is byte-identical (still emits `$'\n'` for ANSI-C quoting), so the 54 round-trip tests stay green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
45de80ce3f
commit
7d9b862e93
|
|
@ -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.
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
const logPath = getActivityLogPath(workspacePath);
|
||||
|
|
@ -98,7 +98,7 @@ export async function readLastActivityEntry(
|
|||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
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" ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = { 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() });
|
||||
|
|
|
|||
|
|
@ -276,86 +276,27 @@ export async function isClaudeProcessAlive(handle: RuntimeHandle): Promise<Proce
|
|||
}
|
||||
|
||||
// =============================================================================
|
||||
// Terminal output classification
|
||||
// Terminal output classification — retired (#1941)
|
||||
// =============================================================================
|
||||
|
||||
/** Classify Claude Code's activity state from terminal output (pure, sync). */
|
||||
export function classifyTerminalOutput(terminalOutput: string): ActivityState {
|
||||
if (!terminalOutput.trim()) return "idle";
|
||||
|
||||
const lines = terminalOutput.trim().split("\n");
|
||||
const lastLine = lines[lines.length - 1]?.trim() ?? "";
|
||||
|
||||
// Empty prompt on the last line is unambiguously idle.
|
||||
if (/^[❯>$#]\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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
|
||||
} 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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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<string, unknown>)[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<string, unknown>)["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<string, unknown>)[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<string, unknown>)["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.<event> (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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>,
|
||||
reg: HookRegistration,
|
||||
): void {
|
||||
const existing = hooks[reg.event];
|
||||
const entries: Array<unknown> = 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<string, unknown>)["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<string, unknown>)["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<string, unknown>;
|
||||
const hooksList = entry["hooks"] as Array<Record<string, unknown>>;
|
||||
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<void> {
|
||||
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<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
|
|
@ -612,61 +989,12 @@ async function setupHookInWorkspace(workspacePath: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// Merge hooks configuration
|
||||
const hooks = (existingSettings["hooks"] as Record<string, unknown>) ?? {};
|
||||
const postToolUse = (hooks["PostToolUse"] as Array<unknown>) ?? [];
|
||||
|
||||
// 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const hooksList = hook["hooks"] as Array<Record<string, unknown>>;
|
||||
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<void> {
|
||||
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<ProcessProbeResult> {
|
||||
return isClaudeProcessAlive(handle);
|
||||
|
|
|
|||
Loading…
Reference in New Issue