/** * Headless CLI last hop (#645/#646/#820): spawn/parse/bind. Shared retry/resume * loop = external-host-turn-loop. Claude print-mode and codex exec share this * lifecycle; argv/parse are protocol-specific. */ import { spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import type { RoleTurnHost, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts"; import { createSerializedRoleTurnHost, driveExternalRoleTurnRounds, hostAbortedError, isHostAbortedError, } from "../external-host-turn-loop.ts"; import { renderSystemPromptOverride, type PreparedRoleTurn, type SessionIdentityAuthority, } from "../prepared-role-turn.ts"; import { reportHostSessionEvent } from "../host-session-record.ts"; import { applyClaudeSkillInvocation, applyCodexSkillInvocation, hostMethodSkills, packagedMethodPluginDir, } from "../host-native-method.ts"; import { closeJsonSchemaForCodex, codexTurnArgs, headlessMcpConfigDocument, headlessTurnArgs, isClaudePrintDescription, isCodexExecDescription, isPlainObject, type HeadlessHostDescription, } from "./description.ts"; import { NAVIGATOR_OUTPUT_TOOL_NAME } from "../package-contracts/navigator-output.ts"; export type HeadlessRoleTurnHostConfig = Readonly<{ description: HeadlessHostDescription; sessionIdentity: SessionIdentityAuthority; /** Seat-table host key (e.g. claude) for sitian host field. */ hostName: string; binary: string; prepare(request: RoleTurnRequest): Promise; env?: NodeJS.ProcessEnv; }>; function failure( cause: "activation" | "session" | "output" | "provider", name: string, code: string, details?: Readonly>, diagnostic?: string, ): RoleTurnResult { return { code: null, stderr: "", timedOut: false, knownFailure: { cause, identity: { name, code }, ...(diagnostic === undefined ? {} : { diagnostic }), ...(details === undefined ? {} : { details }), }, }; } /** One headless CLI result envelope (stream-json last line, or single json doc). */ export type HeadlessCliResult = Readonly<{ session_id?: string; is_error?: boolean; subtype?: string; result?: unknown; structured_output?: unknown; errors?: unknown; permission_denials?: unknown; [key: string]: unknown; }>; /** * True when a parsed stdout object is the typed result receipt (or a single-doc * envelope without stream-json `type`). Intermediate stream-json events are not. */ function isHeadlessResultCandidate(value: unknown): value is HeadlessCliResult { if (!isPlainObject(value)) return false; const record = value as HeadlessCliResult & { type?: unknown }; return record.type === undefined || record.type === "result" || record.structured_output !== undefined; } /** * Parse Claude host stdout into the result envelope. * Production uses `--output-format stream-json` (#811 live records); last-result * line is the typed receipt. A single-document `json` body still parses so a * misconfigured description yields a typed miss rather than a silent empty parse. * Callers must not retain the full stream — only the rolling result candidate. */ export function parseHeadlessCliStdout(stdout: string): HeadlessCliResult | undefined { const trimmed = stdout.trim(); if (trimmed === "") return undefined; try { const single = JSON.parse(trimmed) as unknown; if (isHeadlessResultCandidate(single)) return single; } catch { // fall through } // stream-json: keep the last result line (structured_output / is_error live here). let last: HeadlessCliResult | undefined; for (const line of trimmed.split("\n")) { const text = line.trim(); if (text === "") continue; try { const value = JSON.parse(text) as unknown; if (!isPlainObject(value)) continue; const record = value as HeadlessCliResult & { type?: unknown }; // Multi-line stream: only explicit result / structured_output lines (not bare objects). if (record.type === "result" || record.structured_output !== undefined) { last = record; } } catch { // skip non-JSON noise lines } } return last; } /** * If `line` is a result-candidate JSON object, return its trimmed text; else undefined. * Used to roll the sole stdout retained for final parse (no full-stream copy). */ function resultCandidateText(line: string): string | undefined { const text = line.trim(); if (text === "") return undefined; try { const value = JSON.parse(text) as unknown; return isHeadlessResultCandidate(value) ? text : undefined; } catch { return undefined; } } /** * Minimal consumer-driven parse of `codex exec --json` JSONL (ADR 0043). * Only takes thread_id, final agent_message text, and terminal turn.failed. * Top-level `error` events are non-terminal (reconnect notices, skill budget * warnings); they must not poison a later turn.completed receipt. */ export type CodexExecTurnObservation = Readonly<{ threadId?: string; /** Last `item.completed` agent_message text (final message / structured receipt). */ finalMessage?: string; /** Present only for terminal `turn.failed` (not recoverable `error` events). */ failureDiagnostic?: string; turnCompleted: boolean; }>; function createCodexExecTurnObserver(): { readonly observe: (event: unknown) => void; readonly result: () => CodexExecTurnObservation; } { let threadId: string | undefined; let finalMessage: string | undefined; let failureDiagnostic: string | undefined; let turnCompleted = false; return { observe(value) { if (!isPlainObject(value)) return; const type = typeof value.type === "string" ? value.type : undefined; if (type === "thread.started" && typeof value.thread_id === "string" && value.thread_id !== "") { threadId = value.thread_id; } else if (type === "item.completed" && isPlainObject(value.item)) { if (value.item.type === "agent_message" && typeof value.item.text === "string") { finalMessage = value.item.text; } } else if (type === "turn.completed") { turnCompleted = true; failureDiagnostic = undefined; } else if (type === "turn.failed") { turnCompleted = false; failureDiagnostic = formatCodexFailurePayload(value.error ?? value); } // Top-level `error` is non-terminal; exit/receipt handling remains downstream. }, result() { return { ...(threadId === undefined ? {} : { threadId }), ...(finalMessage === undefined ? {} : { finalMessage }), ...(failureDiagnostic === undefined ? {} : { failureDiagnostic }), turnCompleted, }; }, }; } function formatCodexFailurePayload(payload: unknown): string { if (typeof payload === "string" && payload.trim() !== "") return payload; if (isPlainObject(payload)) { if (typeof payload.message === "string" && payload.message.trim() !== "") return payload.message; try { return JSON.stringify(payload); } catch { return "codex turn failed"; } } return String(payload); } /** cwd or an ancestor has a `.git` entry (file or directory). */ function cwdIsGitWorkTree(cwd: string): boolean { let dir = cwd; for (;;) { if (existsSync(join(dir, ".git"))) return true; const parent = dirname(dir); if (parent === dir) return false; dir = parent; } } /** * Absolute git common dir for workspace-write extra roots (worktree index.lock). * When cwd is not a git work tree → undefined (caller skips extra roots). * When cwd is a git work tree, git non-zero / empty stdout / spawn failure * must fail loud with the real cause — never wash into "no common dir". */ function resolveGitCommonDir(cwd: string): string | undefined { if (!cwdIsGitWorkTree(cwd)) return undefined; let result: { status: number | null; stdout: string; stderr: string; error?: Error }; try { result = spawnSync("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8", }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`git rev-parse --git-common-dir failed: ${message}`); } if (result.error !== undefined) { throw new Error(`git rev-parse --git-common-dir failed: ${result.error.message}`); } if (result.status !== 0) { const detail = result.stderr.trim() || `exit ${String(result.status)}`; throw new Error(`git rev-parse --git-common-dir failed: ${detail}`); } const raw = result.stdout.trim(); if (raw === "") { throw new Error("git rev-parse --git-common-dir returned empty stdout"); } return isAbsolute(raw) ? raw : resolve(cwd, raw); } function spawnHeadlessTurn(options: { readonly binary: string; readonly args: readonly string[]; readonly cwd: string; readonly env: NodeJS.ProcessEnv; readonly signal?: AbortSignal; readonly timeoutMs?: number; /** User dialogue body; omitted from argv so execve cannot E2BIG (#879). */ readonly stdin?: string; /** Called for each complete stdout line as it arrives (live stream-json). */ readonly onStdoutLine?: (line: string) => void; }): Promise<{ code: number | null; stdout: string; stderr: string; timedOut: boolean }> { return new Promise((resolve, reject) => { if (options.signal?.aborted) { reject(hostAbortedError("headless host aborted")); return; } const child = spawn(options.binary, [...options.args], { cwd: options.cwd, env: options.env, stdio: ["pipe", "pipe", "pipe"], }); if (child.stdin === null) { reject(new Error("headless child stdin pipe was not created")); return; } let stdinDeliveryError: Error | undefined; child.stdin.on("error", (error) => { stdinDeliveryError ??= error; }); if (options.stdin !== undefined) { child.stdin.write(options.stdin); } child.stdin.end(); // Rolling retention only: last result-candidate line for final parse. // Live events go to sitian via onStdoutLine — never accumulate the full stream. let resultStdout = ""; let stderr = ""; let lineBuffer = ""; let settled = false; let timedOut = false; let timer: NodeJS.Timeout | undefined; const failLine = (error: unknown): void => { if (settled) return; settled = true; if (timer !== undefined) clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); try { child.kill("SIGTERM"); } catch { /* already exiting */ } reject(error instanceof Error ? error : new Error(String(error))); }; const emitStdoutLine = (line: string): void => { const candidate = resultCandidateText(line); if (candidate !== undefined) resultStdout = candidate; if (options.onStdoutLine === undefined) return; try { options.onStdoutLine(line); } catch (error) { failLine(error); } }; const flushStdoutLines = (chunk: string, final: boolean): void => { lineBuffer += chunk; for (;;) { const end = lineBuffer.indexOf("\n"); if (end < 0) break; const line = lineBuffer.slice(0, end); lineBuffer = lineBuffer.slice(end + 1); emitStdoutLine(line); if (settled) return; } if (final && lineBuffer.length > 0) { emitStdoutLine(lineBuffer); lineBuffer = ""; } }; const settle = (code: number | null): void => { if (settled) return; // Final flush first: onStdoutLine may failLine (reject + settled=true). flushStdoutLines("", true); if (settled) return; settled = true; if (timer !== undefined) clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); if (stdinDeliveryError !== undefined) { reject(stdinDeliveryError); return; } resolve({ code, stdout: resultStdout, stderr, timedOut }); }; const onAbort = (): void => { child.kill("SIGTERM"); if (settled) return; settled = true; if (timer !== undefined) clearTimeout(timer); reject(hostAbortedError("headless host aborted")); }; child.stdout.setEncoding("utf8").on("data", (chunk: string) => { flushStdoutLines(chunk, false); }); child.stderr.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; }); child.on("error", (error) => { if (settled) return; settled = true; if (timer !== undefined) clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); reject(error); }); child.on("close", (code) => settle(code)); options.signal?.addEventListener("abort", onAbort, { once: true }); if (options.timeoutMs !== undefined && options.timeoutMs > 0) { timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, options.timeoutMs); } }); } /** Success→dispose failure; existing failure keeps primary cause + cleanup detail. */ function withCleanupFailure(outcome: RoleTurnResult, cleanupError: unknown): RoleTurnResult { const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); if (outcome.knownFailure === undefined) { return failure("session", "HeadlessDisposeFailure", "dispose-failed", { cleanupError: message }, message); } return { ...outcome, knownFailure: { ...outcome.knownFailure, details: { ...(outcome.knownFailure.details ?? {}), cleanupError: message }, }, }; } function terminalFromSpawned( spawned: { code: number | null; stderr: string; timedOut: boolean }, knownFailure: NonNullable, ): { readonly status: "terminal"; readonly result: RoleTurnResult } { return { status: "terminal", result: { code: spawned.code, stderr: spawned.stderr, timedOut: spawned.timedOut, knownFailure, }, }; } function buildTurnArgs(options: { readonly description: HeadlessHostDescription; readonly systemPromptPath: string; /** Closed schema; omit for #959 navigator prose-exit seats. */ readonly jsonSchema?: Readonly>; readonly mcpServers: readonly Readonly>[]; readonly mcpConfigPath?: string; readonly outputSchemaPath?: string; readonly model?: string; readonly effort?: string; readonly sessionId: string | undefined; readonly sessionKind: "new" | "resume"; readonly cwd: string; readonly writableRoots?: readonly string[]; readonly pluginDir?: string; }): readonly string[] { if (isCodexExecDescription(options.description)) { if (options.sessionKind === "resume" && !options.sessionId) { throw new Error("codex resume requires a bound thread_id"); } // #959: prose-exit seats (navigator) omit --output-schema; other seats still require it. return codexTurnArgs({ systemPromptPath: options.systemPromptPath, ...(options.outputSchemaPath === undefined ? {} : { outputSchemaPath: options.outputSchemaPath }), mcpServers: options.mcpServers, ...(options.model === undefined ? {} : { model: options.model }), ...(options.effort === undefined ? {} : { effort: options.effort }), session: options.sessionKind === "resume" ? { kind: "resume", id: options.sessionId! } : { kind: "new" }, skipGitRepoCheck: !cwdIsGitWorkTree(options.cwd), ...(!options.writableRoots?.length ? {} : { writableRoots: options.writableRoots }), }); } if (!isClaudePrintDescription(options.description)) throw new Error("unsupported headless host protocol"); if (!options.sessionId) throw new Error("claude print-mode requires a session id"); if (options.mcpConfigPath === undefined) throw new Error("claude print-mode requires an MCP config path"); return headlessTurnArgs({ description: options.description, systemPromptPath: options.systemPromptPath, // #959: navigator omits --json-schema so free-form prose is a lawful exit. ...(options.jsonSchema === undefined ? {} : { jsonSchema: options.jsonSchema }), mcpConfigPath: options.mcpConfigPath, ...(options.model === undefined ? {} : { model: options.model }), ...(options.effort === undefined ? {} : { effort: options.effort }), session: { kind: options.sessionKind, id: options.sessionId }, ...(options.pluginDir === undefined ? {} : { pluginDir: options.pluginDir }), }); } /** Headless last hop (#820): session bind/resume, CLI spawn turn, MCP/json-schema/output-schema mount. */ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig): RoleTurnHost { return createSerializedRoleTurnHost(async (request): Promise => { const prepared = await config.prepare(request); const systemPrompt = renderSystemPromptOverride(prepared.systemPrompt); const codex = isCodexExecDescription(config.description); let outcome: RoleTurnResult = failure("session", "HeadlessNoOutcome", "no-outcome"); try { // Claude mints a package UUID for --session-id; codex waits for thread.started. let sessionId = await config.sessionIdentity.load(request.principal); let sessionKind: "new" | "resume" = request.continuation.kind === "resume" && sessionId !== undefined && sessionId !== "" ? "resume" : "new"; if (sessionKind === "new" && !codex) { sessionId = randomUUID(); await config.sessionIdentity.bind(request.principal, sessionId); } // config.env owns package-root/child env; do not re-spread process.env over it. const env: NodeJS.ProcessEnv = { ...process.env, ...(config.env ?? {}) }; const systemPromptPath = join(request.runDirectory, "headless-system-prompt.txt"); await writeFile(systemPromptPath, systemPrompt, "utf8"); let mcpConfigPath: string | undefined; let outputSchemaPath: string | undefined; let pluginDir: string | undefined; let applyMethodPrompt: (prompt: string) => string = codex ? (prompt) => applyCodexSkillInvocation(request.methods, prompt) : (prompt) => prompt; if (codex) { // #959: navigator is a prose-exit seat — no closed output-schema. if (prepared.terminatingToolName !== NAVIGATOR_OUTPUT_TOOL_NAME) { outputSchemaPath = join(request.runDirectory, "headless-output-schema.json"); await writeFile( outputSchemaPath, `${JSON.stringify(closeJsonSchemaForCodex(prepared.jsonSchema), null, 2)}\n`, "utf8", ); } } else { mcpConfigPath = join(request.runDirectory, "headless-mcp-config.json"); await writeFile( mcpConfigPath, `${JSON.stringify(headlessMcpConfigDocument(prepared.mcpServers), null, 2)}\n`, "utf8", ); if (hostMethodSkills(request.methods).length > 0) { const packageRoot = env.AK_PACKAGE_ROOT ?? process.env.AK_PACKAGE_ROOT; if (typeof packageRoot !== "string" || packageRoot === "") { throw new Error("claude method plugin-dir requires AK_PACKAGE_ROOT"); } pluginDir = packagedMethodPluginDir(packageRoot); applyMethodPrompt = (prompt) => applyClaudeSkillInvocation(request.methods, prompt); } } const sessionParent = config.sessionIdentity.resolveSessionFile(request.principal); outcome = await driveExternalRoleTurnRounds(prepared, request, { roundLimitName: "HeadlessRoundLimit", currentSessionId: () => sessionId, afterRetry() { sessionKind = "resume"; }, async runRound({ prompt, abortSignal }) { let args: readonly string[]; try { const gitCommonDir = codex ? resolveGitCommonDir(request.cwd) : undefined; args = buildTurnArgs({ description: config.description, systemPromptPath, // #959: navigator prose exit — no closed JSON schema on claude either. ...(prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME ? {} : { jsonSchema: prepared.jsonSchema }), mcpServers: prepared.mcpServers, ...(mcpConfigPath === undefined ? {} : { mcpConfigPath }), ...(outputSchemaPath === undefined ? {} : { outputSchemaPath }), ...(request.model?.model !== undefined ? { model: request.model.model } : {}), ...(request.model?.thinking !== undefined ? { effort: request.model.thinking } : {}), sessionId, sessionKind, cwd: request.cwd, ...(gitCommonDir === undefined ? {} : { writableRoots: [gitCommonDir] }), ...(pluginDir === undefined ? {} : { pluginDir }), }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { status: "terminal", result: failure("session", "HeadlessArgvFailure", "argv-failed", { diagnostic: message }, message), }; } const codexObserver = codex ? createCodexExecTurnObserver() : undefined; let spawned: { code: number | null; stdout: string; stderr: string; timedOut: boolean }; try { spawned = await spawnHeadlessTurn({ binary: config.binary, args, cwd: request.cwd, env, stdin: request.continuation.kind === "resume" ? prompt : applyMethodPrompt(prompt), ...(abortSignal === undefined ? {} : { signal: abortSignal }), ...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }), onStdoutLine(line) { const trimmed = line.trim(); if (trimmed === "") return; let event: unknown; try { event = JSON.parse(trimmed) as unknown; } catch { // Non-JSON noise on stdout is not a host structured event. return; } // One bounded live seam owns both recording and host-specific reduction. codexObserver?.observe(event); reportHostSessionEvent({ host: config.hostName, cwd: request.cwd, sessionParent, source: "headless-host", event, }); }, }); } catch (error) { if (isHostAbortedError(error)) throw error; const message = error instanceof Error ? error.message : String(error); const observedHostFailure = codexObserver?.result().failureDiagnostic; if (observedHostFailure !== undefined) { return { status: "terminal", result: failure("output", "HeadlessCliError", "codex-turn-failed", { diagnostic: observedHostFailure, sessionRecordDiagnostic: message, sessionId, }, observedHostFailure), }; } // SitianInfrastructureError.knownCause is session; spawn errno stays activation. const isRecordFailure = typeof error === "object" && error !== null && ((error as { knownCause?: unknown }).knownCause === "session" || (error as { name?: unknown }).name === "SitianInfrastructureError"); if (isRecordFailure) { return { status: "terminal", result: failure( "session", "HostSessionRecordFailure", "host-session-record-failed", { diagnostic: message, sessionId }, message, ), }; } return { status: "terminal", result: failure("activation", "HeadlessSpawnFailure", "spawn-failed", { diagnostic: message, binary: config.binary, }, message), }; } if (spawned.timedOut) { return terminalFromSpawned(spawned, { cause: "timeout", identity: { name: "HeadlessTimeout", code: "timeout" }, details: { sessionId }, }); } if (codex) { const observation = codexObserver!.result(); // #987 result 6 / 失败诚实: host-reported failure wins over a package // missing-thread-id label or package persistence failure. if (observation.threadId !== undefined && observation.threadId !== "") { sessionId = observation.threadId; } let sessionBindingDiagnostic: string | undefined; const hostFailed = observation.failureDiagnostic !== undefined || (spawned.code !== 0 && spawned.code !== null); if (hostFailed && observation.threadId !== undefined && observation.threadId !== "") { try { await config.sessionIdentity.bind(request.principal, observation.threadId); } catch (error) { // The host failure remains primary; retain persistence failure as // secondary typed evidence instead of replacing the terminal. sessionBindingDiagnostic = error instanceof Error ? error.message : String(error); } } if (observation.failureDiagnostic !== undefined) { return terminalFromSpawned(spawned, { cause: "output", identity: { name: "HeadlessCliError", code: "codex-turn-failed" }, diagnostic: observation.failureDiagnostic, details: { sessionId, exitCode: spawned.code, ...(sessionBindingDiagnostic === undefined ? {} : { sessionBindingDiagnostic }), }, }); } // Non-zero exit without a parseable failure event still fails loud. if (spawned.code !== 0 && spawned.code !== null) { return terminalFromSpawned(spawned, { cause: "output", identity: { name: "HeadlessCliError", code: "codex-nonzero-exit" }, diagnostic: spawned.stderr.trim() || `codex exec exited ${String(spawned.code)}`, details: { sessionId, exitCode: spawned.code, ...(sessionBindingDiagnostic === undefined ? {} : { sessionBindingDiagnostic }), }, }); } if (observation.threadId === undefined || observation.threadId === "") { return terminalFromSpawned(spawned, { cause: "session", identity: { name: "HeadlessMissingThreadId", code: "missing-thread-id" }, diagnostic: "codex exec emitted no thread.started thread_id", details: { sessionId, exitCode: spawned.code }, }); } sessionId = observation.threadId; await config.sessionIdentity.bind(request.principal, sessionId); if (!observation.turnCompleted) { return terminalFromSpawned(spawned, { cause: "output", identity: { name: "HeadlessCliError", code: "codex-missing-terminal-event" }, diagnostic: "codex exec exited without turn.completed", details: { sessionId, exitCode: spawned.code }, }); } if (observation.finalMessage === undefined) { return terminalFromSpawned(spawned, { cause: "output", identity: { name: "HeadlessEmptyOutput", code: "empty-stdout" }, diagnostic: spawned.stderr.trim() || "codex exec produced no agent_message", details: { sessionId, exitCode: spawned.code }, }); } // #959: navigator prose exit — final agent_message is presented as-is. // Empty text is not a lawful accepted receipt (align pi no_receipt / loud empty). if (prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME) { if (observation.finalMessage.trim() === "") { return terminalFromSpawned(spawned, { cause: "output", identity: { name: "HeadlessEmptyOutput", code: "empty-stdout" }, diagnostic: spawned.stderr.trim() || "codex exec produced empty agent_message", details: { sessionId, exitCode: spawned.code }, }); } await prepared.ingestStructuredOutput({ prose: observation.finalMessage }); return { status: "delivered", stderr: spawned.stderr }; } let receipt: unknown; try { receipt = JSON.parse(observation.finalMessage); } catch { return terminalFromSpawned(spawned, { cause: "output", identity: { name: "HeadlessEmptyOutput", code: "unparseable-final-message" }, diagnostic: "codex final agent_message was not JSON", details: { sessionId, exitCode: spawned.code }, }); } await prepared.ingestStructuredOutput(receipt); return { status: "delivered", stderr: spawned.stderr }; } // Claude print-mode path. const envelope = parseHeadlessCliStdout(spawned.stdout); if (envelope === undefined) { return terminalFromSpawned(spawned, { cause: "output", identity: { name: "HeadlessEmptyOutput", code: "empty-stdout" }, diagnostic: spawned.stderr.length > 0 ? spawned.stderr : "headless CLI produced no parseable result", details: { sessionId, exitCode: spawned.code }, }); } // Bind the host-reported session id (authoritative for --resume). if (typeof envelope.session_id === "string" && envelope.session_id !== "") { sessionId = envelope.session_id; await config.sessionIdentity.bind(request.principal, sessionId); } if (envelope.is_error === true || (typeof envelope.subtype === "string" && envelope.subtype.startsWith("error_"))) { const errorCode = typeof envelope.subtype === "string" && envelope.subtype.startsWith("error_") ? envelope.subtype : envelope.is_error === true ? "is_error" : "cli-error"; const diagnostic = typeof envelope.result === "string" ? envelope.result : Array.isArray(envelope.errors) ? envelope.errors.map(String).join("\n") : spawned.stderr.length > 0 ? spawned.stderr : "headless CLI reported is_error"; return terminalFromSpawned(spawned, { cause: "output", identity: { name: "HeadlessCliError", code: errorCode }, diagnostic, details: { sessionId, subtype: envelope.subtype, errors: envelope.errors, exitCode: spawned.code, }, }); } if (envelope.structured_output !== undefined) { await prepared.ingestStructuredOutput(envelope.structured_output); } else if ( prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME && typeof envelope.result === "string" && envelope.result.trim() !== "" ) { // #959: claude prose exit — free-form result text is the receipt body // when structured_output is absent (json-schema omitted for navigator). await prepared.ingestStructuredOutput({ prose: envelope.result }); } return { status: "delivered", stderr: spawned.stderr }; }, }); } finally { try { await prepared.dispose?.(); } catch (cleanupError) { outcome = withCleanupFailure(outcome, cleanupError); } } return outcome; }); }