Compare commits

..

2 Commits

Author SHA1 Message Date
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
6fc3175730 Merge claude-sdk-backend: v2.7.5 Claude SDK backend + clean-room PostgresSessionStore 2026-06-01 13:38:05 +00:00
14 changed files with 623 additions and 7 deletions

View File

@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.7.6-agent-status-normalize — 2026-06-01
The scoped half of `boocode_code_review_v2.md` §1 #10 — normalized external-agent status, surfaced from BooCoder's own dispatch observation (the heavier config-injection notify-hook, clean-room from superset's ELv2 `agent-setup`, is documented as the follow-on). The review's premise ("PTY agents have no status") had partly aged out — warm-ACP/opencode/SDK already carry working/done — so the real gap was that BooCoder never *published* a normalized per-`(chat,agent)` status (blocked-on-permission was invisible; crash/idle weren't pushed). Adds an `agent_status_updated` WS frame (`working|blocked|idle|error`, server+web parity) published from the dispatcher's turn boundaries across all four external paths (warm-acp/opencode/sdk/pty — `working` at start, `idle`/`error` at end) and the permission flow (`blocked` on request, `working` on resolve), best-effort so it never breaks a turn. A clean-room `normalizeAgentEvent` helper (superset's ~30-vendor-event → Start/blocked/Stop collapse, reimplemented with the event names as facts) ships now with 25 tests so the deferred notify-hook injection reuses it verbatim. The `AgentComposerBar` gains a normalized status dot (working=spinner, blocked=amber, idle=gray, error=red) distinct from the WS-liveness dot, fed by a `useAgentStatus` map `CoderPane` tracks per `(chat,agent)`. Built by two parallel agents (data plane + view plane) against 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-claude-sdk-sessionstore`; openspec `agent-status-normalize`.
## v2.7.5-claude-sdk-sessionstore — 2026-06-01 ## v2.7.5-claude-sdk-sessionstore — 2026-06-01
Lands the Claude Agent SDK direction (`boocode_code_review_v2.md` §1 #9, §6.2 "lean SDK") behind a flag. Adds `@anthropic-ai/claude-agent-sdk@0.3.159` (Commercial Terms — runtime dep, code reference-only) and builds a warm, resumable claude backend to supersede one-shot PTY dispatch — env-gated (`CLAUDE_SDK_BACKEND`, default off) so production claude stays on the unchanged PTY path until a host smoke. **Clean-room `PostgresSessionStore`** implements the SDK's real `SessionStore` type (`append`/`load`/`listSessions`/`delete`/`listSubkeys`) over a new `claude_session_entries` table — typechecked against the installed SDK type, 8 DB-integration tests. **`ClaudeSdkBackend`** (`implements AgentBackend`, mirroring warm-acp/opencode-server) drives one persistent `query()` per `(chat,'claude')` in streaming-input mode via a pushable async-iterable pump, with `sessionStore` + `resume` for cross-turn/cross-restart continuity, a pure `mapSdkMessage``AgentEvent` mapper, `session_id` captured from the `init` message, and `result.usage`/`total_cost_usd` accumulated onto `agent_sessions` (backend CHECK gains `'claude_sdk'`). Built against the REAL SDK 0.3.159 types after installing it — surfacing shapes a blind build would have missed (`SDKPartialAssistantMessage` is `type:'stream_event'` needing `includePartialMessages`; `SDKUserMessage.message` is `MessageParam`; the `SDKResultMessage` error arm). Also fixes a latent test-infra deadlock — three DB-integration suites applying the full schema in parallel under `DATABASE_URL` deadlocked, now serialized via `fileParallelism:false`. ~32 new tests (8 store + 10 mapper + 8 pushable + 6 routing); coder suite 269 passing default / 290 with DB; tsc clean against the SDK types; builds clean. **The live streaming pump + resume + an actual claude turn need a host smoke (`CLAUDE_SDK_BACKEND=1` + claude binary + ANTHROPIC auth) — cannot run from the dev container.** The zod peer-dep wants `^4` (workspace `3.25`) — watch at runtime. Builds on `v2.7.4-mistake-tracker-ledger`; openspec `claude-sdk-sessionstore`. Lands the Claude Agent SDK direction (`boocode_code_review_v2.md` §1 #9, §6.2 "lean SDK") behind a flag. Adds `@anthropic-ai/claude-agent-sdk@0.3.159` (Commercial Terms — runtime dep, code reference-only) and builds a warm, resumable claude backend to supersede one-shot PTY dispatch — env-gated (`CLAUDE_SDK_BACKEND`, default off) so production claude stays on the unchanged PTY path until a host smoke. **Clean-room `PostgresSessionStore`** implements the SDK's real `SessionStore` type (`append`/`load`/`listSessions`/`delete`/`listSubkeys`) over a new `claude_session_entries` table — typechecked against the installed SDK type, 8 DB-integration tests. **`ClaudeSdkBackend`** (`implements AgentBackend`, mirroring warm-acp/opencode-server) drives one persistent `query()` per `(chat,'claude')` in streaming-input mode via a pushable async-iterable pump, with `sessionStore` + `resume` for cross-turn/cross-restart continuity, a pure `mapSdkMessage``AgentEvent` mapper, `session_id` captured from the `init` message, and `result.usage`/`total_cost_usd` accumulated onto `agent_sessions` (backend CHECK gains `'claude_sdk'`). Built against the REAL SDK 0.3.159 types after installing it — surfacing shapes a blind build would have missed (`SDKPartialAssistantMessage` is `type:'stream_event'` needing `includePartialMessages`; `SDKUserMessage.message` is `MessageParam`; the `SDKResultMessage` error arm). Also fixes a latent test-infra deadlock — three DB-integration suites applying the full schema in parallel under `DATABASE_URL` deadlocked, now serialized via `fileParallelism:false`. ~32 new tests (8 store + 10 mapper + 8 pushable + 6 routing); coder suite 269 passing default / 290 with DB; tsc clean against the SDK types; builds clean. **The live streaming pump + resume + an actual claude turn need a host smoke (`CLAUDE_SDK_BACKEND=1` + claude binary + ANTHROPIC auth) — cannot run from the dev container.** The zod peer-dep wants `^4` (workspace `3.25`) — watch at runtime. Builds on `v2.7.4-mistake-tracker-ledger`; openspec `claude-sdk-sessionstore`.

View File

@@ -42,6 +42,7 @@ import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js
import { probeAgents } from './services/agent-probe.js'; import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js'; import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
import { setPermissionHooks } from './services/permission-waiter.js'; import { setPermissionHooks } from './services/permission-waiter.js';
import { publishAgentStatus } from './services/agent-status-publish.js';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
async function main() { async function main() {
@@ -82,6 +83,21 @@ async function main() {
// Broker: in-memory pub/sub for session + user channel streaming. // Broker: in-memory pub/sub for session + user channel streaming.
const broker = createBroker(app.log); const broker = createBroker(app.log);
// agent-status-normalize (#10): the permission hooks carry only taskId +
// sessionId, but the tasks row holds the (chat_id, agent) pair the status frame
// is keyed on. Resolve it best-effort so a blocked/working status accompanies
// every permission_requested/permission_resolved. Returns null when the task
// lacks a chat_id or agent (sessionless creators) — we simply skip the status.
const resolveChatAgent = async (
taskId: string,
): Promise<{ chatId: string; agent: string } | null> => {
const [row] = await sql<{ chat_id: string | null; agent: string | null }[]>`
SELECT chat_id, agent FROM tasks WHERE id = ${taskId}
`;
if (!row?.chat_id || !row.agent) return null;
return { chatId: row.chat_id, agent: row.agent };
};
setPermissionHooks({ setPermissionHooks({
onPrompt: async (prompt) => { onPrompt: async (prompt) => {
await sql` await sql`
@@ -96,6 +112,18 @@ async function main() {
...(prompt.input ? { input: prompt.input } : {}), ...(prompt.input ? { input: prompt.input } : {}),
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })), options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
} as WsFrame); } as WsFrame);
// #10: agent is blocked on a human decision.
const ca = await resolveChatAgent(prompt.taskId).catch(() => null);
if (ca) {
publishAgentStatus(
broker.publishFrame,
prompt.sessionId,
ca.chatId,
ca.agent,
'blocked',
'permission_request',
);
}
}, },
onResolved: async (taskId, sessionId) => { onResolved: async (taskId, sessionId) => {
await sql` await sql`
@@ -106,6 +134,18 @@ async function main() {
task_id: taskId, task_id: taskId,
session_id: sessionId, session_id: sessionId,
} as WsFrame); } as WsFrame);
// #10: human responded — agent resumes work.
const ca = await resolveChatAgent(taskId).catch(() => null);
if (ca) {
publishAgentStatus(
broker.publishFrame,
sessionId,
ca.chatId,
ca.agent,
'working',
'permission_resolved',
);
}
}, },
}); });

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { normalizeAgentEvent } from '../normalize-agent-status.js';
describe('normalizeAgentEvent', () => {
describe('working bucket', () => {
const cases = [
'SessionStart',
'UserPromptSubmit',
'UserPromptSubmitted',
'PostToolUse',
'PostToolUseFailure',
'BeforeAgent',
'AfterTool',
'task_started',
];
for (const name of cases) {
it(`maps ${name} → working`, () => {
expect(normalizeAgentEvent(name)).toBe('working');
});
}
});
describe('blocked bucket', () => {
const cases = [
'PreToolUse',
'Notification',
'PermissionRequest',
'exec_approval_request',
'apply_patch_approval_request',
'request_user_input',
];
for (const name of cases) {
it(`maps ${name} → blocked`, () => {
expect(normalizeAgentEvent(name)).toBe('blocked');
});
}
});
describe('done bucket', () => {
const cases = [
'Stop',
'AfterAgent',
'SessionEnd',
'task_complete',
'agent-turn-complete',
];
for (const name of cases) {
it(`maps ${name} → done`, () => {
expect(normalizeAgentEvent(name)).toBe('done');
});
}
});
describe('unknown / nullish → null', () => {
it('returns null for an unrecognized event', () => {
expect(normalizeAgentEvent('SomeRandomEvent')).toBeNull();
});
it('returns null for empty string', () => {
expect(normalizeAgentEvent('')).toBeNull();
});
it('returns null for undefined', () => {
expect(normalizeAgentEvent(undefined)).toBeNull();
});
});
describe('case- and separator-insensitive matching', () => {
it('matches snake_case spelling of a PascalCase event', () => {
expect(normalizeAgentEvent('session_start')).toBe('working');
expect(normalizeAgentEvent('post_tool_use')).toBe('working');
expect(normalizeAgentEvent('pre_tool_use')).toBe('blocked');
});
it('matches camelCase spelling', () => {
expect(normalizeAgentEvent('userPromptSubmitted')).toBe('working');
expect(normalizeAgentEvent('postToolUse')).toBe('working');
expect(normalizeAgentEvent('preToolUse')).toBe('blocked');
expect(normalizeAgentEvent('sessionEnd')).toBe('done');
});
it('matches arbitrary case', () => {
expect(normalizeAgentEvent('STOP')).toBe('done');
expect(normalizeAgentEvent('notification')).toBe('blocked');
});
});
});

