@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.
- package/dist/acp-host/production-host.js +24606 -24532
- package/dist/engine-detour-tool.js +2 -2
- package/dist/engine-detour.js +23 -7
- package/dist/headless-host/production-host.js +4197 -4139
- package/dist/host-descriptions.js +5 -2
- package/dist/pi/role-turn-host.js +20 -92
- package/dist/public-cli/load-production-acp-host.js +1 -1
- package/dist/public-cli/load-production-external-host.js +10 -0
- package/dist/public-cli/load-production-headless-host.js +1 -1
- package/dist/public-cli/main.js +95 -102
- package/dist/public-role-summons.js +7 -49
- package/dist/reviewer-agent.js +1 -0
- package/dist/reviewer-child-executor.js +3 -2
- package/dist/role-activation-flags.js +50 -0
- package/dist/role-runtime.js +8 -3
- package/package.json +1 -1
- package/scripts/build-package.mjs +2 -2
- package/src/acp-host/production-host.ts +31 -94
- package/src/acp-host/role-turn-host.ts +178 -263
- package/src/diagnostic-tail.ts +18 -0
- package/src/engine-detour-tool.ts +5 -4
- package/src/engine-detour.ts +44 -9
- package/src/external-host-turn-loop.ts +172 -0
- package/src/headless-host/production-host.ts +10 -7
- package/src/headless-host/role-turn-host.ts +271 -263
- package/src/host-descriptions.ts +5 -2
- package/src/host-session-record.ts +41 -0
- package/src/pi/role-turn-host.ts +18 -93
- package/src/prepared-role-turn.ts +68 -0
- package/src/public-cli/cli.ts +11 -20
- package/src/public-cli/load-production-acp-host.ts +2 -1
- package/src/public-cli/load-production-external-host.ts +17 -0
- package/src/public-cli/load-production-headless-host.ts +2 -1
- package/src/public-role-summons.ts +12 -69
- package/src/reviewer-agent.ts +12 -1
- package/src/reviewer-child-executor.ts +10 -2
- package/src/role-activation-flags.ts +53 -0
- package/src/{acp-host/role-envelope.ts → role-envelope.ts} +28 -84
- package/src/role-runtime-dependencies.ts +91 -0
- package/src/role-runtime.ts +14 -4
- package/src/{acp-host/session-identity.ts → session-identity.ts} +6 -6
- /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,
|
|
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
|
-
|
|
15
|
-
type
|
|
16
|
-
type
|
|
17
|
-
} from "../
|
|
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:
|
|
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<
|
|
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 (
|
|
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` (
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
208
|
+
reject(hostAbortedError("headless host aborted"));
|
|
147
209
|
};
|
|
148
|
-
child.stdout.setEncoding("utf8").on("data", (chunk: string) => {
|
|
210
|
+
child.stdout.setEncoding("utf8").on("data", (chunk: string) => { flushStdoutLines(chunk, false); });
|
|
149
211
|
child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
|
|
150
|
-
//
|
|
151
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
...(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
if (
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
await prepared.
|
|
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
|
-
|
|
420
|
+
return { status: "delivered", stderr: spawned.stderr };
|
|
421
|
+
},
|
|
419
422
|
});
|
|
420
|
-
|
|
421
|
-
|
|
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
|
}
|