@akagilnc/pi-workflow-roles 0.1.3837 → 0.1.3873

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/acp-host/production-host.js +24606 -24532
  2. package/dist/engine-detour-tool.js +2 -2
  3. package/dist/engine-detour.js +23 -7
  4. package/dist/headless-host/production-host.js +4197 -4139
  5. package/dist/host-descriptions.js +5 -2
  6. package/dist/pi/role-turn-host.js +20 -92
  7. package/dist/public-cli/load-production-acp-host.js +1 -1
  8. package/dist/public-cli/load-production-external-host.js +10 -0
  9. package/dist/public-cli/load-production-headless-host.js +1 -1
  10. package/dist/public-cli/main.js +95 -102
  11. package/dist/public-role-summons.js +7 -49
  12. package/dist/reviewer-agent.js +1 -0
  13. package/dist/reviewer-child-executor.js +3 -2
  14. package/dist/role-activation-flags.js +50 -0
  15. package/dist/role-runtime.js +8 -3
  16. package/package.json +1 -1
  17. package/scripts/build-package.mjs +2 -2
  18. package/src/acp-host/production-host.ts +31 -94
  19. package/src/acp-host/role-turn-host.ts +178 -263
  20. package/src/diagnostic-tail.ts +18 -0
  21. package/src/engine-detour-tool.ts +5 -4
  22. package/src/engine-detour.ts +44 -9
  23. package/src/external-host-turn-loop.ts +172 -0
  24. package/src/headless-host/production-host.ts +10 -7
  25. package/src/headless-host/role-turn-host.ts +271 -263
  26. package/src/host-descriptions.ts +5 -2
  27. package/src/host-session-record.ts +41 -0
  28. package/src/pi/role-turn-host.ts +18 -93
  29. package/src/prepared-role-turn.ts +68 -0
  30. package/src/public-cli/cli.ts +11 -20
  31. package/src/public-cli/load-production-acp-host.ts +2 -1
  32. package/src/public-cli/load-production-external-host.ts +17 -0
  33. package/src/public-cli/load-production-headless-host.ts +2 -1
  34. package/src/public-role-summons.ts +12 -69
  35. package/src/reviewer-agent.ts +12 -1
  36. package/src/reviewer-child-executor.ts +10 -2
  37. package/src/role-activation-flags.ts +53 -0
  38. package/src/{acp-host/role-envelope.ts → role-envelope.ts} +28 -84
  39. package/src/role-runtime-dependencies.ts +91 -0
  40. package/src/role-runtime.ts +14 -4
  41. package/src/{acp-host/session-identity.ts → session-identity.ts} +6 -6
  42. /package/src/{acp-host/mcp-relay.mjs → mcp-relay.mjs} +0 -0
@@ -1,20 +1,23 @@
1
- /**
2
- * Generic headless CLI RoleTurnHost (#645 / #752).
3
- * One process per turn: spawn → read result envelope/exit → structured_output / MCP → envelope.
4
- * No reads of the host's private home; session id is package-owned binding only.
5
- * No permanent stdout/stderr/init probe copies — sitian + binding are the dossier.
6
- */
1
+ /** Headless CLI last hop (#645/#820): spawn/parse/bind. Shared loop = external-host-turn-loop. */
7
2
  import { spawn } from "node:child_process";
8
3
  import { randomUUID } from "node:crypto";
9
4
  import { writeFile } from "node:fs/promises";
10
5
  import { join } from "node:path";
11
6
 