View File

@@ -0,0 +1,55 @@
/**
* agent-status-publish (#10) — builds + publishes the `agent_status_updated`
* WS frame on the per-session channel (the same channel CoderPane subscribes to).
*
* Kept separate from normalize-agent-status.ts so that module stays a pure,
* broker-free helper (trivially unit-testable; reused by the config-injection
* follow-on). The frame contract is pinned in apps/server/src/types/ws-frames.ts
* (`AgentStatusUpdatedFrame`) and mirrored byte-identical in apps/web.
*/
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import type { AgentStatus } from './normalize-agent-status.js';
// The exact slice of Broker we need — accepting just the bound method keeps call
// sites flexible (pass `broker.publishFrame.bind(broker)` or, since the broker's
// publishFrame doesn't read `this`, `broker.publishFrame` directly).
type PublishFrame = Broker['publishFrame'];
/**
* Best-effort publish of a normalized agent status. The broker's publishFrame
* already fail-closes (validates + logs + drops on bad input, never throws), but
* we additionally swallow any unexpected error so a publish can NEVER break the
* turn it's reporting on.
*
* @param publishFrame the session channel publisher (broker.publishFrame)
* @param sessionId WS subscription channel (CoderPane subscribes per-session)
* @param chatId the (chat) half of the (chat, agent) status key
* @param agent the (agent) half of the key
* @param status normalized lifecycle status
* @param reason free-form discriminator (turn_start / turn_complete / …)
* @param at ISO timestamp; defaults to now
*/
export function publishAgentStatus(
publishFrame: PublishFrame,
sessionId: string,
chatId: string,
agent: string,
status: AgentStatus,
reason?: string,
at: string = new Date().toISOString(),
): void {
try {
const frame: WsFrame = {
type: 'agent_status_updated',
chat_id: chatId,
agent,
status,
...(reason ? { reason } : {}),
at,
};
publishFrame(sessionId, frame);
} catch {
// never let a status publish break the turn — best-effort only.
}
}

