Files
boocode/openspec/changes/agent-status-normalize/proposal.md
indifferentketchup 59cf082e06 feat: normalized external-agent status (#10 scoped) (v2.7.6)
Scoped half of boocode_code_review_v2 §1 #10 — publish the agent status
BooCoder already observes (the config-injection notify-hook is the documented
follow-on, clean-room from superset ELv2).

- agent_status_updated WS frame (working|blocked|idle|error), server+web parity.
- Published from the dispatcher's turn boundaries (warm-acp/opencode/sdk/pty:
  working at start, idle/error at end) + the permission flow (blocked/working).
  Best-effort, never breaks a turn.
- Clean-room normalizeAgentEvent helper (superset's vendor-event -> Start/blocked
  /Stop collapse, event names as facts) + 25 tests — reused by the follow-on.
- AgentComposerBar status dot (distinct from the WS-liveness dot), tracked per
  (chat,agent) by a useAgentStatus map in CoderPane.

Built by 2 parallel agents vs a pinned frame contract. Server 545 + coder 294
tests passing (25 new); web tsc + builds clean; ws-frames parity green. Clears
the actionable review backlog (#1/#3/#4/#6-#12). Builds on v2.7.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:04:04 +00:00

4.0 KiB

Normalized external-agent status (#10, scoped)

Status: in progress (started 2026-06-01) Source: boocode_code_review_v2.md §1 #10, §5j (superset, Elastic License 2.0 — PATTERN-ONLY, clean-room; /opt/forks/superset/.../map-event-type.ts, notify-hook.template.sh, agent-setup/*). Decision (Sam, 2026-06-01): scoped status-publish now; config-injection notify-hook as a follow-on.

Why (corrected premise)

BooCoder already observes agent lifecycle (warm-acp/opencode/SDK backends know active/idle/crashed; the permission-waiter knows blocked) but never publishes a normalized per-(chat,agent) status to the UI — so blocked-on-permission is invisible and crash/idle aren't pushed proactively. The AgentComposerBar dot only shows WS liveness. This batch publishes the status BooCoder already knows; the heavier config-injection notify-hook (for out-of-band signals) is the documented follow-on.

State model (clean-room from superset's mapEventType)

Superset collapses ~30 vendor event names → 3 signals: Start (working), PermissionRequest (blocked), Stop (done). BooCoder adds idle (after done) + error (crash/fail). Normalized status: working | blocked | idle | error.

Pinned frame contract (server + web, byte-identical, parity-tested)

{ type: 'agent_status_updated', chat_id: Uuid, agent: string,
  status: 'working' | 'blocked' | 'idle' | 'error', reason?: string, at: IsoTimestamp }

Added to apps/server/src/types/ws-frames.ts AND apps/web/src/api/ws-frames.ts (the ws-frames parity test), plus the web WsFrame union in apps/web/src/api/types.ts. Published via the coder's broker.publishFrame (validated against the server WsFrameSchema).

Clean-room normalize helper (built now, reused by the injection follow-on)

apps/coder/src/services/normalize-agent-status.ts: normalizeAgentEvent(raw: string): 'working' | 'blocked' | 'done' | null — a clean-room reimplementation of the vendor-event-name → bucket mapping (the event names are facts about each agent's hooks: SessionStart/UserPromptSubmit/PostToolUse→working; PreToolUse/Notification/PermissionRequest/ exec_approval_request→blocked; Stop/session_end/task_complete→done). The scoped publish points use BooCoder's own already-normalized turn boundaries; this helper exists so the config-injection follow-on (which receives raw vendor event names POSTed from agent hooks) reuses it. Unit-tested.

Publish points (BooCoder's existing observation — no per-backend change)

  • Dispatcher (dispatcher.ts) turn boundaries, for every external-agent path (warm-acp/opencode/sdk/pty): working at turn start, idle on clean completion, error on failure.
  • Permission-waiter (permission-waiter.ts / the setPermissionHooks publish in index.ts): blocked when a permission is requested, back to working when resolved. A small publishAgentStatus(broker, chatId, agent, status, reason?) helper centralizes the frame.

Frontend

  • CoderPane.tsx tracks the latest agent_status_updated per (chat, agent) (a small live map; reset on chat switch).
  • AgentComposerBar.tsx renders a normalized status dot beside the existing session chip (reuse the StatusDot visual language: working=spinner/green, blocked=amber, idle=gray, error=red), distinct from the WS-liveness connected dot.

Follow-on (documented, not built): config-injection notify-hook

Clean-room re-derive superset's agent-setup: inject a notify hook into each agent's native config (claude ~/.claude/settings.json, opencode plugin, codex/gemini templates) that POSTs {agent, chat_id, eventType} to a new POST /api/coder/agent-status endpoint, which runs normalizeAgentEvent → publishes the SAME agent_status_updated frame. Reuses everything this batch builds. Catches out-of-band signals BooCoder's dispatch can't see.

Verify

  • pnpm -C apps/coder test (+ normalize-agent-status tests) + pnpm -C apps/server test (ws-frames parity)
  • pnpm -C apps/server build && pnpm -C apps/coder build; npx tsc -p apps/web/tsconfig.app.json --noEmit