diff --git a/client/README.md b/client/README.md index cd188a3..c0d804b 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 @@ -26,8 +28,18 @@ 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. 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`. +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 caller, provide the password on stdin. Do not put it in a command argument: @@ -40,6 +52,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 @@ -49,7 +63,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 0d889de..72adc16 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,20 +174,40 @@ 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; 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 applyPaneEvent = (event) => { + const outcome = paneOutcomeForEvent(event); + if (outcome) agentPane.setOutcome(outcome.status, outcome.summary); + }; 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"); @@ -202,7 +227,8 @@ export async function runInteractive({ const sequence = Number(event.sequence) || 0; if (sequence <= cursor) continue; cursor = sequence; - renderInteractiveEvent(stdout, event); + applyPaneEvent(event); + renderInteractiveEvent(interactiveOutput, event); } return events; }; @@ -221,7 +247,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) => { @@ -238,15 +264,19 @@ 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), + onAgents: (agents, snapshot) => agentPane.render(agents, undefined, snapshot), onEvent: (event) => { if (current?.id !== threadId) return; const sequence = Number(event.sequence) || 0; if (sequence <= cursor) return; cursor = sequence; - renderInteractiveEvent(stdout, event); + applyPaneEvent(event); + renderInteractiveEvent(interactiveOutput, event); if (new Set(["assistant_message", "question", "session_interrupted"]).has(event.type)) { monitor?.controller.abort(); } @@ -263,7 +293,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 +316,8 @@ 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`); await replay({ all: true }); @@ -295,19 +327,24 @@ 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; 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"); @@ -329,12 +366,15 @@ 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.setOutcome("", ""); + 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; } @@ -349,11 +389,15 @@ export async function runInteractive({ const sequence = Number(routed.event.sequence) || 0; if (sequence > cursor) { cursor = sequence; - renderInteractiveEvent(stdout, routed.event); + applyPaneEvent(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; @@ -381,7 +425,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; @@ -632,21 +676,109 @@ 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"]); -export function renderAgentPane(agents, { columns = 80, maxRows = 6, connectionState = "connected" } = {}) { +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 entries = String(event?.payload?.text || event?.payload?.report || "") + .split(/\r?\n/) + .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 String(preferred?.text || preferred).slice(0, 240); +} + +export function renderAgentPane(agents, { + columns = 80, + maxRows = 6, + connectionState = "connected", + thread = null, + executionStatus = "", + agentSnapshot = null, + outcomeStatus = "", + taskSummary = "", +} = {}) { 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 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) { + 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) => { 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) { + if (agentSnapshot?.error) rows.push("└─ ◌ subagent status unavailable"); + 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)); +} + +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", "planning"]).has(value)) return "●"; + return "○"; } function truncateTerminalLine(value, columns) { @@ -656,10 +788,44 @@ function truncateTerminalLine(value, columns) { return width <= 3 ? line.slice(0, width) : `${line.slice(0, width - 3)}...`; } -function createAgentPane(stdout) { +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 = []; let connectionState = "disconnected"; + let thread = null; + let executionStatus = ""; + let agentSnapshot = null; + let outcomeStatus = ""; + let taskSummary = ""; let panel = null; let lastFrame = ""; @@ -677,12 +843,21 @@ 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, + agentSnapshot, + outcomeStatus, + taskSummary, + }); const frame = JSON.stringify({ rows, columns, height, lines }); if (frame === lastFrame) return; lastFrame = frame; @@ -692,18 +867,30 @@ function createAgentPane(stdout) { } output += "\u001b8"; stdout.write(output); + 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) { connectionState = next; draw(); }, + setThread(nextThread, nextExecutionStatus = "") { + thread = nextThread || null; + executionStatus = nextExecutionStatus || ""; + draw(); + }, + setOutcome(nextStatus, nextSummary) { + if (nextStatus !== undefined) outcomeStatus = nextStatus || ""; + if (nextSummary !== undefined) taskSummary = nextSummary || ""; + draw(); + }, close: clear, }; } @@ -728,11 +915,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") { @@ -756,6 +943,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 }); @@ -795,9 +983,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") { @@ -931,6 +1117,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 e79b326..626cb37 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, @@ -17,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({ @@ -25,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; } @@ -100,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 = ""; @@ -109,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"); @@ -119,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([ @@ -139,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 () => { @@ -171,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, @@ -214,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; @@ -249,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 () => { @@ -450,9 +463,140 @@ 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("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"); + const planning = renderAgentPane([], { + columns: 80, + maxRows: 4, + thread: { id: "thread-running", state: "running" }, + }); + assert.equal(planning[0], "● orchestrator · planning"); + assert.equal(planning[1], "└─ ○ no delegated agents yet"); + assert.equal(renderAgentPane([], { + columns: 80, + maxRows: 4, + thread: { id: "thread-running", state: "running" }, + agentSnapshot: { error: "subagent status temporarily unavailable" }, + })[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("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(); + 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: "# 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 |", + }, + }, + }))); + 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, /assistant> # Open PRs/); + assert.match(output.output, /✓ orchestrator · complete/); + assert.match(output.output, /↳ Latest opened PR: #421 — fix: remove global waypoint/); + assert.ok(prompts.some((prompt) => prompt.label === "› " && prompt.preserveCursor === true)); +}); 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 f389c4a..8a0e1a8 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, @@ -21,6 +22,7 @@ import { findActiveSession, normalizeWorkerReport, ownsThreadProjection, + responseTypeForMessage, scopedThreadTranscript, selectFinalMessage, sessionControlInvocation, @@ -237,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 = []; @@ -271,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, @@ -278,6 +294,8 @@ function writeTraceSummary(id, status) { status, completedAt: registry.sessions[id]?.completedAt || null, finalMessage, + completionRoute, + responseType: responseTypeForMessage(finalMessage, completionRoute), traceReferences: references, }; const markdown = [ @@ -414,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; } } @@ -465,21 +486,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) { @@ -674,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 }); @@ -713,6 +774,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 ""; } @@ -861,7 +940,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"; @@ -893,7 +971,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"); @@ -957,8 +1035,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) { @@ -993,7 +1070,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 +1158,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 +1175,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(); 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/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-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/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..285c9b4 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. @@ -420,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