View File

@@ -20,6 +20,8 @@ import { ClaudeSdkBackend } from './backends/claude-sdk.js';
import { shouldUseWarmBackend } from './backends/warm-acp-routing.js'; import { shouldUseWarmBackend } from './backends/warm-acp-routing.js';
import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js'; import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js';
import type { AgentBackend, AgentEvent } from './agent-backend.js'; import type { AgentBackend, AgentEvent } from './agent-backend.js';
import { publishAgentStatus } from './agent-status-publish.js';
import type { AgentStatus } from './normalize-agent-status.js';
interface InferenceRunner { interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
@@ -66,6 +68,21 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
return task.session_id ?? `task:${task.id}`; return task.session_id ?? `task:${task.id}`;
} }
// agent-status-normalize (#10): publish a normalized per-(chat,agent) status on
// the session channel. Every external-agent path (warm-acp / opencode / claude-sdk /
// pty one-shot) reports `working` at turn start, `idle` on clean completion, and
// `error` on the failure path through this single helper so the four paths stay
// DRY and consistent. Best-effort — publishAgentStatus never throws.
function emitAgentStatus(
sessionId: string,
chatId: string,
agent: string,
status: AgentStatus,
reason: string,
): void {
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
}
async function poll(): Promise<void> { async function poll(): Promise<void> {
// `polling` serializes poll() execution itself (timer + NOTIFY can fire // `polling` serializes poll() execution itself (timer + NOTIFY can fire
// concurrently) so we never double-select a task. It does NOT serialize task // concurrently) so we never double-select a task. It does NOT serialize task
@@ -298,6 +315,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// Create an abort controller for this task // Create an abort controller for this task
const ac = new AbortController(); const ac = new AbortController();
// #10: hoisted above the try so the catch block can report `error` status with
// the (chat, agent) key. Empty until resolved below; guarded before use.
let sessionId = '';
let chatId = '';
try { try {
// Mark running // Mark running
await sql` await sql`
@@ -306,9 +328,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
let sessionId: string;
let chatId: string;
if (task.session_id) { if (task.session_id) {
sessionId = task.session_id; sessionId = task.session_id;
const chats = await sql<{ id: string }[]>` const chats = await sql<{ id: string }[]>`
@@ -384,6 +403,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
role: 'assistant', role: 'assistant',
} as WsFrame); } as WsFrame);
// #10: external-agent turn begins.
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
const manifestCommands = getManifestCommands(agent); const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) { if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands); setTaskCommands(taskId, manifestCommands);
@@ -558,6 +580,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)'); log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
// #10: external-agent turn completed cleanly.
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
clearTaskCommands(taskId); clearTaskCommands(taskId);
} catch (err) { } catch (err) {
@@ -570,6 +594,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId} WHERE id = ${taskId}
`.catch(() => {}); `.catch(() => {});
// #10: external-agent turn failed/crashed. chatId may be unbound if the throw
// preceded its assignment — guard so the status publish never masks the real
// error.
if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'failed');
// Best-effort cleanup // Best-effort cleanup
await cleanupWorktree(projectPath, taskId); await cleanupWorktree(projectPath, taskId);
clearTaskCommands(taskId); clearTaskCommands(taskId);
@@ -624,6 +653,10 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const ac = new AbortController(); const ac = new AbortController();
// #10: hoisted so the catch can report `error` with the (chat, agent) key.
let sessionId = '';
let chatId = '';
try { try {
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value // execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
// (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in // (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in
@@ -640,8 +673,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// it directly. Session-less creators (arena, MCP, new_task, generic // it directly. Session-less creators (arena, MCP, new_task, generic
// /api/tasks) leave it null; fall back to resolving/creating a real chat so // /api/tasks) leave it null; fall back to resolving/creating a real chat so
// ensureSession never receives a degenerate (null, agent) key. // ensureSession never receives a degenerate (null, agent) key.
let sessionId: string;
let chatId: string;
if (task.chat_id && task.session_id) { if (task.chat_id && task.session_id) {
sessionId = task.session_id; sessionId = task.session_id;
chatId = task.chat_id; chatId = task.chat_id;
@@ -714,6 +745,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
role: 'assistant', role: 'assistant',
} as WsFrame); } as WsFrame);
// #10: opencode-server turn begins.
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
const manifestCommands = getManifestCommands(agent); const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) { if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands); setTaskCommands(taskId, manifestCommands);
@@ -873,6 +907,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)'); log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
// #10: clean completion → idle; backend-reported failure → error.
emitAgentStatus(
sessionId,
chatId,
agent,
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
clearTaskCommands(taskId); clearTaskCommands(taskId);
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
@@ -882,6 +924,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)} SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId} WHERE id = ${taskId}
`.catch(() => {}); `.catch(() => {});
// #10: turn crashed.
if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
clearTaskCommands(taskId); clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn. // No worktree cleanup (persistent); backend stays warm for the next turn.
} }
@@ -982,6 +1026,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
role: 'assistant', role: 'assistant',
} as WsFrame); } as WsFrame);
// #10: warm-ACP turn begins.
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
const manifestCommands = getManifestCommands(agent); const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) { if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands); setTaskCommands(taskId, manifestCommands);
@@ -1123,6 +1170,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (warm ACP)'); log.info({ taskId, agent, finalState }, 'dispatcher: task finished (warm ACP)');
// #10: clean completion → idle; backend-reported failure → error.
emitAgentStatus(
sessionId,
chatId,
agent,
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
clearTaskCommands(taskId); clearTaskCommands(taskId);
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
@@ -1132,6 +1187,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)} SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId} WHERE id = ${taskId}
`.catch(() => {}); `.catch(() => {});
// #10: turn crashed.
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
clearTaskCommands(taskId); clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn. // No worktree cleanup (persistent); backend stays warm for the next turn.
} }
@@ -1224,6 +1281,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
role: 'assistant', role: 'assistant',
} as WsFrame); } as WsFrame);
// #10: claude-SDK turn begins.
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
const manifestCommands = getManifestCommands(agent); const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) { if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands); setTaskCommands(taskId, manifestCommands);
@@ -1364,6 +1424,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (claude SDK)'); log.info({ taskId, agent, finalState }, 'dispatcher: task finished (claude SDK)');
// #10: clean completion → idle; backend-reported failure → error.
emitAgentStatus(
sessionId,
chatId,
agent,
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
clearTaskCommands(taskId); clearTaskCommands(taskId);
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
@@ -1373,6 +1441,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)} SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId} WHERE id = ${taskId}
`.catch(() => {}); `.catch(() => {});
// #10: turn crashed.
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
clearTaskCommands(taskId); clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn. // No worktree cleanup (persistent); backend stays warm for the next turn.
} }

