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>
This commit is contained in:
2026-06-01 14:04:04 +00:00
parent 6fc3175730
commit 59cf082e06
14 changed files with 623 additions and 7 deletions

View File

@@ -42,6 +42,7 @@ import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js
import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
import { setPermissionHooks } from './services/permission-waiter.js';
import { publishAgentStatus } from './services/agent-status-publish.js';
import { homedir } from 'node:os';
async function main() {
@@ -82,6 +83,21 @@ async function main() {
// Broker: in-memory pub/sub for session + user channel streaming.
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({
onPrompt: async (prompt) => {
await sql`
@@ -96,6 +112,18 @@ async function main() {
...(prompt.input ? { input: prompt.input } : {}),
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
} 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) => {
await sql`
@@ -106,6 +134,18 @@ async function main() {
task_id: taskId,
session_id: sessionId,
} 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',
);
}
},
});