From bd0519f3fbb6bc80573cfdbc5ed4238966204643 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sat, 29 Aug 2026 20:39:00 -0700 Subject: [PATCH 1/8] Keep terminal status and input prompt visible --- client/README.md | 6 +- client/src/client.mjs | 109 ++++++++++++++++++++++++++++-------- client/test/client.test.mjs | 84 +++++++++++++++++++++++++-- 3 files changed, 170 insertions(+), 29 deletions(-) diff --git a/client/README.md b/client/README.md index cd188a3..afc62cf 100644 --- a/client/README.md +++ b/client/README.md @@ -26,8 +26,10 @@ is open, the client maintains an authenticated WebSocket to receive conversation events, thread state, heartbeats, and bounded subagent status. A separate session WebSocket carries live orchestrator terminal output only while an execution is active. The interactive TTY reserves a small bottom pane for each -subagent's state, role, and current work; HTTP event replay repairs gaps after a -disconnect. +subagent's state, role, and current progress as a compact graph rooted at the +orchestrator. The stable `› ` input area remains available while agents work; +asynchronous output redraws it without discarding partially typed follow-up +text. HTTP event replay repairs gaps after a disconnect. The first command securely prompts for the password. For a non-interactive caller, provide the password on stdin. Do not put it in a command argument: diff --git a/client/src/client.mjs b/client/src/client.mjs index 0d889de..5dceb2b 100644 --- a/client/src/client.mjs +++ b/client/src/client.mjs @@ -177,7 +177,22 @@ export async function runInteractive({ let cursor = 0; let monitor = null; let threadConnection = null; - const agentPane = createAgentPane(stdout); + let promptActive = false; + let promptLabel = "› "; + const refreshPrompt = () => { + if (!promptActive) return; + if (typeof terminal.setPrompt !== "function" || typeof terminal.prompt !== "function") return; + terminal.setPrompt(promptLabel); + terminal.prompt(true); + }; + const interactiveOutput = { + write(value) { + if (promptActive && stdout.isTTY) stdout.write("\r\u001b[2K"); + stdout.write(value); + refreshPrompt(); + }, + }; + const agentPane = createAgentPane(stdout, { onDraw: refreshPrompt }); const listThreads = async () => { threads = (await client.request("/api/threads")).value.threads || []; @@ -202,7 +217,7 @@ export async function runInteractive({ const sequence = Number(event.sequence) || 0; if (sequence <= cursor) continue; cursor = sequence; - renderInteractiveEvent(stdout, event); + renderInteractiveEvent(interactiveOutput, event); } return events; }; @@ -238,7 +253,10 @@ export async function runInteractive({ createWebSocketImpl, onState: (state) => agentPane.setConnectionState(state), onThread: (thread) => { - if (current?.id === threadId) current = thread; + if (current?.id === threadId) { + current = thread; + agentPane.setThread(thread); + } }, onAgents: (agents) => agentPane.render(agents), onEvent: (event) => { @@ -246,7 +264,7 @@ export async function runInteractive({ const sequence = Number(event.sequence) || 0; if (sequence <= cursor) return; cursor = sequence; - renderInteractiveEvent(stdout, event); + renderInteractiveEvent(interactiveOutput, event); if (new Set(["assistant_message", "question", "session_interrupted"]).has(event.type)) { monitor?.controller.abort(); } @@ -263,7 +281,7 @@ export async function runInteractive({ const active = { sessionId, controller, promise: null }; monitor = active; active.promise = (async () => { - const stream = streamSessionTerminal({ client, sessionId, stdout, sleep, signal: controller.signal, createWebSocketImpl }); + const stream = streamSessionTerminal({ client, sessionId, stdout: interactiveOutput, sleep, signal: controller.signal, createWebSocketImpl }); try { while (!controller.signal.aborted) { const events = await replay(); @@ -286,6 +304,7 @@ export async function runInteractive({ await stopMonitor(); await stopThreadConnection(); current = response.value.thread; + agentPane.setThread(current); cursor = 0; stdout.write(`\nOpened ${current.id} [${current.state}] — ${current.repository}\n`); await replay({ all: true }); @@ -295,14 +314,20 @@ export async function runInteractive({ } }; - stdout.write("Multiagent terminal\n"); - stdout.write("Threads are durable conversations; execution session IDs are managed by the server.\n"); - stdout.write("Type /help for commands.\n"); + stdout.write("Multiagent — /help\n"); try { await listThreads(); if (initialThreadId) await openThread(initialThreadId); while (true) { - const line = String(await terminal.question(current ? `${current.id}> ` : "multiagent> ")).trim(); + promptLabel = current ? "› " : "multiagent> "; + promptActive = true; + let answer; + try { + answer = await terminal.question(promptLabel); + } finally { + promptActive = false; + } + const line = String(answer).trim(); if (!line) continue; try { if (line === "/quit" || line === "/exit") return 0; @@ -332,6 +357,7 @@ export async function runInteractive({ await stopMonitor(); await stopThreadConnection(); current = created.value.thread; + agentPane.setThread(current); cursor = 0; startThreadConnection(current.id); await listThreads(); @@ -349,11 +375,14 @@ export async function runInteractive({ const sequence = Number(routed.event.sequence) || 0; if (sequence > cursor) { cursor = sequence; - renderInteractiveEvent(stdout, routed.event); + renderInteractiveEvent(interactiveOutput, routed.event); } } const session = routed.session; - if (session) stdout.write(`[execution ${session.status}] ${session.id}\n`); + if (session) { + agentPane.setThread(current, session.status); + stdout.write(`[execution ${session.status}] ${session.id}\n`); + } await startMonitor(session?.id || ""); } catch (error) { if (!(error instanceof ClientError)) throw error; @@ -634,19 +663,39 @@ function claudeStreamProgress(lines) { const inactiveAgentStatuses = new Set(["done", "completed", "closed", "cancelled", "canceled", "failed", "released", "skipped", "finalized", "killed", "missing"]); -export function renderAgentPane(agents, { columns = 80, maxRows = 6, connectionState = "connected" } = {}) { +export function renderAgentPane(agents, { + columns = 80, + maxRows = 6, + connectionState = "connected", + thread = null, + executionStatus = "", +} = {}) { const values = Array.isArray(agents) ? agents : []; - const active = values.filter((agent) => !inactiveAgentStatuses.has(String(agent.status || "").toLowerCase())).length; - const header = `Subagents | ${connectionState} | ${active} active, ${values.length} total`; - const rows = values.slice(0, Math.max(0, maxRows - 1)).map((agent) => { + const orchestratorStatus = executionStatus || thread?.state || "idle"; + const connection = connectionState === "connected" ? "" : ` · ${connectionState}`; + const rows = [`${agentStatusGlyph(orchestratorStatus)} orchestrator · ${orchestratorStatus}${connection}`]; + const agentCapacity = Math.max(0, Math.floor((maxRows - rows.length) / 2)); + const visible = values.slice(0, agentCapacity); + visible.forEach((agent, index) => { const status = String(agent.status || "unknown"); - const role = agent.role ? ` (${agent.role})` : ""; + const role = agent.role ? ` · ${agent.role}` : ""; const work = String(agent.workingOn || agent.assignment || "waiting"); - return `${inactiveAgentStatuses.has(status.toLowerCase()) ? "-" : ">"} ${agent.name || "agent"} [${status}]${role}: ${work}`; + const last = index === visible.length - 1 && values.length === visible.length; + rows.push(`${last ? "└─" : "├─"} ${agentStatusGlyph(status)} ${agent.name || "agent"}${role} · ${status}`); + rows.push(`${last ? " " : "│ "} ↳ ${work}`); }); - if (values.length > rows.length) rows.push(`... ${values.length - rows.length} more`); - if (!rows.length && maxRows > 1) rows.push(" No subagents reported yet"); - return [header, ...rows].slice(0, maxRows).map((line) => truncateTerminalLine(line, columns)); + if (values.length > visible.length && rows.length < maxRows) rows.push(`└─ … ${values.length - visible.length} more`); + if (!values.length && maxRows > 1) rows.push("└─ ○ waiting for agents"); + return rows.slice(0, maxRows).map((line) => truncateTerminalLine(line, columns)); +} + +function agentStatusGlyph(status) { + const value = String(status || "").toLowerCase(); + if (new Set(["failed", "killed", "cancelled", "canceled", "delivery-blocked", "interrupted"]).has(value)) return "×"; + if (inactiveAgentStatuses.has(value)) return "✓"; + if (new Set(["starting", "queued", "connecting", "restoring", "waiting"]).has(value)) return "◌"; + if (new Set(["running", "working", "in-progress"]).has(value)) return "●"; + return "○"; } function truncateTerminalLine(value, columns) { @@ -656,10 +705,12 @@ function truncateTerminalLine(value, columns) { return width <= 3 ? line.slice(0, width) : `${line.slice(0, width - 3)}...`; } -function createAgentPane(stdout) { +function createAgentPane(stdout, { onDraw = () => {} } = {}) { const enabled = Boolean(stdout?.isTTY && Number(stdout.rows) >= 8); let agents = []; let connectionState = "disconnected"; + let thread = null; + let executionStatus = ""; let panel = null; let lastFrame = ""; @@ -677,12 +728,18 @@ function createAgentPane(stdout) { if (!enabled) return; const rows = Math.max(8, Number(stdout.rows) || 24); const columns = Math.max(20, Number(stdout.columns) || 80); - const height = Math.min(7, Math.max(3, Math.floor(rows / 3))); + const height = Math.min(9, Math.max(4, Math.floor(rows / 3))); const start = rows - height + 1; const mainBottom = start - 1; if (panel && (panel.rows !== rows || panel.start !== start)) clear(); panel = { rows, start }; - const lines = renderAgentPane(agents, { columns, maxRows: height, connectionState }); + const lines = renderAgentPane(agents, { + columns, + maxRows: height, + connectionState, + thread, + executionStatus, + }); const frame = JSON.stringify({ rows, columns, height, lines }); if (frame === lastFrame) return; lastFrame = frame; @@ -692,6 +749,7 @@ function createAgentPane(stdout) { } output += "\u001b8"; stdout.write(output); + onDraw(); }; return { @@ -704,6 +762,11 @@ function createAgentPane(stdout) { connectionState = next; draw(); }, + setThread(nextThread, nextExecutionStatus = "") { + thread = nextThread || null; + executionStatus = nextExecutionStatus || ""; + draw(); + }, close: clear, }; } diff --git a/client/test/client.test.mjs b/client/test/client.test.mjs index e79b326..f7b77ec 100644 --- a/client/test/client.test.mjs +++ b/client/test/client.test.mjs @@ -10,6 +10,10 @@ function writer() { return { output: "", write(value) { this.output += String(value); } }; } +function ttyWriter({ rows = 24, columns = 100 } = {}) { + return { ...writer(), isTTY: true, rows, columns }; +} + function jsonResponse(value, init = {}) { return new Response(JSON.stringify(value), { status: init.status || 200, @@ -450,9 +454,81 @@ test("subagent pane includes status, role, and current work within its width", ( const lines = renderAgentPane([ { name: "reader", status: "working", role: "investigator", workingOn: "Tracing the session lifecycle" }, { name: "tester", status: "done", role: "verification", workingOn: "Ran the client tests" }, - ], { columns: 72, maxRows: 4, connectionState: "connected" }); - assert.equal(lines[0], "Subagents | connected | 1 active, 2 total"); - assert.match(lines[1], /> reader \[working\] \(investigator\): Tracing the session lifecycle/); - assert.match(lines[2], /- tester \[done\] \(verification\): Ran the client tests/); + ], { columns: 72, maxRows: 6, connectionState: "connected" }); + assert.equal(lines[0], "○ orchestrator · idle"); + assert.match(lines[1], /├─ ● reader · investigator · working/); + assert.match(lines[2], /↳ Tracing the session lifecycle/); + assert.match(lines[3], /└─ ✓ tester · verification · done/); + assert.match(lines[4], /↳ Ran the client tests/); assert.ok(lines.every((line) => line.length <= 72)); }); + +test("subagent pane keeps the open thread and orchestrator status visible", () => { + const lines = renderAgentPane([ + { name: "reader", status: "running", role: "investigator", workingOn: "Inspecting the runtime" }, + ], { + columns: 100, + maxRows: 5, + connectionState: "connected", + thread: { id: "thread-123", state: "running" }, + }); + assert.equal(lines[0], "● orchestrator · running"); + assert.equal(lines[1], "└─ ● reader · investigator · running"); + assert.equal(lines[2], " ↳ Inspecting the runtime"); +}); + +test("asynchronous status redraw restores the active input prompt", async () => { + const sessionFile = await sessionFixture(); + const output = ttyWriter(); + let questionCount = 0; + let threadSocket = null; + const prompts = []; + const terminal = { + async question() { + questionCount += 1; + if (questionCount === 1) return "/open 1"; + return new Promise((resolve) => { + setImmediate(() => { + threadSocket.emit("message", Buffer.from(JSON.stringify({ + type: "agents", + agents: [{ name: "reader", status: "running", role: "investigator", workingOn: "Checking status" }], + }))); + threadSocket.emit("message", Buffer.from(JSON.stringify({ + type: "event", + event: { sequence: 1, type: "assistant_message", payload: { text: "Still working" } }, + }))); + setImmediate(() => resolve("/quit")); + }); + }); + }, + setPrompt(value) { this.label = value; }, + prompt(preserveCursor) { prompts.push({ label: this.label, preserveCursor }); }, + close() {}, + }; + + await main([ + "--server", "https://control.example", "--session-file", sessionFile, + ], { + stdin: { isTTY: true }, + stdout: output, + createInterface: () => terminal, + sleep: async () => {}, + createWebSocket: (url) => { + const socket = new EventEmitter(); + socket.close = () => queueMicrotask(() => socket.emit("close")); + if (String(url).includes("/stream")) threadSocket = socket; + return socket; + }, + fetchImpl: async (url) => { + const value = String(url); + if (value.endsWith("/api/threads")) return jsonResponse({ threads: [{ id: "thread-1", state: "running", repository: "multiagent" }] }); + if (value.endsWith("/api/threads/thread-1")) return jsonResponse({ thread: { id: "thread-1", state: "running", repository: "multiagent" } }); + if (value.includes("/events?after_sequence=0")) return jsonResponse({ events: [] }); + throw new Error(`unexpected request: ${value}`); + }, + }); + + assert.match(output.output, /● orchestrator · running/); + assert.match(output.output, /\r\u001b\[2K\nassistant> Still working/); + assert.ok(prompts.some((prompt) => prompt.label === "› " && prompt.preserveCursor === true)); +}); From 8f8877c419fe4b2ea8b40dcd3e61b187bedf41fe Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 30 Aug 2026 00:48:03 -0700 Subject: [PATCH 2/8] Keep live subagent graph visible --- client/src/client.mjs | 19 +++++--- client/test/client.test.mjs | 19 ++++++++ control-server/src/server.mjs | 82 +++++++++++++++++++++++++++++------ 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/client/src/client.mjs b/client/src/client.mjs index 5dceb2b..d295849 100644 --- a/client/src/client.mjs +++ b/client/src/client.mjs @@ -236,7 +236,7 @@ export async function runInteractive({ active.controller.abort(); await active.promise; if (threadConnection === active) threadConnection = null; - agentPane.render([], "disconnected"); + agentPane.render([], "disconnected", null); }; const startThreadConnection = (threadId) => { @@ -258,7 +258,7 @@ export async function runInteractive({ agentPane.setThread(thread); } }, - onAgents: (agents) => agentPane.render(agents), + onAgents: (agents, snapshot) => agentPane.render(agents, undefined, snapshot), onEvent: (event) => { if (current?.id !== threadId) return; const sequence = Number(event.sequence) || 0; @@ -410,7 +410,7 @@ async function streamThread({ client, threadId, getCursor, sleep, signal, create onState("connected"); if (payload.type === "event" && payload.event) onEvent(payload.event); else if (payload.type === "thread" && payload.thread) onThread(payload.thread); - else if (payload.type === "agents") onAgents(Array.isArray(payload.agents) ? payload.agents : []); + else if (payload.type === "agents") onAgents(Array.isArray(payload.agents) ? payload.agents : [], payload); }, }); if (signal.aborted) return; @@ -669,6 +669,7 @@ export function renderAgentPane(agents, { connectionState = "connected", thread = null, executionStatus = "", + agentSnapshot = null, } = {}) { const values = Array.isArray(agents) ? agents : []; const orchestratorStatus = executionStatus || thread?.state || "idle"; @@ -685,7 +686,12 @@ export function renderAgentPane(agents, { rows.push(`${last ? " " : "│ "} ↳ ${work}`); }); if (values.length > visible.length && rows.length < maxRows) rows.push(`└─ … ${values.length - visible.length} more`); - if (!values.length && maxRows > 1) rows.push("└─ ○ waiting for agents"); + if (!values.length && maxRows > 1) { + const active = new Set(["queued", "starting", "running", "working", "in-progress"]).has(String(orchestratorStatus).toLowerCase()); + if (agentSnapshot?.error) rows.push("└─ ◌ subagent status unavailable"); + else if (active) rows.push("└─ ◌ discovering subagents"); + else rows.push("└─ ○ no active agents"); + } return rows.slice(0, maxRows).map((line) => truncateTerminalLine(line, columns)); } @@ -711,6 +717,7 @@ function createAgentPane(stdout, { onDraw = () => {} } = {}) { let connectionState = "disconnected"; let thread = null; let executionStatus = ""; + let agentSnapshot = null; let panel = null; let lastFrame = ""; @@ -739,6 +746,7 @@ function createAgentPane(stdout, { onDraw = () => {} } = {}) { connectionState, thread, executionStatus, + agentSnapshot, }); const frame = JSON.stringify({ rows, columns, height, lines }); if (frame === lastFrame) return; @@ -753,9 +761,10 @@ function createAgentPane(stdout, { onDraw = () => {} } = {}) { }; return { - render(nextAgents, nextConnectionState) { + render(nextAgents, nextConnectionState, nextAgentSnapshot) { agents = Array.isArray(nextAgents) ? nextAgents : []; if (nextConnectionState) connectionState = nextConnectionState; + if (nextAgentSnapshot !== undefined) agentSnapshot = nextAgentSnapshot; draw(); }, setConnectionState(next) { diff --git a/client/test/client.test.mjs b/client/test/client.test.mjs index f7b77ec..88102c6 100644 --- a/client/test/client.test.mjs +++ b/client/test/client.test.mjs @@ -477,6 +477,25 @@ test("subagent pane keeps the open thread and orchestrator status visible", () = assert.equal(lines[2], " ↳ Inspecting the runtime"); }); +test("subagent pane distinguishes idle, discovery, and unavailable snapshots", () => { + assert.equal(renderAgentPane([], { + columns: 80, + maxRows: 4, + thread: { id: "thread-idle", state: "idle" }, + })[1], "└─ ○ no active agents"); + assert.equal(renderAgentPane([], { + columns: 80, + maxRows: 4, + thread: { id: "thread-running", state: "running" }, + })[1], "└─ ◌ discovering subagents"); + assert.equal(renderAgentPane([], { + columns: 80, + maxRows: 4, + thread: { id: "thread-running", state: "running" }, + agentSnapshot: { error: "subagent status temporarily unavailable" }, + })[1], "└─ ◌ subagent status unavailable"); +}); + test("asynchronous status redraw restores the active input prompt", async () => { const sessionFile = await sessionFixture(); const output = ttyWriter(); diff --git a/control-server/src/server.mjs b/control-server/src/server.mjs index f389c4a..a11d8a7 100644 --- a/control-server/src/server.mjs +++ b/control-server/src/server.mjs @@ -465,21 +465,58 @@ function fetchWorkerReport(id, podIP) { } const gatewaySubagentSnapshots = new Map(); +const gatewaySubagentSnapshotErrors = new Map(); + +function unavailableSubagentSnapshot(id, error = null) { + const cached = gatewaySubagentSnapshots.get(id); + if (cached) { + return { + ...cached, + available: false, + stale: true, + error: error ? "subagent status temporarily unavailable" : null, + }; + } + return { + sessionId: id, + agents: [], + available: false, + stale: false, + error: error ? "subagent status temporarily unavailable" : null, + updatedAt: null, + }; +} async function gatewaySubagentSnapshot(id) { let record = registry.sessions[id]; - if (!record) return []; + if (!record) return unavailableSubagentSnapshot(id); if (!record.podIP) record = await reconcileGatewaySession(id); - if (!record?.podIP) return gatewaySubagentSnapshots.get(id) || []; + if (!record?.podIP) return unavailableSubagentSnapshot(id); try { const agents = await fetchWorkerSubagents({ sessionId: id, hostname: record.podIP, token: issueWorkerToken(id), }); - gatewaySubagentSnapshots.set(id, agents); - return agents; - } catch { return gatewaySubagentSnapshots.get(id) || []; } + const snapshot = { + sessionId: id, + agents, + available: true, + stale: false, + error: null, + updatedAt: agents.map((agent) => agent.updatedAt).filter(Boolean).sort().at(-1) || null, + }; + gatewaySubagentSnapshots.set(id, snapshot); + gatewaySubagentSnapshotErrors.delete(id); + return snapshot; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (gatewaySubagentSnapshotErrors.get(id) !== message) { + gatewaySubagentSnapshotErrors.set(id, message); + console.warn("session worker subagent snapshot unavailable", { sessionId: id, error: message }); + } + return unavailableSubagentSnapshot(id, error); + } } async function reconcileGatewaySession(id) { @@ -993,7 +1030,7 @@ const server = http.createServer(async (request, response) => { if (!registry.sessions[id] || (workerSessionId !== id && registry.sessions[id].createdBy !== username)) { return json(response, 404, { error: "unknown session" }); } - if (gatewayMode) return json(response, 200, { sessionId: id, agents: await gatewaySubagentSnapshot(id) }); + if (gatewayMode) return json(response, 200, await gatewaySubagentSnapshot(id)); return json(response, 200, { sessionId: id, agents: readSubagentSnapshot(sessionStateDir(id)) }); } if (request.method === "POST" && url.pathname === "/api/sessions") { @@ -1081,6 +1118,7 @@ sockets.on("connection", (socket, request) => { let publishing = false; let previousThread = ""; let previousAgents = ""; + let observedSessionId = null; let lastHeartbeatAt = 0; const publish = async () => { if (publishing) return; @@ -1097,16 +1135,34 @@ sockets.on("connection", (socket, request) => { previousThread = serializedThread; socket.send(JSON.stringify({ type: "thread", thread })); } - const sessionId = thread.activeSessionId || null; - const agents = sessionId + const activeSessionId = thread.activeSessionId || null; + if (activeSessionId) observedSessionId = activeSessionId; + if (!observedSessionId) { + const sessions = await threadStore.listSessionsForActor({ threadId: request.threadId, actor: request.username }); + observedSessionId = sessions.at(-1)?.id || null; + } + let snapshot = observedSessionId ? gatewayMode - ? await gatewaySubagentSnapshot(sessionId) - : readSubagentSnapshot(sessionStateDir(sessionId)) - : []; - const serializedAgents = JSON.stringify({ sessionId, agents }); + ? activeSessionId + ? await gatewaySubagentSnapshot(observedSessionId) + : unavailableSubagentSnapshot(observedSessionId) + : { + sessionId: observedSessionId, + agents: readSubagentSnapshot(sessionStateDir(observedSessionId)), + available: true, + stale: false, + error: null, + updatedAt: null, + } + : unavailableSubagentSnapshot(""); + if (!activeSessionId && snapshot.agents.length === 0) { + snapshot = { ...snapshot, error: null, stale: false }; + } + const agentPayload = { type: "agents", ...snapshot, active: Boolean(activeSessionId && activeSessionId === observedSessionId) }; + const serializedAgents = JSON.stringify(agentPayload); if (serializedAgents !== previousAgents && socket.readyState === WebSocket.OPEN) { previousAgents = serializedAgents; - socket.send(JSON.stringify({ type: "agents", sessionId, agents })); + socket.send(serializedAgents); } if (Date.now() - lastHeartbeatAt >= 15_000 && socket.readyState === WebSocket.OPEN) { lastHeartbeatAt = Date.now(); From a1065bd9d23d686c78ef8615c31bec6a3a7685f7 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 30 Aug 2026 00:55:13 -0700 Subject: [PATCH 3/8] Scope thread listing to the local client --- client/README.md | 14 ++-- client/src/client.mjs | 79 +++++++++++++++---- client/test/client.test.mjs | 25 ++++-- control-server/README.md | 7 ++ control-server/src/server.mjs | 25 +++++- control-server/src/session-visibility.mjs | 8 ++ .../test/session-visibility.test.mjs | 17 ++++ docs/architecture/system-architecture.md | 15 +++- 8 files changed, 156 insertions(+), 34 deletions(-) create mode 100644 control-server/src/session-visibility.mjs create mode 100644 control-server/test/session-visibility.test.mjs diff --git a/client/README.md b/client/README.md index afc62cf..cff75a9 100644 --- a/client/README.md +++ b/client/README.md @@ -13,10 +13,12 @@ npm run client -- --server https://agent.example login operator npm run client -- ``` -Running without a command opens a persistent, Claude Code-style terminal. It -lists durable threads, accepts `/open THREAD_ID` or a list number, and sends -ordinary input to the open thread. Use `/new REPOSITORY [TITLE]` to create a -thread and `/help` to see the complete interactive command set. The server +Running without a command opens a persistent, Claude Code-style terminal without +enumerating server threads. `/list` shows only threads created by this local +client profile; `/open THREAD_ID` opens an explicitly known thread without adding +it to that local list. A list number may be used after `/list`. Use +`/new REPOSITORY [TITLE]` to create a thread and `/help` to see the complete +interactive command set. The server assigns both thread IDs and execution-session IDs. After a message starts an execution session, the client streams the orchestrator terminal without locking the prompt. Additional ordinary input is durably appended and delivered as a @@ -42,6 +44,8 @@ printf '%s' "$MULTIAGENT_LOGIN_PASSWORD" | \ The login session defaults to `~/.config/multiagent/client-session.json` and is written with mode `0600`. Override it with `--session-file` or `MULTIAGENT_CLIENT_SESSION_FILE`. +Locally created thread IDs are kept separately in the adjacent mode-`0600` +`client-session.json.threads.json` file. It contains no authentication cookie. ## Commands @@ -51,7 +55,7 @@ and debugging: ```text connect [THREAD_ID] repositories list -threads list +threads list # only threads created by this local client profile threads show THREAD_ID threads create --repository NAME [--title TITLE] (--message TEXT | --message-file PATH) threads send THREAD_ID (--message TEXT | --message-file PATH) diff --git a/client/src/client.mjs b/client/src/client.mjs index d295849..34e1a4c 100644 --- a/client/src/client.mjs +++ b/client/src/client.mjs @@ -32,7 +32,7 @@ thread watch emits one JSON event per line. Run without a command for the interactive terminal client.`; const interactiveHelp = `Commands: - /threads List threads + /list List threads created by this local client /open THREAD_ID Open a thread /new REPO [TITLE] Create and open a server-assigned thread /sessions List execution sessions for the open thread @@ -103,12 +103,17 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) { const normalizedServer = normalizeServer(server).href; const cookie = stored?.server === normalizedServer ? stored.cookie : ""; const client = new ControlClient({ server: normalizedServer, cookie, fetchImpl }); + const threadIndex = { + file: `${sessionFile}.threads.json`, + server: normalizedServer, + username: stored?.server === normalizedServer ? String(stored.username || "") : "", + }; if (!command || command === "connect") { if (!client.cookie) throw new ClientError(`not logged in to ${normalizedServer}; run the login command first`); const initialThreadId = command === "connect" ? parsed.args.shift() || "" : ""; rejectExtraArguments(parsed.args); - return runInteractive({ client, stdin, stdout, sleep, createInterfaceImpl, createWebSocketImpl, initialThreadId }); + return runInteractive({ client, stdin, stdout, sleep, createInterfaceImpl, createWebSocketImpl, initialThreadId, threadIndex }); } if (command === "login") { @@ -146,7 +151,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) { return 0; } if (command === "threads") { - return runThreads({ client, args: parsed.args, stdout, stdin, sleep }); + return runThreads({ client, args: parsed.args, stdout, stdin, sleep, threadIndex }); } if (command === "sessions") { requireAction(parsed.args.shift(), "list", "sessions"); @@ -169,10 +174,11 @@ export async function runInteractive({ createInterfaceImpl = createInterface, createWebSocketImpl = (url, options) => new WebSocket(url, options), initialThreadId = "", + threadIndex, }) { if (!stdin?.isTTY) throw new ClientError("interactive mode requires a terminal; use a JSON command for non-interactive calls"); const terminal = createInterfaceImpl({ input: stdin, output: stdout, terminal: true }); - let threads = []; + let threads = (await loadLocalThreadIds(threadIndex)).map((id) => ({ id })); let current = null; let cursor = 0; let monitor = null; @@ -195,9 +201,9 @@ export async function runInteractive({ const agentPane = createAgentPane(stdout, { onDraw: refreshPrompt }); const listThreads = async () => { - threads = (await client.request("/api/threads")).value.threads || []; + threads = await fetchLocalThreads(client, threadIndex); if (!threads.length) { - stdout.write("\nNo threads. Create one with /new REPOSITORY [TITLE].\n"); + stdout.write("\nNo threads created by this local client. Use /new REPOSITORY [TITLE].\n"); return; } stdout.write("\nThreads\n"); @@ -316,7 +322,6 @@ export async function runInteractive({ stdout.write("Multiagent — /help\n"); try { - await listThreads(); if (initialThreadId) await openThread(initialThreadId); while (true) { promptLabel = current ? "› " : "multiagent> "; @@ -332,7 +337,7 @@ export async function runInteractive({ try { if (line === "/quit" || line === "/exit") return 0; if (line === "/help") { stdout.write(`\n${interactiveHelp}\n`); continue; } - if (line === "/threads") { await listThreads(); continue; } + if (line === "/list" || line === "/threads") { await listThreads(); continue; } if (line === "/refresh") { await replay(); continue; } if (line === "/wait") { if (!monitor) stdout.write("No execution is currently running.\n"); @@ -354,13 +359,14 @@ export async function runInteractive({ method: "POST", body: { repository, title: titleParts.join(" ") }, }); + await rememberLocalThread(threadIndex, created.value.thread.id); await stopMonitor(); await stopThreadConnection(); current = created.value.thread; agentPane.setThread(current); cursor = 0; + threads = [...threads.filter((thread) => thread.id !== current.id), current]; startThreadConnection(current.id); - await listThreads(); stdout.write(`\nOpened ${current.id}. Enter its first message.\n`); continue; } @@ -800,11 +806,11 @@ function renderInteractiveEvent(stdout, event) { else stdout.write(`\n[${event.type.replaceAll("_", " ")}] ${text || JSON.stringify(event.payload)}\n`); } -async function runThreads({ client, args, stdout, stdin, sleep }) { +async function runThreads({ client, args, stdout, stdin, sleep, threadIndex }) { const action = requiredArgument(args.shift(), "threads action"); if (action === "list") { rejectExtraArguments(args); - writeJson(stdout, (await client.request("/api/threads")).value.threads || []); + writeJson(stdout, await fetchLocalThreads(client, threadIndex)); return 0; } if (action === "show") { @@ -828,6 +834,7 @@ async function runThreads({ client, args, stdout, stdin, sleep }) { body: { repository, title: options.get("--title") || "" }, }); const threadId = created.value.thread.id; + await rememberLocalThread(threadIndex, threadId); try { const routed = await sendThreadMessage(client, threadId, message); writeJson(stdout, { thread: created.value.thread, route: routed }); @@ -867,9 +874,7 @@ async function runLegacy({ client, args, stdout }) { const action = requiredArgument(args.shift(), "legacy action"); if (action === "list") { rejectExtraArguments(args); - const [threads, sessions] = await Promise.all([client.request("/api/threads"), client.request("/api/sessions")]); - const threadIds = new Set((threads.value.threads || []).map((thread) => thread.id)); - writeJson(stdout, (sessions.value.sessions || []).filter((session) => !threadIds.has(session.threadId))); + writeJson(stdout, (await client.request("/api/sessions")).value.sessions || []); return 0; } if (action === "report") { @@ -1003,6 +1008,52 @@ async function saveSession(file, value) { await fs.chmod(file, 0o600); } +async function loadThreadIndex(file) { + try { + const value = JSON.parse(await fs.readFile(file, "utf8")); + if (value.schemaVersion !== 1 || !Array.isArray(value.profiles)) throw new Error("invalid fields"); + return value; + } catch (error) { + if (error.code === "ENOENT") return { schemaVersion: 1, profiles: [] }; + throw new ClientError(`cannot read local thread index: ${error.message}`); + } +} + +async function loadLocalThreadIds(threadIndex) { + if (!threadIndex?.file || !threadIndex.server || !threadIndex.username) return []; + const value = await loadThreadIndex(threadIndex.file); + const profile = value.profiles.find((candidate) => candidate?.server === threadIndex.server && candidate?.username === threadIndex.username); + if (!profile) return []; + if (!Array.isArray(profile.threadIds)) throw new ClientError("cannot read local thread index: invalid thread IDs"); + return [...new Set(profile.threadIds.filter((id) => typeof id === "string" && /^[a-z0-9-]+$/.test(id)))]; +} + +async function rememberLocalThread(threadIndex, threadId) { + if (!threadIndex?.file || !threadIndex.server || !threadIndex.username) { + throw new ClientError("cannot record the local thread without an authenticated client profile"); + } + const value = await loadThreadIndex(threadIndex.file); + let profile = value.profiles.find((candidate) => candidate?.server === threadIndex.server && candidate?.username === threadIndex.username); + if (!profile) { + profile = { server: threadIndex.server, username: threadIndex.username, threadIds: [] }; + value.profiles.push(profile); + } + profile.threadIds = [...new Set([...(Array.isArray(profile.threadIds) ? profile.threadIds : []), threadId])]; + await saveSession(threadIndex.file, value); +} + +async function fetchLocalThreads(client, threadIndex) { + const ids = await loadLocalThreadIds(threadIndex); + return Promise.all(ids.map(async (id) => { + try { + return (await client.request(`/api/threads/${encodeURIComponent(id)}`)).value.thread; + } catch (error) { + if (error instanceof ClientError && error.statusCode === 404) return { id, state: "unavailable", repository: "-" }; + throw error; + } + })); +} + function writeJson(stream, value) { stream.write(JSON.stringify(value, null, 2) + "\n"); } diff --git a/client/test/client.test.mjs b/client/test/client.test.mjs index 88102c6..b9fe0a0 100644 --- a/client/test/client.test.mjs +++ b/client/test/client.test.mjs @@ -21,7 +21,7 @@ function jsonResponse(value, init = {}) { }); } -async function sessionFixture() { +async function sessionFixture({ threadIds = ["thread-1"] } = {}) { const directory = await mkdtemp(path.join(os.tmpdir(), "multiagent-client-")); const file = path.join(directory, "session.json"); await writeFile(file, JSON.stringify({ @@ -29,6 +29,10 @@ async function sessionFixture() { cookie: "multiagent_session=signed-cookie", username: "operator", }), { mode: 0o600 }); + await writeFile(`${file}.threads.json`, JSON.stringify({ + schemaVersion: 1, + profiles: [{ server: "https://control.example/", username: "operator", threadIds }], + }), { mode: 0o600 }); return file; } @@ -104,7 +108,7 @@ test("client login stores only the scoped session cookie with mode 0600", async }); }); -test("users can list durable threads through the terminal client", async () => { +test("users list only locally created threads through individually authorized lookups", async () => { const sessionFile = await sessionFixture(); const output = writer(); let cookie = ""; @@ -113,9 +117,9 @@ test("users can list durable threads through the terminal client", async () => { ], { stdout: output, fetchImpl: async (url, options) => { - assert.equal(String(url), "https://control.example/api/threads"); + assert.equal(String(url), "https://control.example/api/threads/thread-1"); cookie = options.headers.cookie; - return jsonResponse({ threads: [{ id: "thread-1", state: "idle", repository: "multiagent" }] }); + return jsonResponse({ thread: { id: "thread-1", state: "idle", repository: "multiagent" } }); }, }); assert.equal(cookie, "multiagent_session=signed-cookie"); @@ -123,7 +127,7 @@ test("users can list durable threads through the terminal client", async () => { }); test("thread creation lets the server generate both the thread and execution session IDs", async () => { - const sessionFile = await sessionFixture(); + const sessionFile = await sessionFixture({ threadIds: [] }); const output = writer(); const requests = []; await main([ @@ -143,6 +147,9 @@ test("thread creation lets the server generate both the thread and execution ses assert.deepEqual(JSON.parse(requests[1].options.body), { text: "Investigate the incident" }); assert.ok(requests[1].options.headers["idempotency-key"]); assert.equal(JSON.parse(output.output).route.session.id, "thread-1-generated-session"); + const index = JSON.parse(await readFile(`${sessionFile}.threads.json`, "utf8")); + assert.deepEqual(index.profiles[0].threadIds, ["thread-1"]); + assert.equal((await stat(`${sessionFile}.threads.json`)).mode & 0o777, 0o600); }); test("thread show and one-shot watch expose history and execution state as JSON", async () => { @@ -175,7 +182,7 @@ test("client refuses to send authentication over non-local plaintext HTTP", () = test("interactive terminal lists, opens, and continues durable threads", async () => { const sessionFile = await sessionFixture(); const output = writer(); - const answers = ["/open missing", "/open 1", "Continue the investigation", "/wait", "/quit"]; + const answers = ["/list", "/open missing", "/open 1", "Continue the investigation", "/wait", "/quit"]; const requests = []; await main([ "--server", "https://control.example", "--session-file", sessionFile, @@ -218,10 +225,11 @@ test("interactive terminal lists, opens, and continues durable threads", async ( assert.match(output.output, /Planning\nDelegating/); assert.match(output.output, /assistant> Investigation complete/); assert.ok(requests.some((request) => request.url.endsWith("/api/threads/thread-1/messages"))); + assert.deepEqual(JSON.parse(await readFile(`${sessionFile}.threads.json`, "utf8")).profiles[0].threadIds, ["thread-1"]); }); test("interactive new asks only for a repository and streams its first execution", async () => { - const sessionFile = await sessionFixture(); + const sessionFile = await sessionFixture({ threadIds: [] }); const output = writer(); const answers = ["/new multiagent Incident triage", "Investigate now", "/wait", "/quit"]; let created = null; @@ -253,10 +261,11 @@ test("interactive new asks only for a repository and streams its first execution throw new Error(`unexpected request: ${value}`); }, }); - assert.match(output.output, /No threads\. Create one with \/new REPOSITORY \[TITLE\]/); + assert.doesNotMatch(output.output, /No threads|\nThreads\n/); assert.match(output.output, /Opened thread-generated\. Enter its first message/); assert.match(output.output, /Starting orchestrator\nReader assigned/); assert.match(output.output, /assistant> Done/); + assert.deepEqual(JSON.parse(await readFile(`${sessionFile}.threads.json`, "utf8")).profiles[0].threadIds, ["thread-generated"]); }); test("interactive streaming retries while the session worker starts", async () => { diff --git a/control-server/README.md b/control-server/README.md index 7a088b8..6907e5e 100644 --- a/control-server/README.md +++ b/control-server/README.md @@ -5,6 +5,13 @@ and execution Session lifecycle. It has no browser UI and does not contain the terminal client implementation. The standalone client lives in `../client` and communicates only through the public HTTP API. +The public API supports thread creation and individually authorized lookup by +thread ID; it does not expose server-wide thread discovery. `GET /api/threads` +returns `404`. Deployment diagnostics inside the control-server Pod inspect the +internal thread manifest/store directly rather than using an HTTP endpoint. +The legacy `GET /api/sessions` collection excludes every thread-backed execution, +so it cannot be used to recover the removed thread collection indirectly. + ## Development ```bash diff --git a/control-server/src/server.mjs b/control-server/src/server.mjs index a11d8a7..347e65e 100644 --- a/control-server/src/server.mjs +++ b/control-server/src/server.mjs @@ -13,6 +13,7 @@ import { readSubagentSnapshot } from "./subagent-status.mjs"; import { renderThreadTask } from "./thread-execution-context.mjs"; import { fetchWorkerSubagents } from "./worker-subagent-client.mjs"; import { configuredRepository, parseRepositoryCatalog } from "./repository-catalog.mjs"; +import { visibleLegacySessionIds } from "./session-visibility.mjs"; import { acceptsLiveInput, automaticResumeLimit, @@ -750,6 +751,24 @@ function sessionView(id) { return { ...record, live: tmuxAlive(id) }; } +async function publicLegacySessions(username) { + const legacyIds = await visibleLegacySessionIds({ + records: registry.sessions, + username, + hasThread: async (threadId, actor) => { + try { + await threadStore.getThreadForActor(threadId, actor); + return true; + } catch (error) { + if (error?.statusCode !== 404) throw error; + return false; + } + }, + }); + if (gatewayMode) await Promise.all(legacyIds.map(reconcileGatewaySession)); + return legacyIds.map((id) => gatewayMode ? registry.sessions[id] : sessionView(id)); +} + function capture(id) { if (!tmuxAlive(id)) { try { return fs.readFileSync(path.join(traceRoot(id), "terminal-tail.log"), "utf8"); } catch { return ""; } @@ -898,7 +917,6 @@ const server = http.createServer(async (request, response) => { readiness: "/readyz", }); } - if (!validOrigin(request)) return json(response, 403, { error: "origin rejected" }); if (request.method === "POST" && url.pathname === "/api/login") { const address = request.socket.remoteAddress || "unknown"; @@ -930,7 +948,7 @@ const server = http.createServer(async (request, response) => { return json(response, 200, { repositories }); } if (request.method === "GET" && url.pathname === "/api/threads") { - return json(response, 200, { threads: await threadStore.listThreadsForActor(username) }); + return json(response, 404, { error: "not found" }); } if (request.method === "POST" && url.pathname === "/api/threads") { if (workerMode) throw new Error("session workers cannot create threads"); @@ -994,8 +1012,7 @@ const server = http.createServer(async (request, response) => { return json(response, 202, { ...routed, delivery }); } if (request.method === "GET" && url.pathname === "/api/sessions") { - if (gatewayMode) await Promise.all(Object.keys(registry.sessions).map(reconcileGatewaySession)); - return json(response, 200, { sessions: Object.keys(registry.sessions).sort().filter((id) => registry.sessions[id].createdBy === username).map((id) => gatewayMode ? registry.sessions[id] : sessionView(id)) }); + return json(response, 200, { sessions: await publicLegacySessions(username) }); } const reportMatch = url.pathname.match(/^\/api\/sessions\/([a-z0-9-]+)\/report$/); if (request.method === "POST" && reportMatch) { diff --git a/control-server/src/session-visibility.mjs b/control-server/src/session-visibility.mjs new file mode 100644 index 0000000..13e9ef4 --- /dev/null +++ b/control-server/src/session-visibility.mjs @@ -0,0 +1,8 @@ +export async function visibleLegacySessionIds({ records, username, hasThread }) { + const ids = Object.keys(records).sort().filter((id) => records[id]?.createdBy === username); + const visible = []; + for (const id of ids) { + if (!await hasThread(records[id]?.threadId, username)) visible.push(id); + } + return visible; +} diff --git a/control-server/test/session-visibility.test.mjs b/control-server/test/session-visibility.test.mjs new file mode 100644 index 0000000..b74ae47 --- /dev/null +++ b/control-server/test/session-visibility.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { visibleLegacySessionIds } from "../src/session-visibility.mjs"; + +test("legacy session listing cannot reveal thread-backed executions", async () => { + const records = { + "legacy-1": { id: "legacy-1", createdBy: "operator", threadId: "legacy-1" }, + "session-thread-1": { id: "session-thread-1", createdBy: "operator", threadId: "thread-1" }, + "other-user": { id: "other-user", createdBy: "someone-else", threadId: "legacy-2" }, + }; + const visible = await visibleLegacySessionIds({ + records, + username: "operator", + hasThread: async (threadId) => threadId === "thread-1", + }); + assert.deepEqual(visible, ["legacy-1"]); +}); diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 0bcecb8..5be346f 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -70,7 +70,7 @@ storage configuration shown above. | Component | Owns | Must not own or know | | --- | --- | --- | -| Terminal client | User login, local session-cookie storage, interactive durable-thread conversation, scriptable commands, result presentation | Runbook implementation, KMS signing, production credentials | +| Terminal client | User login, local session-cookie storage, a separate local index of thread IDs created by that client profile, interactive durable-thread conversation, scriptable commands, result presentation | Server-wide thread discovery, runbook implementation, KMS signing, production credentials | | Control server | Treating the authenticated client caller as the user, durable thread ownership and public history, execution-session creation, message transport, event replay, trace-derived context, result streaming | Provider lifecycle logic, agent/model turn storage, Grafana procedures, operation IDs, runbook steps, production credentials | | Supervisor | One session's authority, role bootstrap, role confinement, privileged-request mediation, KMS signing | Service-specific operational procedures | | Orchestrator | Goal decomposition, role routing, workflow coordination | Grafana/Loki knowledge, concrete production operations, `prod-mcp` parameters, provider-specific prompts | @@ -90,8 +90,10 @@ part of the orchestrator's production-operation path. ### AD-001: The authenticated client user is the authorizing user The terminal client authenticates the human user and submits that user's intent -to the control server. It stores only the resulting session cookie in a local -mode-`0600` file. Interactive mode presents durable thread conversation events; +to the control server. Its authentication file stores only the resulting session +cookie. A separate mode-`0600` local index records only the thread IDs created by +that exact server-and-user client profile; it contains no credential or server-wide +discovery result. Interactive mode presents durable thread conversation events; explicit subcommands emit thread state as JSON for automation. The control server records the authenticated actor and approval time. It must not convert authorization into hidden prompt text or ask each role agent to authenticate @@ -101,6 +103,13 @@ The control server may have high authority because access to it is already restricted to authenticated users. That authority remains attributable to the authenticated user and session. +The client does not enumerate threads on startup. `/list` and the scriptable +thread-list command resolve only IDs from the local client index through +individually authorized thread lookups. A caller may explicitly open a known +thread ID, but doing so does not add it to the local-created index. Server-wide +thread collection listing is not an HTTP API. Pod-local deployment diagnostics +inspect the control server's internal thread manifest/store directly. + The HTTP API is the client contract. There is no browser client and therefore no second thread/session state machine. The unauthenticated root route returns only JSON service metadata; health and readiness use their dedicated JSON routes. From f5d92e51858ae56a3f87da31ffa35add0770fc8a Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 30 Aug 2026 01:12:31 -0700 Subject: [PATCH 4/8] Keep the completed task summary visible --- client/README.md | 5 +++- client/src/client.mjs | 46 +++++++++++++++++++++++++++++++++++-- client/test/client.test.mjs | 15 ++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/client/README.md b/client/README.md index cff75a9..3e98dde 100644 --- a/client/README.md +++ b/client/README.md @@ -31,7 +31,10 @@ execution is active. The interactive TTY reserves a small bottom pane for each subagent's state, role, and current progress as a compact graph rooted at the orchestrator. The stable `› ` input area remains available while agents work; asynchronous output redraws it without discarding partially typed follow-up -text. HTTP event replay repairs gaps after a disconnect. +text. When an execution finishes, the pane keeps a one-line summary of the +latest public outcome and labels the orchestrator `complete` instead of reducing +the result to `idle`. HTTP event replay repairs gaps after a disconnect and +reconstructs that summary when a thread is reopened. The first command securely prompts for the password. For a non-interactive caller, provide the password on stdin. Do not put it in a command argument: diff --git a/client/src/client.mjs b/client/src/client.mjs index 34e1a4c..4c8ddda 100644 --- a/client/src/client.mjs +++ b/client/src/client.mjs @@ -199,6 +199,10 @@ export async function runInteractive({ }, }; const agentPane = createAgentPane(stdout, { onDraw: refreshPrompt }); + const applyPaneEvent = (event) => { + const outcome = paneOutcomeForEvent(event); + if (outcome) agentPane.setOutcome(outcome.status, outcome.summary); + }; const listThreads = async () => { threads = await fetchLocalThreads(client, threadIndex); @@ -223,6 +227,7 @@ export async function runInteractive({ const sequence = Number(event.sequence) || 0; if (sequence <= cursor) continue; cursor = sequence; + applyPaneEvent(event); renderInteractiveEvent(interactiveOutput, event); } return events; @@ -270,6 +275,7 @@ export async function runInteractive({ const sequence = Number(event.sequence) || 0; if (sequence <= cursor) return; cursor = sequence; + applyPaneEvent(event); renderInteractiveEvent(interactiveOutput, event); if (new Set(["assistant_message", "question", "session_interrupted"]).has(event.type)) { monitor?.controller.abort(); @@ -310,6 +316,7 @@ export async function runInteractive({ await stopMonitor(); await stopThreadConnection(); current = response.value.thread; + agentPane.setOutcome("", ""); agentPane.setThread(current); cursor = 0; stdout.write(`\nOpened ${current.id} [${current.state}] — ${current.repository}\n`); @@ -363,6 +370,7 @@ export async function runInteractive({ await stopMonitor(); await stopThreadConnection(); current = created.value.thread; + agentPane.setOutcome("", ""); agentPane.setThread(current); cursor = 0; threads = [...threads.filter((thread) => thread.id !== current.id), current]; @@ -381,6 +389,7 @@ export async function runInteractive({ const sequence = Number(routed.event.sequence) || 0; if (sequence > cursor) { cursor = sequence; + applyPaneEvent(routed.event); renderInteractiveEvent(interactiveOutput, routed.event); } } @@ -667,7 +676,28 @@ function claudeStreamProgress(lines) { return { detected, progress: progress.join("\n") }; } -const inactiveAgentStatuses = new Set(["done", "completed", "closed", "cancelled", "canceled", "failed", "released", "skipped", "finalized", "killed", "missing"]); +const inactiveAgentStatuses = new Set(["complete", "done", "completed", "closed", "cancelled", "canceled", "failed", "released", "skipped", "finalized", "killed", "missing"]); + +function paneOutcomeForEvent(event) { + const type = String(event?.type || ""); + if (type === "user_message") return { status: "", summary: "" }; + if (type === "session_started") return { status: "running", summary: "" }; + if (type === "assistant_message") return { status: "complete", summary: compactOutcomeSummary(event) }; + if (type === "question") return { status: "waiting", summary: compactOutcomeSummary(event) }; + if (type === "session_interrupted") return { status: "interrupted", summary: compactOutcomeSummary(event) }; + if (type === "progress") return { status: "working", summary: compactOutcomeSummary(event) }; + if (type === "session_completed") return { status: "complete", summary: undefined }; + return null; +} + +function compactOutcomeSummary(event) { + const value = String(event?.payload?.text || event?.payload?.report || "") + .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") + .replace(/[`*_#>]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return value.slice(0, 240); +} export function renderAgentPane(agents, { columns = 80, @@ -676,11 +706,14 @@ export function renderAgentPane(agents, { thread = null, executionStatus = "", agentSnapshot = null, + outcomeStatus = "", + taskSummary = "", } = {}) { const values = Array.isArray(agents) ? agents : []; - const orchestratorStatus = executionStatus || thread?.state || "idle"; + const orchestratorStatus = outcomeStatus || executionStatus || thread?.state || "idle"; const connection = connectionState === "connected" ? "" : ` · ${connectionState}`; const rows = [`${agentStatusGlyph(orchestratorStatus)} orchestrator · ${orchestratorStatus}${connection}`]; + if (taskSummary && rows.length < maxRows) rows.push(` ↳ ${taskSummary}`); const agentCapacity = Math.max(0, Math.floor((maxRows - rows.length) / 2)); const visible = values.slice(0, agentCapacity); visible.forEach((agent, index) => { @@ -724,6 +757,8 @@ function createAgentPane(stdout, { onDraw = () => {} } = {}) { let thread = null; let executionStatus = ""; let agentSnapshot = null; + let outcomeStatus = ""; + let taskSummary = ""; let panel = null; let lastFrame = ""; @@ -753,6 +788,8 @@ function createAgentPane(stdout, { onDraw = () => {} } = {}) { thread, executionStatus, agentSnapshot, + outcomeStatus, + taskSummary, }); const frame = JSON.stringify({ rows, columns, height, lines }); if (frame === lastFrame) return; @@ -782,6 +819,11 @@ function createAgentPane(stdout, { onDraw = () => {} } = {}) { executionStatus = nextExecutionStatus || ""; draw(); }, + setOutcome(nextStatus, nextSummary) { + if (nextStatus !== undefined) outcomeStatus = nextStatus || ""; + if (nextSummary !== undefined) taskSummary = nextSummary || ""; + draw(); + }, close: clear, }; } diff --git a/client/test/client.test.mjs b/client/test/client.test.mjs index b9fe0a0..07fcaec 100644 --- a/client/test/client.test.mjs +++ b/client/test/client.test.mjs @@ -505,6 +505,19 @@ test("subagent pane distinguishes idle, discovery, and unavailable snapshots", ( })[1], "└─ ◌ subagent status unavailable"); }); +test("completed orchestrator pane retains a concise outcome summary", () => { + const lines = renderAgentPane([], { + columns: 90, + maxRows: 5, + thread: { id: "thread-complete", state: "idle" }, + outcomeStatus: "complete", + taskSummary: "Found open PR #421 and returned its review status.", + }); + assert.equal(lines[0], "✓ orchestrator · complete"); + assert.equal(lines[1], " ↳ Found open PR #421 and returned its review status."); + assert.equal(lines[2], "└─ ○ no active agents"); +}); + test("asynchronous status redraw restores the active input prompt", async () => { const sessionFile = await sessionFixture(); const output = ttyWriter(); @@ -558,5 +571,7 @@ test("asynchronous status redraw restores the active input prompt", async () => assert.match(output.output, /● orchestrator · running/); assert.match(output.output, /\r\u001b\[2K\nassistant> Still working/); + assert.match(output.output, /✓ orchestrator · complete/); + assert.match(output.output, /↳ Still working/); assert.ok(prompts.some((prompt) => prompt.label === "› " && prompt.preserveCursor === true)); }); From 31d0f0d70690ff5942336213011c9395df621e30 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 30 Aug 2026 01:13:59 -0700 Subject: [PATCH 5/8] Prioritize concrete result summaries --- client/src/client.mjs | 24 ++++++++++++++++++------ client/test/client.test.mjs | 12 +++++++++--- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/client/src/client.mjs b/client/src/client.mjs index 4c8ddda..9653c91 100644 --- a/client/src/client.mjs +++ b/client/src/client.mjs @@ -691,12 +691,24 @@ function paneOutcomeForEvent(event) { } function compactOutcomeSummary(event) { - const value = String(event?.payload?.text || event?.payload?.report || "") - .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") - .replace(/[`*_#>]+/g, " ") - .replace(/\s+/g, " ") - .trim(); - return value.slice(0, 240); + const lines = String(event?.payload?.text || event?.payload?.report || "") + .split(/\r?\n/) + .map((line) => line + .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") + .replace(/^\s*#{1,6}\s*/, "") + .replace(/[`*_>]+/g, "") + .replace(/^\s*[-•]\s*/, "") + .replace(/\s+/g, " ") + .trim()) + .filter(Boolean); + const meaningful = lines.filter((line) => !/^(?:result|summary|outcome|answer|final answer)$/i.test(line)); + const preferred = meaningful.find((line) => /\b(?:most recently|latest)\b/i.test(line)) + || meaningful.find((line) => /^(?:result|answer|outcome)\s*:/i.test(line)) + || meaningful.find((line) => /\b(?:found|fixed|created|updated|merged|deployed|completed)\b/i.test(line)) + || meaningful[0] + || lines[0] + || ""; + return preferred.slice(0, 240); } export function renderAgentPane(agents, { diff --git a/client/test/client.test.mjs b/client/test/client.test.mjs index 07fcaec..b999a70 100644 --- a/client/test/client.test.mjs +++ b/client/test/client.test.mjs @@ -536,7 +536,13 @@ test("asynchronous status redraw restores the active input prompt", async () => }))); threadSocket.emit("message", Buffer.from(JSON.stringify({ type: "event", - event: { sequence: 1, type: "assistant_message", payload: { text: "Still working" } }, + event: { + sequence: 1, + type: "assistant_message", + payload: { + text: "# Open PRs — movement-network/aptos-core\n\nChecked current open pull requests.\n\n**Most recently opened:** **#421** — fix: remove global waypoint signature-verification bypass", + }, + }, }))); setImmediate(() => resolve("/quit")); }); @@ -570,8 +576,8 @@ test("asynchronous status redraw restores the active input prompt", async () => }); assert.match(output.output, /● orchestrator · running/); - assert.match(output.output, /\r\u001b\[2K\nassistant> Still working/); + assert.match(output.output, /assistant> # Open PRs/); assert.match(output.output, /✓ orchestrator · complete/); - assert.match(output.output, /↳ Still working/); + assert.match(output.output, /↳ Most recently opened: #421 — fix: remove global waypoint/); assert.ok(prompts.some((prompt) => prompt.label === "› " && prompt.preserveCursor === true)); }); From 7f27136edc93da7b0731313a874453343bf6b20b Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 30 Aug 2026 01:15:58 -0700 Subject: [PATCH 6/8] Wrap completed task summaries --- client/README.md | 5 +++-- client/src/client.mjs | 38 ++++++++++++++++++++++++++++++++++++- client/test/client.test.mjs | 17 +++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/client/README.md b/client/README.md index 3e98dde..1847f29 100644 --- a/client/README.md +++ b/client/README.md @@ -31,8 +31,9 @@ execution is active. The interactive TTY reserves a small bottom pane for each subagent's state, role, and current progress as a compact graph rooted at the orchestrator. The stable `› ` input area remains available while agents work; asynchronous output redraws it without discarding partially typed follow-up -text. When an execution finishes, the pane keeps a one-line summary of the -latest public outcome and labels the orchestrator `complete` instead of reducing +text. When an execution finishes, the pane keeps a concise result summary, +wrapped to at most three terminal lines, showing the latest public outcome and +labels the orchestrator `complete` instead of reducing the result to `idle`. HTTP event replay repairs gaps after a disconnect and reconstructs that summary when a thread is reopened. diff --git a/client/src/client.mjs b/client/src/client.mjs index 9653c91..9848fa5 100644 --- a/client/src/client.mjs +++ b/client/src/client.mjs @@ -725,7 +725,14 @@ export function renderAgentPane(agents, { const orchestratorStatus = outcomeStatus || executionStatus || thread?.state || "idle"; const connection = connectionState === "connected" ? "" : ` · ${connectionState}`; const rows = [`${agentStatusGlyph(orchestratorStatus)} orchestrator · ${orchestratorStatus}${connection}`]; - if (taskSummary && rows.length < maxRows) rows.push(` ↳ ${taskSummary}`); + if (taskSummary && rows.length < maxRows) { + rows.push(...wrapTerminalText(taskSummary, { + columns, + firstPrefix: " ↳ ", + continuationPrefix: " ", + maxLines: Math.min(3, maxRows - rows.length), + })); + } const agentCapacity = Math.max(0, Math.floor((maxRows - rows.length) / 2)); const visible = values.slice(0, agentCapacity); visible.forEach((agent, index) => { @@ -762,6 +769,35 @@ function truncateTerminalLine(value, columns) { return width <= 3 ? line.slice(0, width) : `${line.slice(0, width - 3)}...`; } +function wrapTerminalText(value, { + columns, + firstPrefix = "", + continuationPrefix = firstPrefix, + maxLines = 3, +} = {}) { + const width = Math.max(8, Number(columns) || 80); + let remaining = String(value || "").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim(); + const lines = []; + while (remaining && lines.length < maxLines) { + const prefix = lines.length === 0 ? firstPrefix : continuationPrefix; + const available = Math.max(1, width - prefix.length); + if (remaining.length <= available) { + lines.push(prefix + remaining); + remaining = ""; + break; + } + let split = remaining.lastIndexOf(" ", available); + if (split <= 0) split = available; + lines.push(prefix + remaining.slice(0, split).trimEnd()); + remaining = remaining.slice(split).trimStart(); + } + if (remaining && lines.length) { + const last = lines.length - 1; + lines[last] = truncateTerminalLine(`${lines[last]}…`, width); + } + return lines; +} + function createAgentPane(stdout, { onDraw = () => {} } = {}) { const enabled = Boolean(stdout?.isTTY && Number(stdout.rows) >= 8); let agents = []; diff --git a/client/test/client.test.mjs b/client/test/client.test.mjs index b999a70..e0e4f0f 100644 --- a/client/test/client.test.mjs +++ b/client/test/client.test.mjs @@ -518,6 +518,23 @@ test("completed orchestrator pane retains a concise outcome summary", () => { assert.equal(lines[2], "└─ ○ no active agents"); }); +test("completed outcome summary wraps within the terminal width", () => { + const lines = renderAgentPane([], { + columns: 52, + maxRows: 6, + thread: { id: "thread-complete", state: "idle" }, + outcomeStatus: "complete", + taskSummary: "Latest open PR: #421 — fix: remove global waypoint signature-verification bypass from the live consensus path", + }); + assert.deepEqual(lines.slice(0, 4), [ + "✓ orchestrator · complete", + " ↳ Latest open PR: #421 — fix: remove global", + " waypoint signature-verification bypass from the", + " live consensus path", + ]); + assert.ok(lines.every((line) => line.length <= 52)); +}); + test("asynchronous status redraw restores the active input prompt", async () => { const sessionFile = await sessionFixture(); const output = ttyWriter(); From 59ad0cceb65c47ee67d91bb493ac333371cf98b4 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 30 Aug 2026 01:31:00 -0700 Subject: [PATCH 7/8] Include result details after summary headings --- client/src/client.mjs | 46 +++++++++++++++++++++++++------------ client/test/client.test.mjs | 4 ++-- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/client/src/client.mjs b/client/src/client.mjs index 9848fa5..717dfed 100644 --- a/client/src/client.mjs +++ b/client/src/client.mjs @@ -691,24 +691,40 @@ function paneOutcomeForEvent(event) { } function compactOutcomeSummary(event) { - const lines = String(event?.payload?.text || event?.payload?.report || "") + const entries = String(event?.payload?.text || event?.payload?.report || "") .split(/\r?\n/) - .map((line) => line - .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") - .replace(/^\s*#{1,6}\s*/, "") - .replace(/[`*_>]+/g, "") - .replace(/^\s*[-•]\s*/, "") - .replace(/\s+/g, " ") - .trim()) - .filter(Boolean); - const meaningful = lines.filter((line) => !/^(?:result|summary|outcome|answer|final answer)$/i.test(line)); - const preferred = meaningful.find((line) => /\b(?:most recently|latest)\b/i.test(line)) - || meaningful.find((line) => /^(?:result|answer|outcome)\s*:/i.test(line)) - || meaningful.find((line) => /\b(?:found|fixed|created|updated|merged|deployed|completed)\b/i.test(line)) - || meaningful[0] + .map((source) => { + const tableRow = /^\s*\|/.test(source) && /\|\s*$/.test(source); + let text = source + .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") + .replace(/^\s*#{1,6}\s*/, "") + .replace(/[`*_>]+/g, "") + .replace(/^\s*[-•]\s*/, ""); + if (tableRow) { + text = text.replace(/^\s*\|\s*/, "").replace(/\s*\|\s*$/, "").replace(/\s*\|\s*/g, " — "); + } + return { text: text.replace(/\s+/g, " ").trim(), tableRow }; + }) + .filter(({ text }) => text); + const meaningful = entries.filter(({ text }) => + !/^(?:result|summary|outcome|answer|final answer)$/i.test(text) + && !/^(?:-+)(?:\s+—\s+-+)*$/.test(text)); + const latestIndex = meaningful.findIndex(({ text }) => /\b(?:most recently|latest)\b/i.test(text)); + if (latestIndex >= 0) { + const label = meaningful[latestIndex].text; + if (/\bpr\b/i.test(label) && !/#\d+\b/.test(label)) { + const detail = meaningful.slice(latestIndex + 1).find(({ text }) => /#\d+\b/.test(text)); + if (detail) return `${label.replace(/[::]\s*$/, "")}: ${detail.text}`.slice(0, 240); + } + } + const lines = meaningful.map(({ text }) => text); + const preferred = lines.find((line) => /\b(?:most recently|latest)\b/i.test(line)) + || lines.find((line) => /^(?:result|answer|outcome)\s*:/i.test(line)) + || lines.find((line) => /\b(?:found|fixed|created|updated|merged|deployed|completed)\b/i.test(line)) || lines[0] + || entries[0]?.text || ""; - return preferred.slice(0, 240); + return String(preferred?.text || preferred).slice(0, 240); } export function renderAgentPane(agents, { diff --git a/client/test/client.test.mjs b/client/test/client.test.mjs index e0e4f0f..4566665 100644 --- a/client/test/client.test.mjs +++ b/client/test/client.test.mjs @@ -557,7 +557,7 @@ test("asynchronous status redraw restores the active input prompt", async () => sequence: 1, type: "assistant_message", payload: { - text: "# Open PRs — movement-network/aptos-core\n\nChecked current open pull requests.\n\n**Most recently opened:** **#421** — fix: remove global waypoint signature-verification bypass", + text: "# Open PRs — movement-network/aptos-core\n\nChecked current open pull requests.\n\n## Latest opened PR\n\n| PR | Title |\n| --- | --- |\n| **#421** | fix: remove global waypoint signature-verification bypass |", }, }, }))); @@ -595,6 +595,6 @@ test("asynchronous status redraw restores the active input prompt", async () => assert.match(output.output, /● orchestrator · running/); assert.match(output.output, /assistant> # Open PRs/); assert.match(output.output, /✓ orchestrator · complete/); - assert.match(output.output, /↳ Most recently opened: #421 — fix: remove global waypoint/); + assert.match(output.output, /↳ Latest opened PR: #421 — fix: remove global waypoint/); assert.ok(prompts.some((prompt) => prompt.label === "› " && prompt.preserveCursor === true)); }); From 2e2cf3603975df57fa0b05cbefd58bbf6466b003 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 30 Aug 2026 01:57:08 -0700 Subject: [PATCH 8/8] Complete bounded clarifications without retries --- client/README.md | 12 ++- client/src/client.mjs | 11 ++- client/test/client.test.mjs | 8 +- control-server/src/server.mjs | 29 +++++- control-server/src/session-runtime.mjs | 25 ++++- control-server/test/session-runtime.test.mjs | 24 +++++ docs/architecture/system-architecture.md | 6 +- src/runtime.rs | 99 ++++++++++++++++++-- tests/lifecycle.sh | 30 ++++++ tests/run.sh | 15 +++ 10 files changed, 234 insertions(+), 25 deletions(-) diff --git a/client/README.md b/client/README.md index 1847f29..c0d804b 100644 --- a/client/README.md +++ b/client/README.md @@ -29,12 +29,16 @@ events, thread state, heartbeats, and bounded subagent status. A separate session WebSocket carries live orchestrator terminal output only while an execution is active. The interactive TTY reserves a small bottom pane for each subagent's state, role, and current progress as a compact graph rooted at the -orchestrator. The stable `› ` input area remains available while agents work; +orchestrator. Before the first delegation, the graph labels the orchestrator +`planning` and says that no agents have been delegated yet; it does not imply +that a separate discovery operation is running. The stable `› ` input area +remains available while agents work; asynchronous output redraws it without discarding partially typed follow-up text. When an execution finishes, the pane keeps a concise result summary, -wrapped to at most three terminal lines, showing the latest public outcome and -labels the orchestrator `complete` instead of reducing -the result to `idle`. HTTP event replay repairs gaps after a disconnect and +wrapped to at most three terminal lines, showing the latest public outcome, and +labels the orchestrator `complete` instead of reducing the result to `idle`. +Bounded clarification responses are shown as questions and wait for ordinary +follow-up input. HTTP event replay repairs gaps after a disconnect and reconstructs that summary when a thread is reopened. The first command securely prompts for the password. For a non-interactive diff --git a/client/src/client.mjs b/client/src/client.mjs index 717dfed..72adc16 100644 --- a/client/src/client.mjs +++ b/client/src/client.mjs @@ -738,7 +738,11 @@ export function renderAgentPane(agents, { taskSummary = "", } = {}) { const values = Array.isArray(agents) ? agents : []; - const orchestratorStatus = outcomeStatus || executionStatus || thread?.state || "idle"; + const executionState = outcomeStatus || executionStatus || thread?.state || "idle"; + const planning = !values.length + && !agentSnapshot?.error + && new Set(["running", "working", "in-progress"]).has(String(executionState).toLowerCase()); + const orchestratorStatus = planning ? "planning" : executionState; const connection = connectionState === "connected" ? "" : ` · ${connectionState}`; const rows = [`${agentStatusGlyph(orchestratorStatus)} orchestrator · ${orchestratorStatus}${connection}`]; if (taskSummary && rows.length < maxRows) { @@ -761,9 +765,8 @@ export function renderAgentPane(agents, { }); if (values.length > visible.length && rows.length < maxRows) rows.push(`└─ … ${values.length - visible.length} more`); if (!values.length && maxRows > 1) { - const active = new Set(["queued", "starting", "running", "working", "in-progress"]).has(String(orchestratorStatus).toLowerCase()); if (agentSnapshot?.error) rows.push("└─ ◌ subagent status unavailable"); - else if (active) rows.push("└─ ◌ discovering subagents"); + else if (planning) rows.push("└─ ○ no delegated agents yet"); else rows.push("└─ ○ no active agents"); } return rows.slice(0, maxRows).map((line) => truncateTerminalLine(line, columns)); @@ -774,7 +777,7 @@ function agentStatusGlyph(status) { if (new Set(["failed", "killed", "cancelled", "canceled", "delivery-blocked", "interrupted"]).has(value)) return "×"; if (inactiveAgentStatuses.has(value)) return "✓"; if (new Set(["starting", "queued", "connecting", "restoring", "waiting"]).has(value)) return "◌"; - if (new Set(["running", "working", "in-progress"]).has(value)) return "●"; + if (new Set(["running", "working", "in-progress", "planning"]).has(value)) return "●"; return "○"; } diff --git a/client/test/client.test.mjs b/client/test/client.test.mjs index 4566665..626cb37 100644 --- a/client/test/client.test.mjs +++ b/client/test/client.test.mjs @@ -486,17 +486,19 @@ test("subagent pane keeps the open thread and orchestrator status visible", () = assert.equal(lines[2], " ↳ Inspecting the runtime"); }); -test("subagent pane distinguishes idle, discovery, and unavailable snapshots", () => { +test("subagent pane distinguishes idle, orchestrator planning, and unavailable snapshots", () => { assert.equal(renderAgentPane([], { columns: 80, maxRows: 4, thread: { id: "thread-idle", state: "idle" }, })[1], "└─ ○ no active agents"); - assert.equal(renderAgentPane([], { + const planning = renderAgentPane([], { columns: 80, maxRows: 4, thread: { id: "thread-running", state: "running" }, - })[1], "└─ ◌ discovering subagents"); + }); + assert.equal(planning[0], "● orchestrator · planning"); + assert.equal(planning[1], "└─ ○ no delegated agents yet"); assert.equal(renderAgentPane([], { columns: 80, maxRows: 4, diff --git a/control-server/src/server.mjs b/control-server/src/server.mjs index 347e65e..8a0e1a8 100644 --- a/control-server/src/server.mjs +++ b/control-server/src/server.mjs @@ -22,6 +22,7 @@ import { findActiveSession, normalizeWorkerReport, ownsThreadProjection, + responseTypeForMessage, scopedThreadTranscript, selectFinalMessage, sessionControlInvocation, @@ -238,14 +239,27 @@ function activeWorkflow(id) { } function workflowPhase(id) { + return workflowLifecycleValue(id, "phase"); +} + +function workflowLifecycleValue(id, key) { const workflow = activeWorkflow(id); if (!workflow) return ""; try { const lifecycle = fs.readFileSync(path.join(sessionStateDir(id), "workflows", workflow, "lifecycle", "lifecycle.env"), "utf8"); - return lifecycle.split("\n").find((line) => line.startsWith("phase="))?.slice(6).trim() || ""; + const prefix = `${key}=`; + return lifecycle.split("\n").find((line) => line.startsWith(prefix))?.slice(prefix.length).trim() || ""; } catch { return ""; } } +function workflowCompletionRoute(id) { + const result = workflowLifecycleValue(id, "candidate_diff_hash"); + if (result.startsWith("direct-response:")) return "direct-response"; + if (result.startsWith("read-only:")) return "read-only"; + if (result.startsWith("external-only:")) return "external-only"; + return result ? "source" : null; +} + function traceReferences(id) { const root = traceRoot(id); const references = []; @@ -272,6 +286,7 @@ function writeTraceSummary(id, status) { try { result = conciseTail(fs.readFileSync(path.join(sessionStateDir(id), "orchestrator-result.md"), "utf8"), 80, 6000); } catch {} try { fallback = conciseTail(fs.readFileSync(path.join(sessionStateDir(id), "orchestrator-last-message.txt"), "utf8"), 40, 6000); } catch {} const finalMessage = selectFinalMessage(result, fallback); + const completionRoute = workflowCompletionRoute(id); const references = traceReferences(id); const report = { taskId: id, @@ -279,6 +294,8 @@ function writeTraceSummary(id, status) { status, completedAt: registry.sessions[id]?.completedAt || null, finalMessage, + completionRoute, + responseType: responseTypeForMessage(finalMessage, completionRoute), traceReferences: references, }; const markdown = [ @@ -415,9 +432,12 @@ async function writeGatewayReport(id, report) { function readLocalWorkerReport(id) { try { + const finalReport = JSON.parse(fs.readFileSync(path.join(traceRoot(id), "final-report.json"), "utf8")); return normalizeWorkerReport({ report: fs.readFileSync(path.join(traceRoot(id), "final-report.md"), "utf8"), transcript: JSON.parse(fs.readFileSync(path.join(traceRoot(id), "transcript-index.json"), "utf8")), + message: finalReport.finalMessage, + completionRoute: finalReport.completionRoute, }); } catch { return null; } } @@ -712,8 +732,11 @@ async function projectSessionToThread(id, status, reportReader = readGatewayRepo sessionId: id, generation: record.leaseGeneration, eventId: `final-${id}`, - type: "assistant_message", - payload: { text: report.report, transcript: scopedThreadTranscript(id, report.transcript) }, + type: report.responseType, + payload: { + text: report.responseType === "question" && report.message ? report.message : report.report, + transcript: scopedThreadTranscript(id, report.transcript), + }, }); await threadStore.markSessionFinishing({ threadId: record.threadId, sessionId: id, generation: record.leaseGeneration }); const finalized = await threadStore.finalizeSession({ threadId: record.threadId, sessionId: id, generation: record.leaseGeneration }); diff --git a/control-server/src/session-runtime.mjs b/control-server/src/session-runtime.mjs index 661475c..fc24057 100644 --- a/control-server/src/session-runtime.mjs +++ b/control-server/src/session-runtime.mjs @@ -59,6 +59,19 @@ export function selectFinalMessage(result, fallback) { return String(result || "").trim() || String(fallback || "").trim(); } +export function responseTypeForMessage(message, completionRoute = "") { + if (completionRoute !== "direct-response") return "assistant_message"; + const text = String(message || "").trim(); + const questions = [...text].filter((character) => character === "?" || character === "?").length; + const tail = text.replace(/[\s*_`"')\]]+$/g, ""); + return Buffer.byteLength(text, "utf8") <= 2000 + && questions >= 1 + && questions <= 3 + && (tail.endsWith("?") || tail.endsWith("?")) + ? "question" + : "assistant_message"; +} + export async function submitLocalFollowup({ id, text, actor, live, sendInput, restart, sessionView }) { if (live) { await sendInput(id, text); @@ -72,7 +85,17 @@ export function normalizeWorkerReport(value) { if (Buffer.byteLength(value.report, "utf8") > 64 * 1024) return null; const transcript = value.transcript === undefined ? null : value.transcript; if (Buffer.byteLength(JSON.stringify(transcript), "utf8") > 64 * 1024) return null; - return { report: value.report, transcript }; + const message = typeof value.message === "string" && value.message.trim() ? value.message.trim() : null; + if (message && Buffer.byteLength(message, "utf8") > 6000) return null; + const completionRoute = new Set(["direct-response", "read-only", "external-only", "source"]) + .has(value.completionRoute) ? value.completionRoute : null; + return { + report: value.report, + transcript, + message, + completionRoute, + responseType: responseTypeForMessage(message, completionRoute), + }; } export function scopedThreadTranscript(sessionId, transcript) { diff --git a/control-server/test/session-runtime.test.mjs b/control-server/test/session-runtime.test.mjs index 6a41fdd..921c09c 100644 --- a/control-server/test/session-runtime.test.mjs +++ b/control-server/test/session-runtime.test.mjs @@ -9,6 +9,7 @@ import { findActiveSession, normalizeWorkerReport, ownsThreadProjection, + responseTypeForMessage, scopedThreadTranscript, selectFinalMessage, sessionControlInvocation, @@ -80,11 +81,34 @@ test("completed session reports prefer the explicit bounded caller result", () = assert.deepEqual(normalizeWorkerReport({ report: "complete result", transcript: { taskId: "task-1" } }), { report: "complete result", transcript: { taskId: "task-1" }, + message: null, + completionRoute: null, + responseType: "assistant_message", + }); + assert.deepEqual(normalizeWorkerReport({ + report: "completed report", + transcript: null, + message: "Which repository should I check?", + completionRoute: "direct-response", + }), { + report: "completed report", + transcript: null, + message: "Which repository should I check?", + completionRoute: "direct-response", + responseType: "question", }); assert.equal(normalizeWorkerReport({ report: "" }), null); assert.equal(normalizeWorkerReport({ report: "x".repeat(64 * 1024 + 1) }), null); }); +test("only bounded direct-response questions project as clarification events", () => { + assert.equal(responseTypeForMessage("Which repository should I check?", "direct-response"), "question"); + assert.equal(responseTypeForMessage("你希望检查哪个仓库?", "direct-response"), "question"); + assert.equal(responseTypeForMessage("The latest PR is #421.", "direct-response"), "assistant_message"); + assert.equal(responseTypeForMessage("Which repository should I check?", "external-only"), "assistant_message"); + assert.equal(responseTypeForMessage("One? Two? Three? Four?", "direct-response"), "assistant_message"); +}); + test("thread transcript references remain bound to their originating session", () => { assert.deepEqual(scopedThreadTranscript("session-a", { taskId: "session-a", diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 5be346f..285c9b4 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -429,7 +429,11 @@ selects the corresponding mechanical completion gate: clarification without launching another role. It requires a clean repository, no external operation, no role launch, no active workflow obligation, and no source lifecycle state. Because it produces no independently mutable artifact - or external effect, it does not require a reviewer. + or external effect, it does not require a reviewer. When a successful headless + orchestrator pass exits with a bounded clarification question but omits the + explicit completion command, the runtime submits that exact question to the + same supervisor-owned direct-response gate. The adapter may not mark the + workflow complete itself; a failed gate leaves the workflow incomplete. - A read-only investigation route may launch repository readers with the selected repository as their working directory. The supervisor denies source writes, records the exact read-only launch manifests and sealed outputs, and diff --git a/src/runtime.rs b/src/runtime.rs index 0e760f3..acaefeb 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1106,6 +1106,19 @@ fn write_bootstrap( }; text.push_str(&command); text.push('\n'); + if headless { + let executable = environment + .get("MULTIAGENT_BIN") + .ok_or_else(|| "missing MULTIAGENT_BIN in launch environment".to_string())?; + text.push_str("agent_status=$?\n"); + text.push_str("if [[ $agent_status -eq 0 ]]; then\n"); + text.push_str(&format!( + " {} orchestrator complete --auto-clarification --result-file {} >/dev/null 2>&1 || true\n", + shell_escape(executable), + shell_escape(&last_message.display().to_string()) + )); + text.push_str("fi\nexit \"$agent_status\"\n"); + } atomic_write(path, &text, "orchestrator bootstrap")?; set_executable(path, 0o700)?; Ok(()) @@ -1119,7 +1132,7 @@ fn resume_user_turn(original_task: Option<&str>, followup: Option<&str>) -> Stri let mut turn = String::from( "Continue this same execution session after a prior headless pass exited before lifecycle completion.\n\ Reconcile the persisted workflow and subagent state against every unfinished requirement in the authenticated original task.\n\ - A prior prose answer is not completion: finish the work, satisfy the required lifecycle gates, and produce the final answer.\n", + A prior prose answer is not completion. If one bounded clarification is still required, persist that exact question and use the direct-response completion route; do not guess the missing user choice. Otherwise finish the work, satisfy the required lifecycle gates, and produce the final answer.\n", ); if let Some(task) = original_task.map(str::trim).filter(|task| !task.is_empty()) { turn.push_str("\n## Authenticated Original Task\n\n"); @@ -1159,13 +1172,15 @@ pub fn orchestrator(args: &[String]) -> Result { .iter() .any(|arg| matches!(arg.as_str(), "-h" | "--help")) { - println!("Usage:\n multiagent orchestrator complete\n multiagent orchestrator complete --direct-response --result-file PATH\n multiagent orchestrator complete --read-only --result-file PATH --reviewer NAME\n multiagent orchestrator complete --external-only --result-file PATH\n\nRuns the supervisor completion gates. Shortcut and external-only completion require a self-contained caller result under MULTIAGENT_STATE_DIR."); + println!("Usage:\n multiagent orchestrator complete\n multiagent orchestrator complete --direct-response --result-file PATH\n multiagent orchestrator complete --clarification --result-file PATH\n multiagent orchestrator complete --auto-clarification --result-file PATH\n multiagent orchestrator complete --read-only --result-file PATH --reviewer NAME\n multiagent orchestrator complete --external-only --result-file PATH\n\nRuns the supervisor completion gates. Shortcut and external-only completion require a self-contained caller result under MULTIAGENT_STATE_DIR."); return Ok(ExitCode::SUCCESS); } #[derive(Clone, Copy)] enum CompletionRoute<'a> { Source, Direct(&'a str), + Clarification(&'a str), + AutoClarification(&'a str), ReadOnly { result: &'a str, reviewer: &'a str }, External(&'a str), } @@ -1183,6 +1198,18 @@ pub fn orchestrator(args: &[String]) -> Result { && args[2] == "--result-file" { CompletionRoute::Direct(&args[3]) + } else if args.len() == 4 + && args[0] == "complete" + && args[1] == "--clarification" + && args[2] == "--result-file" + { + CompletionRoute::Clarification(&args[3]) + } else if args.len() == 4 + && args[0] == "complete" + && args[1] == "--auto-clarification" + && args[2] == "--result-file" + { + CompletionRoute::AutoClarification(&args[3]) } else if args.len() == 6 && args[0] == "complete" && args[1] == "--read-only" @@ -1198,9 +1225,20 @@ pub fn orchestrator(args: &[String]) -> Result { }; let result_file = match route { CompletionRoute::Source => None, - CompletionRoute::Direct(path) | CompletionRoute::External(path) => Some(path), + CompletionRoute::Direct(path) + | CompletionRoute::Clarification(path) + | CompletionRoute::AutoClarification(path) + | CompletionRoute::External(path) => Some(path), CompletionRoute::ReadOnly { result, .. } => Some(result), }; + if let CompletionRoute::Clarification(path) = route { + validate_bounded_clarification(path)?; + } + if let CompletionRoute::AutoClarification(path) = route { + if !is_bounded_clarification(&validated_orchestrator_result(path)?) { + return Ok(ExitCode::SUCCESS); + } + } if let Some(path) = result_file { persist_orchestrator_result(path)?; } @@ -1209,7 +1247,9 @@ pub fn orchestrator(args: &[String]) -> Result { .ok_or_else(|| "lifecycle enforcement requires MULTIAGENT_WORKFLOW_ID".to_string())?; let diff = match route { CompletionRoute::Source => crate::workflow::supervisor_complete(&workflow_id)?, - CompletionRoute::Direct(_) => { + CompletionRoute::Direct(_) + | CompletionRoute::Clarification(_) + | CompletionRoute::AutoClarification(_) => { crate::workflow::supervisor_complete_direct(&workflow_id)? } CompletionRoute::ReadOnly { reviewer, .. } => { @@ -1233,6 +1273,16 @@ pub fn orchestrator(args: &[String]) -> Result { } fn persist_orchestrator_result(path: &str) -> Result<(), String> { + let result = validated_orchestrator_result(path)?; + let state = config::state_dir()?; + atomic_write( + &state.join("orchestrator-result.md"), + &format!("{result}\n"), + "orchestrator result", + ) +} + +fn validated_orchestrator_result(path: &str) -> Result { const MAX_RESULT_BYTES: usize = 6_000; let state = config::state_dir()?; let canonical_state = @@ -1255,11 +1305,27 @@ fn persist_orchestrator_result(path: &str) -> Result<(), String> { if result.is_empty() { return Err("orchestrator result must not be blank".into()); } - atomic_write( - &state.join("orchestrator-result.md"), - &format!("{result}\n"), - "orchestrator result", - ) + Ok(result.to_string()) +} + +fn validate_bounded_clarification(path: &str) -> Result<(), String> { + let result = validated_orchestrator_result(path)?; + if !is_bounded_clarification(&result) { + return Err("automatic clarification completion requires one bounded question".into()); + } + Ok(()) +} + +fn is_bounded_clarification(result: &str) -> bool { + const MAX_CLARIFICATION_BYTES: usize = 2_000; + let question_count = result.matches(['?', '?']).count(); + let tail = result.trim_end_matches(|character: char| { + character.is_whitespace() || matches!(character, '*' | '_' | '`' | '"' | '\'' | ')' | ']') + }); + !result.trim().is_empty() + && result.len() <= MAX_CLARIFICATION_BYTES + && (1..=3).contains(&question_count) + && (tail.ends_with('?') || tail.ends_with('?')) } pub fn status(args: &[String]) -> Result { @@ -5417,6 +5483,21 @@ mod tests { assert!(turn.contains("Also report the current branch")); assert!(turn.contains("additive unless it explicitly replaces")); assert!(turn.contains("lifecycle gates")); + assert!(turn.contains("do not guess the missing user choice")); + } + + #[test] + fn automatic_clarification_accepts_only_bounded_questions() { + assert!(is_bounded_clarification( + "Which repository should I check — prod-mcp, aptos-core, or both?" + )); + assert!(is_bounded_clarification("你希望检查哪个仓库?")); + assert!(!is_bounded_clarification("The latest PR is #421.")); + assert!(!is_bounded_clarification(&format!( + "{}?", + "x".repeat(2_000) + ))); + assert!(!is_bounded_clarification("One? Two? Three? Four?")); } #[test] diff --git a/tests/lifecycle.sh b/tests/lifecycle.sh index d6263e0..61206c5 100755 --- a/tests/lifecycle.sh +++ b/tests/lifecycle.sh @@ -397,6 +397,36 @@ assert_contains "$DIRECT_STATE/workflows/WF-DIRECT/lifecycle/lifecycle.env" "pha assert_contains "$DIRECT_STATE/workflows/WF-DIRECT/lifecycle/events.log" "route=direct-response" assert_contains "$DIRECT_STATE/orchestrator-result.md" "The direct conversational answer." +CLARIFICATION_STATE="$TEST_TMP/clarification-state" +MULTIAGENT_STATE_DIR="$CLARIFICATION_STATE" MULTIAGENT_ORIGINAL_TASK_FILE="$SHORTCUT_TASK" \ + "$MULTIAGENT" workflow init WF-CLARIFICATION >/dev/null +CLARIFICATION_RESULT="$CLARIFICATION_STATE/clarification-result.md" +printf 'Which repository should I check — prod-mcp, aptos-core, or both?\n' \ + >"$CLARIFICATION_RESULT" +MULTIAGENT_ROOT="$SHORTCUT_REPO" MULTIAGENT_STATE_DIR="$CLARIFICATION_STATE" \ + MULTIAGENT_WORKFLOW_ID=WF-CLARIFICATION MULTIAGENT_RUN_ID=RUN-CLARIFICATION \ + MULTIAGENT_LIFECYCLE_ENFORCEMENT=1 \ + "$MULTIAGENT" orchestrator complete --auto-clarification --result-file "$CLARIFICATION_RESULT" \ + >"$TEST_TMP/clarification-shortcut.out" +assert_contains "$CLARIFICATION_STATE/workflows/WF-CLARIFICATION/lifecycle/lifecycle.env" "phase=complete" +assert_contains "$CLARIFICATION_STATE/workflows/WF-CLARIFICATION/lifecycle/events.log" "route=direct-response" +assert_contains "$CLARIFICATION_STATE/orchestrator-result.md" "Which repository should I check" + +NONQUESTION_STATE="$TEST_TMP/nonquestion-state" +MULTIAGENT_STATE_DIR="$NONQUESTION_STATE" MULTIAGENT_ORIGINAL_TASK_FILE="$SHORTCUT_TASK" \ + "$MULTIAGENT" workflow init WF-NONQUESTION >/dev/null +NONQUESTION_RESULT="$NONQUESTION_STATE/nonquestion-result.md" +printf 'This is not a clarification.\n' >"$NONQUESTION_RESULT" +if MULTIAGENT_ROOT="$SHORTCUT_REPO" MULTIAGENT_STATE_DIR="$NONQUESTION_STATE" \ + MULTIAGENT_WORKFLOW_ID=WF-NONQUESTION MULTIAGENT_RUN_ID=RUN-NONQUESTION \ + MULTIAGENT_LIFECYCLE_ENFORCEMENT=1 \ + "$MULTIAGENT" orchestrator complete --clarification --result-file "$NONQUESTION_RESULT" \ + >"$TEST_TMP/nonquestion-shortcut.out" 2>&1; then + echo "expected automatic clarification completion to reject a prose answer" >&2 + exit 1 +fi +assert_contains "$NONQUESTION_STATE/workflows/WF-NONQUESTION/lifecycle/lifecycle.env" "phase=pre-implementation" + READ_ONLY_STATE="$TEST_TMP/read-only-state" MULTIAGENT_STATE_DIR="$READ_ONLY_STATE" MULTIAGENT_ORIGINAL_TASK_FILE="$SHORTCUT_TASK" \ "$MULTIAGENT" workflow init WF-READ-ONLY >/dev/null diff --git a/tests/run.sh b/tests/run.sh index b416875..d219d2a 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -430,6 +430,21 @@ if [[ "$SOURCE_BOOTSTRAP_OUTPUT" != "source-complete" ]]; then printf '%s\n' "$SOURCE_BOOTSTRAP_OUTPUT" >&2 exit 1 fi + +HEADLESS_LAUNCH_STATE="$TMPDIR/launch-headless-state" +MOCK_TMUX_HAS_SESSION=0 \ + MULTIAGENT_AGENT_HEADLESS=1 \ + MULTIAGENT_SESSION="launch-headless" \ + MULTIAGENT_ROOT= \ + MULTIAGENT_PROMPT= \ + MULTIAGENT_STATE_DIR="$HEADLESS_LAUNCH_STATE" \ + MULTIAGENT_WRITE_POLICY="$TMPDIR/launch-headless-policy/write-policy.paths" \ + "$ROOT/launch.sh" --session launch-headless --root "$LAUNCH_TARGET" --no-attach \ + >"$TMPDIR/launch-headless.out" +HEADLESS_LAUNCH_BOOTSTRAP="$HEADLESS_LAUNCH_STATE/orchestrator-bootstrap.sh" +assert_file_contains "$HEADLESS_LAUNCH_BOOTSTRAP" "orchestrator complete --auto-clarification --result-file" +assert_file_contains "$HEADLESS_LAUNCH_BOOTSTRAP" 'exit "$agent_status"' + LAUNCH_WORKFLOW_ID="$(tr -d '\r\n' <"$LAUNCH_STATE/runtime_state/active-workflow-id")" assert_file_contains "$LAUNCH_STATE/workflows/$LAUNCH_WORKFLOW_ID/lifecycle/lifecycle.env" "phase=pre-implementation" if grep -Fq "$LAUNCH_TARGET/orchestrator_prompt.md" "$MOCK_TMUX_LOG" "$TMPDIR/launch.out" "$LAUNCH_BOOTSTRAP"; then