View File

@@ -0,0 +1,92 @@
/**
* normalize-agent-status (#10) — clean-room vendor-event → bucket mapping.
*
* Different coding agents (claude, opencode, codex/gemini, goose, qwen) emit
* lifecycle hook events under inconsistent names: PascalCase (`SessionStart`),
* snake_case (`session_start`), camelCase (`sessionStart`), and a handful of
* provider-specific approval events (`exec_approval_request`). This module
* collapses every known event name into one of three coarse signals:
*
* working — the agent is actively progressing a turn
* blocked — the agent is waiting on a human (permission / approval / question)
* done — the turn / session ended cleanly
*
* `null` is returned for anything unrecognized so callers can ignore noise.
*
* Built now for the scoped status-publish, but specifically shaped for reuse by
* the documented config-injection follow-on: a future notify-hook injected into
* each agent's native config will POST the RAW vendor event name to a BooCoder
* endpoint, which runs this helper to derive the normalized status. The names
* below are facts about each agent's hook surface — not copied vendor code.
*/
export type AgentStatus = 'working' | 'blocked' | 'idle' | 'error';
/** The coarse signal a raw vendor event collapses to. */
export type AgentEventBucket = 'working' | 'blocked' | 'done';
// Each bucket lists the canonical vendor event names. Lookup is
// case-insensitive AND separator-insensitive (snake_case / camelCase /
// PascalCase all fold to the same key), so we normalize the raw input the same
// way before matching rather than enumerating every spelling here.
const WORKING_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'UserPromptSubmitted',
'PostToolUse',
'PostToolUseFailure',
'BeforeAgent',
'AfterTool',
'task_started',
] as const;
const BLOCKED_EVENTS = [
'PreToolUse',
'Notification',
'PermissionRequest',
'exec_approval_request',
'apply_patch_approval_request',
'request_user_input',
] as const;
const DONE_EVENTS = [
'Stop',
'AfterAgent',
'SessionEnd',
'task_complete',
'agent-turn-complete',
] as const;
/**
* Fold a raw event name to a separator/case-insensitive key:
* strip every non-alphanumeric character and lowercase. So `post_tool_use`,
* `postToolUse`, `PostToolUse`, and `POST-TOOL-USE` all map to `posttooluse`.
*/
function foldKey(raw: string): string {
return raw.replace(/[^a-z0-9]/gi, '').toLowerCase();
}
function buildLookup(
groups: ReadonlyArray<readonly [AgentEventBucket, readonly string[]]>,
): Map<string, AgentEventBucket> {
const map = new Map<string, AgentEventBucket>();
for (const [bucket, names] of groups) {
for (const name of names) map.set(foldKey(name), bucket);
}
return map;
}
const EVENT_LOOKUP = buildLookup([
['working', WORKING_EVENTS],
['blocked', BLOCKED_EVENTS],
['done', DONE_EVENTS],
]);
/**
* Map a raw vendor hook-event name to its normalized bucket, or `null` when the
* name is unknown / undefined. Case- and separator-insensitive.
*/
export function normalizeAgentEvent(raw: string | undefined): AgentEventBucket | null {
if (!raw) return null;
return EVENT_LOOKUP.get(foldKey(raw)) ?? null;
}

