agent-orchestrator/packages/plugins/agent-claude-code
Harshit Singh Bhandari 7d9b862e93
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>
2026-05-20 18:17:35 +05:30
..
src refactor(agent-claude-code): replace terminal-regex activity detection with hooks (closes #1941) (#1945) 2026-05-20 18:17:35 +05:30
CHANGELOG.md fix(agent-plugins,lifecycle): distinguish indeterminate probe from "not found" + bump ps timeout (closes #1838) (#1839) 2026-05-14 21:50:39 +05:30
package.json fix(agent-plugins,lifecycle): distinguish indeterminate probe from "not found" + bump ps timeout (closes #1838) (#1839) 2026-05-14 21:50:39 +05:30
tsconfig.json fix: scope node types to node packages 2026-04-13 18:25:21 +05:30