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:
Harshit Singh Bhandari 2026-05-20 18:17:35 +05:30 committed by GitHub
parent 45de80ce3f
commit 7d9b862e93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1091 additions and 387 deletions

View File

@ -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.

View File

@ -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 = {

View File

@ -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" ||

View File

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

View File

@ -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() });

View File

@ -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";
}

View File

@ -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)',
);
});
});
}

View File

@ -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
// =========================================================================

View File

@ -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);