View File

@@ -39,6 +39,12 @@ const ChatStatusValue = z.enum([
'error', 'error',
]); ]);
// agent-status-normalize (#10): normalized per-(chat,agent) lifecycle status for
// external coding agents (warm-acp / opencode / claude-sdk / pty). Distinct from
// ChatStatusValue (native-inference chat lifecycle) — published by BooCoder's
// dispatcher + permission flow on the per-session channel.
const AgentStatusValue = z.enum(['working', 'blocked', 'idle', 'error']);
const ErrorReasonValue = z.enum([ const ErrorReasonValue = z.enum([
'llm_provider_error', 'llm_provider_error',
'doom_loop', 'doom_loop',
@@ -301,6 +307,21 @@ export const AgentCommandsFrame = z.object({
commands: z.array(AgentCommandShape), commands: z.array(AgentCommandShape),
}); });
// agent-status-normalize (#10): published by BooCoder on the per-session channel
// when an external agent's normalized status changes (turn start/end, permission
// block/unblock). Keyed per (chat_id, agent); the frontend tracks the latest per
// pair and resets on chat switch. `reason` is a free-form discriminator
// (turn_start / turn_complete / failed / crashed / permission_request /
// permission_resolved).
export const AgentStatusUpdatedFrame = z.object({
type: z.literal('agent_status_updated'),
chat_id: Uuid,
agent: z.string().min(1),
status: AgentStatusValue,
reason: z.string().optional(),
at: IsoTimestamp,
});
// ---- discriminated union --------------------------------------------------- // ---- discriminated union ---------------------------------------------------
export const WsFrameSchema = z.discriminatedUnion('type', [ export const WsFrameSchema = z.discriminatedUnion('type', [
@@ -320,6 +341,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
PermissionRequestedFrame, PermissionRequestedFrame,
PermissionResolvedFrame, PermissionResolvedFrame,
AgentCommandsFrame, AgentCommandsFrame,
AgentStatusUpdatedFrame,
// per-user // per-user
ChatStatusFrame, ChatStatusFrame,
SessionUpdatedFrame, SessionUpdatedFrame,
@@ -361,6 +383,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'permission_requested', 'permission_requested',
'permission_resolved', 'permission_resolved',
'agent_commands', 'agent_commands',
'agent_status_updated',
'chat_status', 'chat_status',
'session_updated', 'session_updated',
'session_renamed', 'session_renamed',

View File

@@ -596,4 +596,16 @@ export type WsFrame =
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string } | { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
// v1.8.2: `reason` discriminates structured failures (the UI prefers it // v1.8.2: `reason` discriminates structured failures (the UI prefers it
// over `error` text when present). // over `error` text when present).
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason }; | { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason }
// agent-status-normalize (#10): BooCoder publishes a normalized per-(chat,agent)
// lifecycle status for external coding agents on the per-session channel. The
// CoderPane tracks the latest status per (chat_id, agent) and resets on chat
// switch; AgentComposerBar renders the dot (distinct from the WS-liveness dot).
| {
type: 'agent_status_updated';
chat_id: string;
agent: string;
status: 'working' | 'blocked' | 'idle' | 'error';
reason?: string;
at: string;
};

View File

@@ -39,6 +39,12 @@ const ChatStatusValue = z.enum([
'error', 'error',
]); ]);
// agent-status-normalize (#10): normalized per-(chat,agent) lifecycle status for
// external coding agents (warm-acp / opencode / claude-sdk / pty). Distinct from
// ChatStatusValue (native-inference chat lifecycle) — published by BooCoder's
// dispatcher + permission flow on the per-session channel.
const AgentStatusValue = z.enum(['working', 'blocked', 'idle', 'error']);
const ErrorReasonValue = z.enum([ const ErrorReasonValue = z.enum([
'llm_provider_error', 'llm_provider_error',
'doom_loop', 'doom_loop',
@@ -301,6 +307,21 @@ export const AgentCommandsFrame = z.object({
commands: z.array(AgentCommandShape), commands: z.array(AgentCommandShape),
}); });
// agent-status-normalize (#10): published by BooCoder on the per-session channel
// when an external agent's normalized status changes (turn start/end, permission
// block/unblock). Keyed per (chat_id, agent); the frontend tracks the latest per
// pair and resets on chat switch. `reason` is a free-form discriminator
// (turn_start / turn_complete / failed / crashed / permission_request /
// permission_resolved).
export const AgentStatusUpdatedFrame = z.object({
type: z.literal('agent_status_updated'),
chat_id: Uuid,
agent: z.string().min(1),
status: AgentStatusValue,
reason: z.string().optional(),
at: IsoTimestamp,
});
// ---- discriminated union --------------------------------------------------- // ---- discriminated union ---------------------------------------------------
export const WsFrameSchema = z.discriminatedUnion('type', [ export const WsFrameSchema = z.discriminatedUnion('type', [
@@ -320,6 +341,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
PermissionRequestedFrame, PermissionRequestedFrame,
PermissionResolvedFrame, PermissionResolvedFrame,
AgentCommandsFrame, AgentCommandsFrame,
AgentStatusUpdatedFrame,
// per-user // per-user
ChatStatusFrame, ChatStatusFrame,
SessionUpdatedFrame, SessionUpdatedFrame,
@@ -361,6 +383,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'permission_requested', 'permission_requested',
'permission_resolved', 'permission_resolved',
'agent_commands', 'agent_commands',
'agent_status_updated',
'chat_status', 'chat_status',
'session_updated', 'session_updated',
'session_renamed', 'session_renamed',

View File

@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'luci
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types'; import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot'; import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
import type { AgentStatusEntry } from '@/hooks/useAgentStatus';
import { providerIcon } from '@/components/coder/providerIcons'; import { providerIcon } from '@/components/coder/providerIcons';
import { useAgentSessions } from '@/hooks/useAgentSessions'; import { useAgentSessions } from '@/hooks/useAgentSessions';
import { import {
@@ -183,6 +184,11 @@ interface Props {
// True once the chat has at least one prior turn — gates the chip so it stays // True once the chat has at least one prior turn — gates the chip so it stays
// hidden on a brand-new chat. Defaults to false (no chip). // hidden on a brand-new chat. Defaults to false (no chip).
hasPriorTurn?: boolean; hasPriorTurn?: boolean;
// #10: normalized status (working|blocked|idle|error) for the active external
// agent in this chat, or null for native boocode / before any frame. Renders
// a status dot DISTINCT from the WS-liveness `connected` dot. Undefined for
// non-coder callers — no dot.
agentStatus?: AgentStatusEntry | null;
} }
// Condensed token count: 950 → "950", 12_400 → "12.4K", 3_200_000 → "3.2M". // Condensed token count: 950 → "950", 12_400 → "12.4K", 3_200_000 → "3.2M".
@@ -210,7 +216,42 @@ function relativeTime(iso: string | null): string {
return `${day}d ago`; return `${day}d ago`;
} }
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn }: Props) { // #10: normalized external-agent status dot. Mirrors StatusDot's visual
// language but on the four normalized buckets (working|blocked|idle|error),
// and is DISTINCT from the WS-liveness `connected` dot beside it:
// working — emerald spinning ring (subtle motion, like chat streaming)
// blocked — amber dot (matches the permission/blocked state colour)
// idle — gray dot
// error — red dot
function AgentStatusDot({ entry, agent }: { entry: AgentStatusEntry; agent: string }) {
const title =
`${agent}: ${entry.status}` + (entry.reason ? `${entry.reason}` : '');
if (entry.status === 'working') {
return (
<span
aria-label={`Agent status: working${entry.reason ? `${entry.reason}` : ''}`}
title={title}
className="inline-block w-3 h-3 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
/>
);
}
const bg =
entry.status === 'blocked' ? 'bg-amber-500'
: entry.status === 'error' ? 'bg-destructive'
: 'bg-muted-foreground/40';
return (
<span
aria-label={`Agent status: ${entry.status}${entry.reason ? `${entry.reason}` : ''}`}
title={title}
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', bg)}
/>
);
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn, agentStatus }: Props) {
const allEntries = useProviderSnapshot(projectPath); const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or // 5.5 — the composer picker only offers ENABLED providers that are ready (or
// still loading). Disabled (enabled:false) and unavailable/error providers are // still loading). Disabled (enabled:false) and unavailable/error providers are
@@ -434,6 +475,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
{/* Status dot + refresh as one right-aligned unit so the refresh button {/* Status dot + refresh as one right-aligned unit so the refresh button
stays on the top line instead of wrapping past the edge-pinned dot. */} stays on the top line instead of wrapping past the edge-pinned dot. */}
<div className="ml-auto flex items-center gap-1 shrink-0"> <div className="ml-auto flex items-center gap-1 shrink-0">
{/* #10: normalized agent status — only for an external agent with a
live status frame. Distinct from the WS-liveness dot that follows. */}
{agentStatus && value.provider !== 'boocode' && (
<AgentStatusDot entry={agentStatus} agent={value.provider} />
)}
{connected !== undefined && ( {connected !== undefined && (
<span <span
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')} className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}

View File

@@ -18,6 +18,7 @@ import { mergeWireToolCall } from '@/lib/coder-tools';
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList'; import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
import { providerIcon, providerLabel } from '@/components/coder/providerIcons'; import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
import { refreshAgentSessions } from '@/hooks/useAgentSessions'; import { refreshAgentSessions } from '@/hooks/useAgentSessions';
import { useAgentStatus, type AgentStatus, type AgentStatusEntry } from '@/hooks/useAgentStatus';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -80,6 +81,14 @@ interface WsHandlers {
onAssistantComplete?: () => void; onAssistantComplete?: () => void;
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void; onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
onConnectedChange?: (connected: boolean) => void; onConnectedChange?: (connected: boolean) => void;
// #10: normalized external-agent status (working|blocked|idle|error) for the
// (chat,agent) carried on the frame. CoderPane records it in a live map and
// feeds the active agent's status to AgentComposerBar's status dot.
onAgentStatus?: (
chatId: string,
agent: string,
entry: AgentStatusEntry,
) => void;
} }
type RawCoderMessage = { type RawCoderMessage = {
@@ -326,6 +335,19 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
description: c.description, description: c.description,
})), })),
); );
} else if (frame.type === 'agent_status_updated') {
// #10: { chat_id, agent, status, reason?, at }. The chat_id guard
// above already dropped cross-chat frames; record per (chat,agent).
const chatId = (frame.chat_id ?? scopedChatId) as string | undefined;
const agent = frame.agent as string | undefined;
const status = frame.status as AgentStatus | undefined;
if (chatId && agent && status) {
handlersRef.current.onAgentStatus?.(chatId, agent, {
status,
...(frame.reason ? { reason: frame.reason as string } : {}),
at: (frame.at as string) ?? new Date().toISOString(),
});
}
} }
} catch { } catch {
// ignore unparseable frames // ignore unparseable frames
@@ -642,6 +664,8 @@ export function CoderPane({
return groups; return groups;
}, [agentCommands, skillItems, agentConfig.provider]); }, [agentCommands, skillItems, agentConfig.provider]);
// #10: live normalized status per (chat,agent), reset on chat switch below.
const agentStatus = useAgentStatus();
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, { const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
onConnectedChange, onConnectedChange,
onPermissionRequested: (prompt) => { onPermissionRequested: (prompt) => {
@@ -661,7 +685,21 @@ export function CoderPane({
onAgentCommands: (_taskId, commands) => { onAgentCommands: (_taskId, commands) => {
setLiveTaskCommands(commands); setLiveTaskCommands(commands);
}, },
onAgentStatus: agentStatus.record,
}); });
// Clear any stale status for the previous chat when the pane switches chats so
// a lingering working/blocked dot never carries into the next conversation.
useEffect(() => {
return () => agentStatus.reset(chatId);
}, [chatId, agentStatus]);
// The active agent's normalized status for this chat. null for native boocode
// (no external status published) or before any frame arrives — gates the dot.
const currentAgentStatus: AgentStatusEntry | null =
agentConfig.provider && agentConfig.provider !== 'boocode'
? agentStatus.get(chatId, agentConfig.provider)
: null;
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId); const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId); const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
@@ -968,6 +1006,7 @@ export function CoderPane({
connected={connected} connected={connected}
sessionId={sessionId} sessionId={sessionId}
hasPriorTurn={hasPriorTurn} hasPriorTurn={hasPriorTurn}
agentStatus={currentAgentStatus}
/> />
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */} {/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">

View File

@@ -0,0 +1,62 @@
import { useCallback, useMemo, useState } from 'react';
// Normalized external-agent status (#10). Consumed from the
// `agent_status_updated` WS frame the coder backend publishes:
// { type: 'agent_status_updated'; chat_id; agent; status; reason?; at }
// BooCoder collapses ~30 vendor lifecycle events into these four buckets:
// working — turn in flight
// blocked — waiting on a permission / approval
// idle — clean completion
// error — crash / failure
export type AgentStatus = 'working' | 'blocked' | 'idle' | 'error';
export interface AgentStatusEntry {
status: AgentStatus;
reason?: string;
at: string;
}
const key = (chatId: string, agent: string): string => `${chatId}:${agent}`;
// Per-(chat,agent) live status map. The dot reflects the latest frame for the
// active agent in the current chat; entries are reset when the chat switches so
// a stale "working"/"blocked" from a previous chat never leaks into the next.
export function useAgentStatus() {
const [map, setMap] = useState<Record<string, AgentStatusEntry>>({});
const record = useCallback(
(chatId: string, agent: string, entry: AgentStatusEntry) => {
setMap((prev) => ({ ...prev, [key(chatId, agent)]: entry }));
},
[],
);
// Drop every entry for a chat (called on chat switch). No-op when nothing
// matches so it's safe to call unconditionally from an effect.
const reset = useCallback((chatId: string | undefined) => {
setMap((prev) => {
if (!chatId) return prev;
const prefix = `${chatId}:`;
let changed = false;
const next: Record<string, AgentStatusEntry> = {};
for (const [k, v] of Object.entries(prev)) {
if (k.startsWith(prefix)) {
changed = true;
continue;
}
next[k] = v;
}
return changed ? next : prev;
});
}, []);
const get = useCallback(
(chatId: string | undefined, agent: string | undefined): AgentStatusEntry | null => {
if (!chatId || !agent) return null;
return map[key(chatId, agent)] ?? null;
},
[map],
);
return useMemo(() => ({ record, reset, get }), [record, reset, get]);
}

View File

@@ -189,6 +189,12 @@ function applyFrame(state: State, frame: WsFrame): State {
// duplicating async work inside a synchronous reducer. // duplicating async work inside a synchronous reducer.
return state; return state;
} }
case 'agent_status_updated': {
// agent-status-normalize (#10): coder-only frame consumed by CoderPane's
// own WS handler, not BooChat's native message reducer. No-op here to keep
// TS exhaustiveness satisfied (native sessions never emit it).
return state;
}
} }
} }

View File

@@ -0,0 +1,61 @@
# 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)
```ts
{ 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`