12
- import type { RoleTurnHost, RoleTurnKnownFailure, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
7
+ import type { RoleTurnHost, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
8
+ import {
9
+ createSerializedRoleTurnHost,
10
+ driveExternalRoleTurnRounds,
11
+ hostAbortedError,
12
+ isHostAbortedError,
13
+ } from "../external-host-turn-loop.ts";
13
14
  import {
14
- renderAcpSystemPromptOverride,
15
- type AcpPreparedTurn,
16
- type AcpSessionIdentityAuthority,
17
- } from "../acp-host/role-turn-host.ts";
15
+ renderSystemPromptOverride,
16
+ type PreparedRoleTurn,
17
+ type SessionIdentityAuthority,
18
+ } from "../prepared-role-turn.ts";
19
+ import { retainDiagnosticTail } from "../diagnostic-tail.ts";
20
+ import { reportHostSessionEvent } from "../host-session-record.ts";
18
21
  import {
19
22
  headlessMcpConfigDocument,
20
23
  headlessTurnArgs,
@@ -23,9 +26,11 @@ import {
23
26
 
24
27
  export type HeadlessRoleTurnHostConfig = Readonly<{
25
28
  description: HeadlessHostDescription;
26
- sessionIdentity: AcpSessionIdentityAuthority;
29
+ sessionIdentity: SessionIdentityAuthority;
30
+ /** Seat-table host key (e.g. claude) for sitian host field. */
31
+ hostName: string;
27
32
  binary: string;
28
- prepare(request: RoleTurnRequest): Promise<AcpPreparedTurn>;
33
+ prepare(request: RoleTurnRequest): Promise<PreparedRoleTurn>;
29
34
  env?: NodeJS.ProcessEnv;
30
35
  }>;
31
36
 
@@ -49,7 +54,7 @@ function failure(
49
54
  };
50
55
  }
51
56
 
52
- /** One headless CLI result envelope (`--output-format json`). */
57
+ /** One headless CLI result envelope (stream-json last line, or single json doc). */
53
58
  export type HeadlessCliResult = Readonly<{
54
59
  session_id?: string;
55
60
  is_error?: boolean;
@@ -61,27 +66,35 @@ export type HeadlessCliResult = Readonly<{
61
66
  [key: string]: unknown;
62
67
  }>;
63
68
 
69
+ /**
70
+ * True when a parsed stdout object is the typed result receipt (or a single-doc
71
+ * envelope without stream-json `type`). Intermediate stream-json events are not.
72
+ */
73
+ function isHeadlessResultCandidate(value: unknown): value is HeadlessCliResult {
74
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
75
+ const record = value as HeadlessCliResult & { type?: unknown };
76
+ return record.type === undefined
77
+ || record.type === "result"
78
+ || record.structured_output !== undefined;
79
+ }
80
+
64
81
  /**
65
82
  * Parse host stdout into the result envelope.
66
- * Production uses `--output-format json` (one document). stream-json last-result
67
- * parsing remains so a misconfigured description still yields a typed miss rather
68
- * than a silent empty parse — not a permanent probe path.
83
+ * Production uses `--output-format stream-json` (#811 live records); last-result
84
+ * line is the typed receipt. A single-document `json` body still parses so a
85
+ * misconfigured description yields a typed miss rather than a silent empty parse.
86
+ * Callers must not retain the full stream — only the rolling result candidate.
69
87
  */
70
88
  export function parseHeadlessCliStdout(stdout: string): HeadlessCliResult | undefined {
71
89
  const trimmed = stdout.trim();
72
90
  if (trimmed === "") return undefined;
73
91
  try {
74
92
  const single = JSON.parse(trimmed) as unknown;
75
- if (typeof single === "object" && single !== null && !Array.isArray(single)) {
76
- const record = single as HeadlessCliResult & { type?: unknown };
77
- if (record.type === undefined || record.type === "result" || record.structured_output !== undefined) {
78
- return record;
79
- }
80
- }
93
+ if (isHeadlessResultCandidate(single)) return single;
81
94
  } catch {
82
95
  // fall through
83
96
  }
84
- // Defensive: if a description still requests stream-json, keep only the result line.
97
+ // stream-json: keep the last result line (structured_output / is_error live here).
85
98
  let last: HeadlessCliResult | undefined;
86
99
  for (const line of trimmed.split("\n")) {
87
100
  const text = line.trim();
@@ -90,6 +103,7 @@ export function parseHeadlessCliStdout(stdout: string): HeadlessCliResult | unde
90
103
  const value = JSON.parse(text) as unknown;
91
104
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
92
105
  const record = value as HeadlessCliResult & { type?: unknown };
106
+ // Multi-line stream: only explicit result / structured_output lines (not bare objects).
93
107
  if (record.type === "result" || record.structured_output !== undefined) {
94
108
  last = record;
95
109
  }
@@ -100,12 +114,19 @@ export function parseHeadlessCliStdout(stdout: string): HeadlessCliResult | unde
100
114
  return last;
101
115
  }
102
116
 
103
- /** Bound stderr retained only for failure diagnostics (not a dossier copy). */
104
- const STDERR_DIAGNOSTIC_CAP = 16 * 1024;
105
-
106
- function clipDiagnostic(text: string): string {
107
- if (text.length <= STDERR_DIAGNOSTIC_CAP) return text;
108
- return `${text.slice(0, STDERR_DIAGNOSTIC_CAP)}\n…[stderr clipped]`;
117
+ /**
118
+ * If `line` is a result-candidate JSON object, return its trimmed text; else undefined.
119
+ * Used to roll the sole stdout retained for final parse (no full-stream copy).
120
+ */
121
+ function resultCandidateText(line: string): string | undefined {
122
+ const text = line.trim();
123
+ if (text === "") return undefined;
124
+ try {
125
+ const value = JSON.parse(text) as unknown;
126
+ return isHeadlessResultCandidate(value) ? text : undefined;
127
+ } catch {
128
+ return undefined;
129
+ }
109
130
  }
110
131
 
111
132
  function spawnHeadlessTurn(options: {
@@ -115,10 +136,12 @@ function spawnHeadlessTurn(options: {
115
136
  readonly env: NodeJS.ProcessEnv;
116
137
  readonly signal?: AbortSignal;
117
138
  readonly timeoutMs?: number;
139
+ /** Called for each complete stdout line as it arrives (live stream-json). */
140
+ readonly onStdoutLine?: (line: string) => void;
118
141
  }): Promise<{ code: number | null; stdout: string; stderr: string; timedOut: boolean }> {
119
142
  return new Promise((resolve, reject) => {
120
143
  if (options.signal?.aborted) {
121
- reject(Object.assign(new Error("headless host aborted"), { code: "host-aborted" }));
144
+ reject(hostAbortedError("headless host aborted"));
122
145
  return;
123
146
  }
124
147
  const child = spawn(options.binary, [...options.args], {
@@ -126,31 +149,68 @@ function spawnHeadlessTurn(options: {
126
149
  env: options.env,
127
150
  stdio: ["ignore", "pipe", "pipe"],
128
151
  });
129
- let stdout = "";
152
+ // Rolling retention only: last result-candidate line for final parse.
153
+ // Live events go to sitian via onStdoutLine — never accumulate the full stream.
154
+ let resultStdout = "";
130
155
  let stderr = "";
156
+ let lineBuffer = "";
131
157
  let settled = false;
132
158
  let timedOut = false;
133
159
  let timer: NodeJS.Timeout | undefined;
160
+ const failLine = (error: unknown): void => {
161
+ if (settled) return;
162
+ settled = true;
163
+ if (timer !== undefined) clearTimeout(timer);
164
+ options.signal?.removeEventListener("abort", onAbort);
165
+ try { child.kill("SIGTERM"); } catch { /* already exiting */ }
166
+ reject(error instanceof Error ? error : new Error(String(error)));
167
+ };
168
+ const emitStdoutLine = (line: string): void => {
169
+ const candidate = resultCandidateText(line);
170
+ if (candidate !== undefined) resultStdout = candidate;
171
+ if (options.onStdoutLine === undefined) return;
172
+ try {
173
+ options.onStdoutLine(line);
174
+ } catch (error) {
175
+ failLine(error);
176
+ }
177
+ };
178
+ const flushStdoutLines = (chunk: string, final: boolean): void => {
179
+ lineBuffer += chunk;
180
+ for (;;) {
181
+ const end = lineBuffer.indexOf("\n");
182
+ if (end < 0) break;
183
+ const line = lineBuffer.slice(0, end);
184
+ lineBuffer = lineBuffer.slice(end + 1);
185
+ emitStdoutLine(line);
186
+ if (settled) return;
187
+ }
188
+ if (final && lineBuffer.length > 0) {
189
+ emitStdoutLine(lineBuffer);
190
+ lineBuffer = "";
191
+ }
192
+ };
134
193
  const settle = (code: number | null): void => {
194
+ if (settled) return;
195
+ // Final flush first: onStdoutLine may failLine (reject + settled=true).
196
+ flushStdoutLines("", true);
135
197
  if (settled) return;
136
198
  settled = true;
137
199
  if (timer !== undefined) clearTimeout(timer);
138
200
  options.signal?.removeEventListener("abort", onAbort);
139
- resolve({ code, stdout, stderr, timedOut });
201
+ resolve({ code, stdout: resultStdout, stderr, timedOut });
140
202
  };
141
203
  const onAbort = (): void => {
142
204
  child.kill("SIGTERM");
143
205
  if (settled) return;
144
206
  settled = true;
145
207
  if (timer !== undefined) clearTimeout(timer);
146
- reject(Object.assign(new Error("headless host aborted"), { code: "host-aborted" }));
208
+ reject(hostAbortedError("headless host aborted"));
147
209
  };
148
- child.stdout.setEncoding("utf8").on("data", (chunk: string) => { stdout += chunk; });
210
+ child.stdout.setEncoding("utf8").on("data", (chunk: string) => { flushStdoutLines(chunk, false); });
149
211
  child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
150
- // Cap retained stderr: diagnostics only, not an unbounded transcript face.
151
- if (stderr.length < STDERR_DIAGNOSTIC_CAP) {
152
- stderr = clipDiagnostic(stderr + chunk);
153
- }
212
+ // Rolling diagnostic tail only — not an unbounded transcript face.
213
+ stderr = retainDiagnosticTail(stderr + chunk);
154
214
  });
155
215
  child.on("error", (error) => {
156
216
  if (settled) return;
@@ -170,255 +230,203 @@ function spawnHeadlessTurn(options: {
170
230
  });
171
231
  }
172
232
 
173
- function cleanupErrorMessage(error: unknown): string {
174
- return error instanceof Error ? error.message : String(error);
175
- }
176
-
177
- /**
178
- * If dispose/cleanup failed: success becomes typed failure; existing failure keeps
179
- * its primary cause and records the cleanup error in details (failure-honesty).
180
- */
233
+ /** Success→dispose failure; existing failure keeps primary cause + cleanup detail. */
181
234
  function withCleanupFailure(outcome: RoleTurnResult, cleanupError: unknown): RoleTurnResult {
182
- const message = cleanupErrorMessage(cleanupError);
235
+ const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
183
236
  if (outcome.knownFailure === undefined) {
184
- return failure(
185
- "session",
186
- "HeadlessDisposeFailure",
187
- "dispose-failed",
188
- { cleanupError: message },
189
- message,
190
- );
237
+ return failure("session", "HeadlessDisposeFailure", "dispose-failed", { cleanupError: message }, message);
191
238
  }
192
239
  return {
193
240
  ...outcome,
194
241
  knownFailure: {
195
242
  ...outcome.knownFailure,
196
- details: {
197
- ...(outcome.knownFailure.details ?? {}),
198
- cleanupError: message,
199
- },
243
+ details: { ...(outcome.knownFailure.details ?? {}), cleanupError: message },
200
244
  },
201
245
  };
202
246
  }
203
247
 
204
- /**
205
- * Main-session headless adapter. prepare() is the shared envelope boundary;
206
- * this module owns only CLI spawn / parse / session-id bind / resume loop.
207
- */
208
- export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig): RoleTurnHost {
209
- let serial = Promise.resolve();
248
+ function terminalFromSpawned(
249
+ spawned: { code: number | null; stderr: string; timedOut: boolean },
250
+ knownFailure: NonNullable<RoleTurnResult["knownFailure"]>,
251
+ ): { readonly status: "terminal"; readonly result: RoleTurnResult } {
210
252
  return {
211
- executeTurn(request) {
212
- const execution = serial.then(async (): Promise<RoleTurnResult> => {
213
- const prepared = await config.prepare(request);
214
- const systemPrompt = renderAcpSystemPromptOverride(prepared.systemPrompt);
215
- let outcome: RoleTurnResult = failure("session", "HeadlessNoOutcome", "no-outcome");
216
- try {
217
- // Same-host resume reuses the bound native session id via --resume.
218
- let sessionId = await config.sessionIdentity.load(request.principal);
219
- let sessionKind: "new" | "resume" =
220
- request.continuation.kind === "resume" && sessionId !== undefined && sessionId !== ""
221
- ? "resume"
222
- : "new";
223
- if (sessionKind === "new") {
224
- sessionId = randomUUID();
225
- await config.sessionIdentity.bind(request.principal, sessionId);
226
- }
227
-
228
- // Cross-host handoff: prior-native paths ride the user prompt (DK-7).
229
- const priorNativePaths =
230
- request.continuation.kind === "resume"
231
- ? request.hostTransition?.priorNativePaths
232
- : undefined;
233
- let prompt =
234
- priorNativePaths !== undefined && priorNativePaths.length > 0
235
- ? `${prepared.prompt}\n${priorNativePaths.join("\n")}`
236
- : prepared.prompt;
253
+ status: "terminal",
254
+ result: {
255
+ code: spawned.code,
256
+ stderr: spawned.stderr,
257
+ timedOut: spawned.timedOut,
258
+ knownFailure,
259
+ },
260
+ };
261
+ }
237
262
 
238
- const abortSignal =
239
- request.signal === undefined
240
- ? prepared.abortSignal
241
- : prepared.abortSignal === undefined
242
- ? request.signal
243
- : AbortSignal.any([prepared.abortSignal, request.signal]);
263
+ /** Headless last hop (#820): session bind/resume, CLI spawn turn, MCP/json-schema mount. */
264
+ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig): RoleTurnHost {
265
+ return createSerializedRoleTurnHost(async (request): Promise<RoleTurnResult> => {
266
+ const prepared = await config.prepare(request);
267
+ const systemPrompt = renderSystemPromptOverride(prepared.systemPrompt);
268
+ let outcome: RoleTurnResult = failure("session", "HeadlessNoOutcome", "no-outcome");
269
+ try {
270
+ let sessionId = await config.sessionIdentity.load(request.principal);
271
+ let sessionKind: "new" | "resume" =
272
+ request.continuation.kind === "resume" && sessionId !== undefined && sessionId !== ""
273
+ ? "resume"
274
+ : "new";
275
+ if (sessionKind === "new") {
276
+ sessionId = randomUUID();
277
+ await config.sessionIdentity.bind(request.principal, sessionId);
278
+ }
244
279
 
245
- // config.env is the production authority for package-root / host child env
246
- // (see createProductionHeadlessRoleTurnHost). Do not re-override keys from
247
- // process.env after the spread — that erased AK_PACKAGE_ROOT.
248
- const env: NodeJS.ProcessEnv = {
249
- ...process.env,
250
- ...(config.env ?? {}),
251
- };
280
+ // config.env owns package-root/child env; do not re-spread process.env over it.
281
+ const env: NodeJS.ProcessEnv = { ...process.env, ...(config.env ?? {}) };
282
+ const systemPromptPath = join(request.runDirectory, "headless-system-prompt.txt");
283
+ await writeFile(systemPromptPath, systemPrompt, "utf8");
284
+ const mcpConfigPath = join(request.runDirectory, "headless-mcp-config.json");
285
+ await writeFile(
286
+ mcpConfigPath,
287
+ `${JSON.stringify(headlessMcpConfigDocument(prepared.mcpServers), null, 2)}\n`,
288
+ "utf8",
289
+ );
252
290
 
253
- // Materialize system prompt + MCP config once per prepare (stable across
254
- // correctable retries). Paths live under the run directory we already own.
255
- // Envelope always projects the AK MCP relay row; no empty-server branch.
256
- const systemPromptPath = join(request.runDirectory, "headless-system-prompt.txt");
257
- await writeFile(systemPromptPath, systemPrompt, "utf8");
258
- const mcpConfigPath = join(request.runDirectory, "headless-mcp-config.json");
259
- await writeFile(
291
+ const sessionParent = config.sessionIdentity.resolveSessionFile(request.principal);
292
+ outcome = await driveExternalRoleTurnRounds(prepared, request, {
293
+ roundLimitName: "HeadlessRoundLimit",
294
+ currentSessionId: () => sessionId,
295
+ afterRetry() { sessionKind = "resume"; },
296
+ async runRound({ prompt, abortSignal }) {
297
+ const args = headlessTurnArgs({
298
+ description: config.description,
299
+ prompt,
300
+ systemPromptPath,
301
+ jsonSchema: prepared.jsonSchema,
260
302
  mcpConfigPath,
261
- `${JSON.stringify(headlessMcpConfigDocument(prepared.mcpServers), null, 2)}\n`,
262
- "utf8",
263
- );
264
-
265
- // No round cap on content review (#750); this bound is only for
266
- // correctable mechanical resubmit (non-sole etc.), matching ACP's 8.
267
- for (let attempt = 0; attempt < 8; attempt += 1) {
268
- if (abortSignal?.aborted) {
269
- const closure = await prepared.closeRound();
270
- if ("failure" in closure) {
271
- outcome = { code: null, stderr: "", timedOut: false, knownFailure: closure.failure };
272
- break;
273
- }
274
- outcome = failure("session", "HostAborted", "host-aborted", { sessionId });
275
- break;
276
- }
303
+ ...(request.model?.model !== undefined ? { model: request.model.model } : {}),
304
+ ...(request.model?.thinking !== undefined ? { effort: request.model.thinking } : {}),
305
+ session: sessionKind === "new"
306
+ ? { kind: "new", id: sessionId! }
307
+ : { kind: "resume", id: sessionId! },
308
+ });
277
309
 
278
- const args = headlessTurnArgs({
279
- description: config.description,
280
- prompt,
281
- systemPromptPath,
282
- jsonSchema: prepared.jsonSchema,
283
- mcpConfigPath,
284
- ...(request.model?.model !== undefined ? { model: request.model.model } : {}),
285
- ...(request.model?.thinking !== undefined ? { effort: request.model.thinking } : {}),
286
- session: sessionKind === "new"
287
- ? { kind: "new", id: sessionId! }
288
- : { kind: "resume", id: sessionId! },
289
- });
290
-
291
- let spawned: { code: number | null; stdout: string; stderr: string; timedOut: boolean };
292
- try {
293
- spawned = await spawnHeadlessTurn({
294
- binary: config.binary,
295
- args,
296
- cwd: request.cwd,
297
- env,
298
- ...(abortSignal === undefined ? {} : { signal: abortSignal }),
299
- ...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
300
- });
301
- } catch (error) {
302
- if (
303
- typeof error === "object"
304
- && error !== null
305
- && (error as { code?: unknown }).code === "host-aborted"
306
- ) {
307
- const closure = await prepared.closeRound();
308
- if ("failure" in closure) {
309
- outcome = { code: null, stderr: "", timedOut: false, knownFailure: closure.failure };
310
- break;
310
+ let spawned: { code: number | null; stdout: string; stderr: string; timedOut: boolean };
311
+ try {
312
+ spawned = await spawnHeadlessTurn({
313
+ binary: config.binary,
314
+ args,
315
+ cwd: request.cwd,
316
+ env,
317
+ ...(abortSignal === undefined ? {} : { signal: abortSignal }),
318
+ ...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
319
+ onStdoutLine(line) {
320
+ const trimmed = line.trim();
321
+ if (trimmed === "") return;
322
+ let event: unknown;
323
+ try {
324
+ event = JSON.parse(trimmed) as unknown;
325
+ } catch {
326
+ // Non-JSON noise on stdout is not a host structured event.
327
+ return;
311
328
  }
312
- outcome = failure("session", "HostAborted", "host-aborted", { sessionId });
313
- break;
314
- }
315
- const message = error instanceof Error ? error.message : String(error);
316
- outcome = failure("activation", "HeadlessSpawnFailure", "spawn-failed", {
317
- diagnostic: message,
318
- binary: config.binary,
319
- }, message);
320
- break;
321
- }
322
-
323
- if (spawned.timedOut) {
324
- outcome = {
325
- code: spawned.code,
326
- stderr: spawned.stderr,
327
- timedOut: true,
328
- knownFailure: {
329
- cause: "timeout",
330
- identity: { name: "HeadlessTimeout", code: "timeout" },
331
- details: { sessionId },
332
- },
329
+ // Sitian write failures propagate → spawn rejects → session failure.
330
+ reportHostSessionEvent({
331
+ host: config.hostName,
332
+ cwd: request.cwd,
333
+ sessionParent,
334
+ source: "headless-host",
335
+ event,
336
+ });
337
+ },
338
+ });
339
+ } catch (error) {
340
+ if (isHostAbortedError(error)) throw error;
341
+ const message = error instanceof Error ? error.message : String(error);
342
+ // SitianInfrastructureError.knownCause is session; spawn errno stays activation.
343
+ const isRecordFailure =
344
+ typeof error === "object"
345
+ && error !== null
346
+ && ((error as { knownCause?: unknown }).knownCause === "session"
347
+ || (error as { name?: unknown }).name === "SitianInfrastructureError");
348
+ if (isRecordFailure) {
349
+ return {
350
+ status: "terminal",
351
+ result: failure(
352
+ "session",
353
+ "HostSessionRecordFailure",
354
+ "host-session-record-failed",
355
+ { diagnostic: message, sessionId },
356
+ message,
357
+ ),
333
358
  };
334
- break;
335
359
  }
360
+ return {
361
+ status: "terminal",
362
+ result: failure("activation", "HeadlessSpawnFailure", "spawn-failed", {
363
+ diagnostic: message,
364
+ binary: config.binary,
365
+ }, message),
366
+ };
367
+ }
336
368
 
337
- const envelope = parseHeadlessCliStdout(spawned.stdout);
338
- if (envelope === undefined) {
339
- outcome = {
340
- code: spawned.code,
341
- stderr: spawned.stderr,
342
- timedOut: false,
343
- knownFailure: {
344
- cause: "output",
345
- identity: { name: "HeadlessEmptyOutput", code: "empty-stdout" },
346
- diagnostic: clipDiagnostic(spawned.stderr.trim() || "headless CLI produced no parseable result"),
347
- details: { sessionId, exitCode: spawned.code },
348
- },
349
- };
350
- break;
351
- }
369
+ if (spawned.timedOut) {
370
+ return terminalFromSpawned(spawned, {
371
+ cause: "timeout",
372
+ identity: { name: "HeadlessTimeout", code: "timeout" },
373
+ details: { sessionId },
374
+ });
375
+ }
352
376
 
353
- // Bind the host-reported session id (authoritative for --resume).
354
- if (typeof envelope.session_id === "string" && envelope.session_id !== "") {
355
- sessionId = envelope.session_id;
356
- await config.sessionIdentity.bind(request.principal, sessionId);
357
- }
377
+ const envelope = parseHeadlessCliStdout(spawned.stdout);
378
+ if (envelope === undefined) {
379
+ return terminalFromSpawned(spawned, {
380
+ cause: "output",
381
+ identity: { name: "HeadlessEmptyOutput", code: "empty-stdout" },
382
+ diagnostic: retainDiagnosticTail(spawned.stderr.trim() || "headless CLI produced no parseable result"),
383
+ details: { sessionId, exitCode: spawned.code },
384
+ });
385
+ }
358
386
 
359
- if (envelope.is_error === true || (typeof envelope.subtype === "string" && envelope.subtype.startsWith("error_"))) {
360
- const errorCode =
361
- typeof envelope.subtype === "string" && envelope.subtype.startsWith("error_")
362
- ? envelope.subtype
363
- : envelope.is_error === true
364
- ? "is_error"
365
- : "cli-error";
366
- const diagnostic = typeof envelope.result === "string"
367
- ? envelope.result
368
- : Array.isArray(envelope.errors)
369
- ? envelope.errors.map(String).join("\n")
370
- : clipDiagnostic(spawned.stderr.trim() || "headless CLI reported is_error");
371
- outcome = {
372
- code: spawned.code,
373
- stderr: spawned.stderr,
374
- timedOut: false,
375
- knownFailure: {
376
- cause: "output",
377
- identity: { name: "HeadlessCliError", code: errorCode },
378
- diagnostic,
379
- details: {
380
- sessionId,
381
- subtype: envelope.subtype,
382
- errors: envelope.errors,
383
- exitCode: spawned.code,
384
- },
385
- },
386
- };
387
- break;
388
- }
387
+ if (typeof envelope.session_id === "string" && envelope.session_id !== "") {
388
+ sessionId = envelope.session_id;
389
+ await config.sessionIdentity.bind(request.principal, sessionId);
390
+ }
389
391
 
390
- // Schema channel: host-native structured_output is the terminating receipt
391
- // (#750). Intermediate tools may have already run via MCP during the process.
392
- if (envelope.structured_output !== undefined) {
393
- await prepared.ingestStructuredOutput(envelope.structured_output);
394
- }
395
- const closure = await prepared.closeRound();
396
- if (closure.accepted) {
397
- outcome = { code: 0, stderr: "", timedOut: false };
398
- break;
399
- }
400
- if ("failure" in closure) {
401
- outcome = { code: null, stderr: spawned.stderr, timedOut: false, knownFailure: closure.failure };
402
- break;
403
- }
404
- // Shared envelope already owns the officer/correctable text (#813).
405
- prompt = closure.retry.message;
406
- sessionKind = "resume";
407
- if (attempt === 7) {
408
- outcome = failure("output", "HeadlessRoundLimit", "round-retry-limit", { sessionId });
409
- }
392
+ if (envelope.is_error === true || (typeof envelope.subtype === "string" && envelope.subtype.startsWith("error_"))) {
393
+ const errorCode =
394
+ typeof envelope.subtype === "string" && envelope.subtype.startsWith("error_")
395
+ ? envelope.subtype
396
+ : envelope.is_error === true
397
+ ? "is_error"
398
+ : "cli-error";
399
+ const diagnostic = typeof envelope.result === "string"
400
+ ? envelope.result
401
+ : Array.isArray(envelope.errors)
402
+ ? envelope.errors.map(String).join("\n")
403
+ : retainDiagnosticTail(spawned.stderr.trim() || "headless CLI reported is_error");
404
+ return terminalFromSpawned(spawned, {
405
+ cause: "output",
406
+ identity: { name: "HeadlessCliError", code: errorCode },
407
+ diagnostic,
408
+ details: {
409
+ sessionId,
410
+ subtype: envelope.subtype,
411
+ errors: envelope.errors,
412
+ exitCode: spawned.code,
413
+ },
414
+ });
410
415
  }
411
- } finally {
412
- try {
413
- await prepared.dispose?.();
414
- } catch (cleanupError) {
415
- outcome = withCleanupFailure(outcome, cleanupError);
416
+
417
+ if (envelope.structured_output !== undefined) {
418
+ await prepared.ingestStructuredOutput(envelope.structured_output);
416
419
  }
417
- }
418
- return outcome;
420
+ return { status: "delivered", stderr: spawned.stderr };
421
+ },
419
422
  });
420
- serial = execution.then(() => undefined, () => undefined);
421
- return execution;
422
- },
423
- };
423
+ } finally {
424
+ try {
425
+ await prepared.dispose?.();
426
+ } catch (cleanupError) {
427
+ outcome = withCleanupFailure(outcome, cleanupError);
428
+ }
429
+ }
430
+ return outcome;
431
+ });
424
432
  }