@akagilnc/pi-workflow-roles 0.1.3758 → 0.1.3783
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/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/dist/acp-host/production-host.js +1735 -948
- package/dist/collector-config.js +0 -1
- package/dist/collector-github.js +199 -2
- package/dist/collector-identity.js +128 -41
- package/dist/collector-ledger.js +38 -9
- package/dist/collector-receipt.js +19 -7
- package/dist/collector-role.js +330 -369
- package/dist/collector-target.js +169 -0
- package/dist/collector-tool-schemas.js +51 -14
- package/dist/headless-host/description.js +77 -0
- package/dist/headless-host/mcp-relay.mjs +119 -0
- package/dist/headless-host/production-host.js +26590 -0
- package/dist/host-descriptions.js +44 -3
- package/dist/package-contracts/collector-output.js +32 -0
- package/dist/package-contracts/terminating-infrastructure.js +13 -12
- package/dist/pi/role-turn-host.js +1 -2
- package/dist/public-cli/github-remote.js +45 -0
- package/dist/public-cli/invocation.js +53 -54
- package/dist/public-cli/load-production-external-host.js +19 -0
- package/dist/public-cli/load-production-headless-host.js +36 -0
- package/dist/public-cli/main.js +857 -310
- package/dist/public-cli/option-definitions.js +6 -4
- package/dist/public-cli/run-lifecycle.js +3 -3
- package/dist/public-cli/settlement.js +83 -6
- package/dist/public-role-summons.js +40 -5
- package/dist/role-runtime.js +137 -7
- package/dist/submission-correctable-error.js +24 -0
- package/extensions/role-runtime.ts +0 -1
- package/package.json +1 -1
- package/scripts/build-package.mjs +28 -0
- package/src/acp-host/role-envelope.ts +146 -103
- package/src/acp-host/role-turn-host.ts +12 -0
- package/src/collector-config.ts +0 -1
- package/src/collector-github.ts +236 -2
- package/src/collector-identity.ts +148 -40
- package/src/collector-ledger.ts +48 -10
- package/src/collector-receipt.ts +33 -14
- package/src/collector-role.ts +376 -450
- package/src/collector-target.ts +207 -0
- package/src/collector-tool-schemas.ts +62 -15
- package/src/headless-host/description.ts +123 -0
- package/src/headless-host/production-host.ts +75 -0
- package/src/headless-host/role-turn-host.ts +424 -0
- package/src/host-contracts.ts +2 -1
- package/src/host-descriptions.ts +53 -6
- package/src/package-contracts/collector-output.ts +72 -0
- package/src/package-contracts/terminating-infrastructure.ts +24 -13
- package/src/pi/role-turn-host.ts +1 -2
- package/src/public-cli/cli.ts +12 -3
- package/src/public-cli/collector-run.ts +3 -2
- package/src/public-cli/github-remote.ts +45 -0
- package/src/public-cli/invocation.ts +60 -59
- package/src/public-cli/load-production-external-host.ts +29 -0
- package/src/public-cli/load-production-headless-host.ts +48 -0
- package/src/public-cli/main.ts +5 -0
- package/src/public-cli/option-definitions.ts +6 -4
- package/src/public-cli/run-lifecycle.ts +2 -2
- package/src/public-cli/settlement.ts +82 -6
- package/src/public-role-summons.ts +62 -9
- package/src/role-runtime.ts +166 -13
- package/src/submission-correctable-error.ts +38 -0
|
@@ -0,0 +1,424 @@
|
|
|
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
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { writeFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { RoleTurnHost, RoleTurnKnownFailure, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
|
|
13
|
+
import {
|
|
14
|
+
renderAcpSystemPromptOverride,
|
|
15
|
+
type AcpPreparedTurn,
|
|
16
|
+
type AcpSessionIdentityAuthority,
|
|
17
|
+
} from "../acp-host/role-turn-host.ts";
|
|
18
|
+
import {
|
|
19
|
+
headlessMcpConfigDocument,
|
|
20
|
+
headlessTurnArgs,
|
|
21
|
+
type HeadlessHostDescription,
|
|
22
|
+
} from "./description.ts";
|
|
23
|
+
|
|
24
|
+
export type HeadlessRoleTurnHostConfig = Readonly<{
|
|
25
|
+
description: HeadlessHostDescription;
|
|
26
|
+
sessionIdentity: AcpSessionIdentityAuthority;
|
|
27
|
+
binary: string;
|
|
28
|
+
prepare(request: RoleTurnRequest): Promise<AcpPreparedTurn>;
|
|
29
|
+
env?: NodeJS.ProcessEnv;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
function failure(
|
|
33
|
+
cause: "activation" | "session" | "output" | "provider",
|
|
34
|
+
name: string,
|
|
35
|
+
code: string,
|
|
36
|
+
details?: Readonly<Record<string, unknown>>,
|
|
37
|
+
diagnostic?: string,
|
|
38
|
+
): RoleTurnResult {
|
|
39
|
+
return {
|
|
40
|
+
code: null,
|
|
41
|
+
stderr: "",
|
|
42
|
+
timedOut: false,
|
|
43
|
+
knownFailure: {
|
|
44
|
+
cause,
|
|
45
|
+
identity: { name, code },
|
|
46
|
+
...(diagnostic === undefined ? {} : { diagnostic }),
|
|
47
|
+
...(details === undefined ? {} : { details }),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One headless CLI result envelope (`--output-format json`). */
|
|
53
|
+
export type HeadlessCliResult = Readonly<{
|
|
54
|
+
session_id?: string;
|
|
55
|
+
is_error?: boolean;
|
|
56
|
+
subtype?: string;
|
|
57
|
+
result?: unknown;
|
|
58
|
+
structured_output?: unknown;
|
|
59
|
+
errors?: unknown;
|
|
60
|
+
permission_denials?: unknown;
|
|
61
|
+
[key: string]: unknown;
|
|
62
|
+
}>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 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.
|
|
69
|
+
*/
|
|
70
|
+
export function parseHeadlessCliStdout(stdout: string): HeadlessCliResult | undefined {
|
|
71
|
+
const trimmed = stdout.trim();
|
|
72
|
+
if (trimmed === "") return undefined;
|
|
73
|
+
try {
|
|
74
|
+
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
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// fall through
|
|
83
|
+
}
|
|
84
|
+
// Defensive: if a description still requests stream-json, keep only the result line.
|
|
85
|
+
let last: HeadlessCliResult | undefined;
|
|
86
|
+
for (const line of trimmed.split("\n")) {
|
|
87
|
+
const text = line.trim();
|
|
88
|
+
if (text === "") continue;
|
|
89
|
+
try {
|
|
90
|
+
const value = JSON.parse(text) as unknown;
|
|
91
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
92
|
+
const record = value as HeadlessCliResult & { type?: unknown };
|
|
93
|
+
if (record.type === "result" || record.structured_output !== undefined) {
|
|
94
|
+
last = record;
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// skip non-JSON noise lines
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return last;
|
|
101
|
+
}
|
|
102
|
+
|
|
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]`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function spawnHeadlessTurn(options: {
|
|
112
|
+
readonly binary: string;
|
|
113
|
+
readonly args: readonly string[];
|
|
114
|
+
readonly cwd: string;
|
|
115
|
+
readonly env: NodeJS.ProcessEnv;
|
|
116
|
+
readonly signal?: AbortSignal;
|
|
117
|
+
readonly timeoutMs?: number;
|
|
118
|
+
}): Promise<{ code: number | null; stdout: string; stderr: string; timedOut: boolean }> {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
if (options.signal?.aborted) {
|
|
121
|
+
reject(Object.assign(new Error("headless host aborted"), { code: "host-aborted" }));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const child = spawn(options.binary, [...options.args], {
|
|
125
|
+
cwd: options.cwd,
|
|
126
|
+
env: options.env,
|
|
127
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
128
|
+
});
|
|
129
|
+
let stdout = "";
|
|
130
|
+
let stderr = "";
|
|
131
|
+
let settled = false;
|
|
132
|
+
let timedOut = false;
|
|
133
|
+
let timer: NodeJS.Timeout | undefined;
|
|
134
|
+
const settle = (code: number | null): void => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
138
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
139
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
140
|
+
};
|
|
141
|
+
const onAbort = (): void => {
|
|
142
|
+
child.kill("SIGTERM");
|
|
143
|
+
if (settled) return;
|
|
144
|
+
settled = true;
|
|
145
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
146
|
+
reject(Object.assign(new Error("headless host aborted"), { code: "host-aborted" }));
|
|
147
|
+
};
|
|
148
|
+
child.stdout.setEncoding("utf8").on("data", (chunk: string) => { stdout += chunk; });
|
|
149
|
+
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
|
+
}
|
|
154
|
+
});
|
|
155
|
+
child.on("error", (error) => {
|
|
156
|
+
if (settled) return;
|
|
157
|
+
settled = true;
|
|
158
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
159
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
160
|
+
reject(error);
|
|
161
|
+
});
|
|
162
|
+
child.on("close", (code) => settle(code));
|
|
163
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
164
|
+
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
|
165
|
+
timer = setTimeout(() => {
|
|
166
|
+
timedOut = true;
|
|
167
|
+
child.kill("SIGTERM");
|
|
168
|
+
}, options.timeoutMs);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
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
|
+
*/
|
|
181
|
+
function withCleanupFailure(outcome: RoleTurnResult, cleanupError: unknown): RoleTurnResult {
|
|
182
|
+
const message = cleanupErrorMessage(cleanupError);
|
|
183
|
+
if (outcome.knownFailure === undefined) {
|
|
184
|
+
return failure(
|
|
185
|
+
"session",
|
|
186
|
+
"HeadlessDisposeFailure",
|
|
187
|
+
"dispose-failed",
|
|
188
|
+
{ cleanupError: message },
|
|
189
|
+
message,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
...outcome,
|
|
194
|
+
knownFailure: {
|
|
195
|
+
...outcome.knownFailure,
|
|
196
|
+
details: {
|
|
197
|
+
...(outcome.knownFailure.details ?? {}),
|
|
198
|
+
cleanupError: message,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
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();
|
|
210
|
+
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;
|
|
237
|
+
|
|
238
|
+
const abortSignal =
|
|
239
|
+
request.signal === undefined
|
|
240
|
+
? prepared.abortSignal
|
|
241
|
+
: prepared.abortSignal === undefined
|
|
242
|
+
? request.signal
|
|
243
|
+
: AbortSignal.any([prepared.abortSignal, request.signal]);
|
|
244
|
+
|
|
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
|
+
};
|
|
252
|
+
|
|
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(
|
|
260
|
+
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
|
+
}
|
|
277
|
+
|
|
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;
|
|
311
|
+
}
|
|
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
|
+
},
|
|
333
|
+
};
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
|
|
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
|
+
}
|
|
352
|
+
|
|
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
|
+
}
|
|
358
|
+
|
|
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
|
+
}
|
|
389
|
+
|
|
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
|
+
// Correctable rejection → resume same session with plain resubmit prompt.
|
|
405
|
+
prompt = `The prior terminal submission was rejected (${closure.retry.code}). Resubmit it as the sole terminal structured output (or the sole terminating tool call). Rejected call ids: ${closure.retry.toolCallIds.join(", ") || "none"}.`;
|
|
406
|
+
sessionKind = "resume";
|
|
407
|
+
if (attempt === 7) {
|
|
408
|
+
outcome = failure("output", "HeadlessRoundLimit", "round-retry-limit", { sessionId });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
} finally {
|
|
412
|
+
try {
|
|
413
|
+
await prepared.dispose?.();
|
|
414
|
+
} catch (cleanupError) {
|
|
415
|
+
outcome = withCleanupFailure(outcome, cleanupError);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return outcome;
|
|
419
|
+
});
|
|
420
|
+
serial = execution.then(() => undefined, () => undefined);
|
|
421
|
+
return execution;
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
package/src/host-contracts.ts
CHANGED
|
@@ -103,7 +103,8 @@ export type RoleTurnActivation =
|
|
|
103
103
|
| {
|
|
104
104
|
readonly role: "collector";
|
|
105
105
|
readonly repo: string;
|
|
106
|
-
|
|
106
|
+
/** Bound PR when known at admission; omit when role will bind from materials. */
|
|
107
|
+
readonly pr?: string;
|
|
107
108
|
readonly requestManifestPath?: string;
|
|
108
109
|
}
|
|
109
110
|
| { readonly role: "doctor"; readonly casePath: string }
|
package/src/host-descriptions.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Packaged host description
|
|
3
|
-
* Key = seat-table `host` value
|
|
2
|
+
* Packaged host description tables (#729 / #731 / #645).
|
|
3
|
+
* Key = seat-table `host` value.
|
|
4
|
+
* - ACP family rows feed the generic ACP factory.
|
|
5
|
+
* - Headless CLI family rows feed the generic headless factory (#645; #646 codex next).
|
|
4
6
|
* pi is the in-process default, not a row.
|
|
5
|
-
* Unregistered names fail closed (#510);
|
|
7
|
+
* Unregistered names fail closed (#510); these tables do not fallback.
|
|
6
8
|
*/
|
|
7
9
|
import type { AcpHostDescription } from "./acp-host/description.ts";
|
|
10
|
+
import type { HeadlessHostDescription } from "./headless-host/description.ts";
|
|
8
11
|
|
|
9
12
|
/** Grok CLI reads vendor-private compat surfaces unless each is disabled by name. */
|
|
10
13
|
const PRIVATE_COMPAT_ENV = Object.fromEntries(
|
|
@@ -15,6 +18,8 @@ const PRIVATE_COMPAT_ENV = Object.fromEntries(
|
|
|
15
18
|
|
|
16
19
|
export const DEFAULT_ROLE_TURN_HOST = "pi" as const;
|
|
17
20
|
|
|
21
|
+
export type HostFamily = "acp" | "headless";
|
|
22
|
+
|
|
18
23
|
export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = Object.freeze({
|
|
19
24
|
/** Operator home `~/.grok`, native session/load resume, `agent [--model X] stdio`. */
|
|
20
25
|
"grok-build": Object.freeze({
|
|
@@ -60,17 +65,59 @@ export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = O
|
|
|
60
65
|
}),
|
|
61
66
|
});
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Headless CLI family (#645). Claude is the first row; codex (#646) adds another.
|
|
70
|
+
* fixedArgs: print mode, isolation without `--bare` (OAuth stays), full permissions.
|
|
71
|
+
* `--setting-sources` empty = load no user/project/local CLAUDE.md/hooks/skills
|
|
72
|
+
* (role envelope is delivered via `--system-prompt` wholesale replace).
|
|
73
|
+
* `--strict-mcp-config` with no `--mcp-config` drops operator MCP + claude.ai connectors.
|
|
74
|
+
*/
|
|
75
|
+
export const HEADLESS_HOST_DESCRIPTIONS: Readonly<Record<string, HeadlessHostDescription>> = Object.freeze({
|
|
76
|
+
"claude": Object.freeze({
|
|
77
|
+
binaryFromHome: Object.freeze([".local", "bin", "claude"]),
|
|
78
|
+
sessionBindingFile: "claude-headless-session.json",
|
|
79
|
+
fixedArgs: Object.freeze([
|
|
80
|
+
// One result envelope (not stream-json): typed receipt only; no event-stream copy.
|
|
81
|
+
"--output-format", "json",
|
|
82
|
+
"--permission-mode", "bypassPermissions",
|
|
83
|
+
// Empty sources: no user/project/local operator surface (envelope owns materials).
|
|
84
|
+
"--setting-sources", "",
|
|
85
|
+
// With adapter-supplied --mcp-config only (AK relay); drops operator + claude.ai MCP.
|
|
86
|
+
"--strict-mcp-config",
|
|
87
|
+
]),
|
|
88
|
+
promptFlag: "-p",
|
|
89
|
+
modelFlag: "--model",
|
|
90
|
+
effortFlag: "--effort",
|
|
91
|
+
// File path keeps large role envelopes off ARG_MAX.
|
|
92
|
+
systemPromptFlag: "--system-prompt-file",
|
|
93
|
+
jsonSchemaFlag: "--json-schema",
|
|
94
|
+
mcpConfigFlag: "--mcp-config",
|
|
95
|
+
sessionIdFlag: "--session-id",
|
|
96
|
+
resumeFlag: "--resume",
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
|
|
63
100
|
export function lookupHostDescription(host: string): AcpHostDescription | undefined {
|
|
64
101
|
return Object.hasOwn(HOST_DESCRIPTIONS, host) ? HOST_DESCRIPTIONS[host] : undefined;
|
|
65
102
|
}
|
|
66
103
|
|
|
104
|
+
export function lookupHeadlessHostDescription(host: string): HeadlessHostDescription | undefined {
|
|
105
|
+
return Object.hasOwn(HEADLESS_HOST_DESCRIPTIONS, host) ? HEADLESS_HOST_DESCRIPTIONS[host] : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function lookupHostFamily(host: string): HostFamily | undefined {
|
|
109
|
+
if (lookupHostDescription(host) !== undefined) return "acp";
|
|
110
|
+
if (lookupHeadlessHostDescription(host) !== undefined) return "headless";
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
67
114
|
export function packagedExternalHostNames(): readonly string[] {
|
|
68
|
-
return Object.keys(HOST_DESCRIPTIONS);
|
|
115
|
+
return [...Object.keys(HOST_DESCRIPTIONS), ...Object.keys(HEADLESS_HOST_DESCRIPTIONS)];
|
|
69
116
|
}
|
|
70
117
|
|
|
71
|
-
/** Legal persistent/invocation host: default pi, or a table key. */
|
|
118
|
+
/** Legal persistent/invocation host: default pi, or a table key (any family). */
|
|
72
119
|
export function assertRegisteredHostName(host: string): string {
|
|
73
|
-
if (host === DEFAULT_ROLE_TURN_HOST ||
|
|
120
|
+
if (host === DEFAULT_ROLE_TURN_HOST || lookupHostFamily(host) !== undefined) {
|
|
74
121
|
return host;
|
|
75
122
|
}
|
|
76
123
|
throw new Error(`unregistered host: ${host}`);
|
|
@@ -59,10 +59,29 @@ export type CollectorEvidenceRecord = {
|
|
|
59
59
|
authoritativeTime?: string | null;
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* #676 C: sole structured projection-fact type (builder + package leaf).
|
|
64
|
+
* Builder always emits the full object; package leaf keeps it optional so old
|
|
65
|
+
* volumes without the field stay readable — missing is unknown, never defaulted.
|
|
66
|
+
*/
|
|
67
|
+
export type CollectorSubmissionProjection = {
|
|
68
|
+
findingsSource: "absent" | "array" | "unreadable";
|
|
69
|
+
findingsProjectedCount: number;
|
|
70
|
+
findingsUnprojected: boolean;
|
|
71
|
+
unfinishedReasonsSource: "absent" | "array" | "unreadable";
|
|
72
|
+
unfinishedReasonsProjectedCount: number;
|
|
73
|
+
unfinishedReasonsUnprojected: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
62
76
|
export type CollectorReceipt = {
|
|
63
77
|
host: "github.com";
|
|
64
78
|
repository: string;
|
|
65
79
|
prNumber: number;
|
|
80
|
+
/**
|
|
81
|
+
* Final observed PR state; non-OPEN still delivers materials (#676 D6).
|
|
82
|
+
* Absent/unreadable on old volumes is unknown — never defaulted to OPEN.
|
|
83
|
+
*/
|
|
84
|
+
prState?: string;
|
|
66
85
|
manifestDigest: string;
|
|
67
86
|
activationTime: string;
|
|
68
87
|
deadlineTime: string;
|
|
@@ -70,6 +89,13 @@ export type CollectorReceipt = {
|
|
|
70
89
|
finalSnapshotId: string;
|
|
71
90
|
targetHead: string;
|
|
72
91
|
groups: CollectorIdentityGroup[];
|
|
92
|
+
unfinishedReasons?: string[];
|
|
93
|
+
/**
|
|
94
|
+
* Distinguishes readable-empty from unprojected original submission content (#676 C).
|
|
95
|
+
* Package leaf may carry a partial projection when some sibling keys are unreadable —
|
|
96
|
+
* missing keys stay absent (never defaulted false/0/OPEN).
|
|
97
|
+
*/
|
|
98
|
+
submissionProjection?: Partial<CollectorSubmissionProjection>;
|
|
73
99
|
requestAttempts: CollectorRequestAttempt[];
|
|
74
100
|
snapshots: CollectorSnapshot[];
|
|
75
101
|
evidenceRecords: CollectorEvidenceRecord[];
|
|
@@ -97,10 +123,18 @@ export function validateAcceptedCollectorReceipt(value: unknown): CollectorRecei
|
|
|
97
123
|
materials: records(safeGet(group, "materials")),
|
|
98
124
|
findings: records(safeGet(group, "findings")),
|
|
99
125
|
}));
|
|
126
|
+
const unfinishedRaw = safeGet(value, "unfinishedReasons");
|
|
127
|
+
const unfinishedReasons = strings(unfinishedRaw);
|
|
128
|
+
// #676 C: project readable submissionProjection fields independently — do not
|
|
129
|
+
// drop the whole block when one sibling key is the wrong type (left-拾遗1).
|
|
130
|
+
const submissionProjection = projectSubmissionProjection(safeGet(value, "submissionProjection"));
|
|
131
|
+
const prStateRaw = safeGet(value, "prState");
|
|
132
|
+
const prState = typeof prStateRaw === "string" ? prStateRaw : undefined;
|
|
100
133
|
return {
|
|
101
134
|
host: safeGet(value, "host") as "github.com",
|
|
102
135
|
repository: safeGet(value, "repository") as string,
|
|
103
136
|
prNumber: safeGet(value, "prNumber") as number,
|
|
137
|
+
...(prState === undefined ? {} : { prState }),
|
|
104
138
|
manifestDigest: safeGet(value, "manifestDigest") as string,
|
|
105
139
|
activationTime: safeGet(value, "activationTime") as string,
|
|
106
140
|
deadlineTime: safeGet(value, "deadlineTime") as string,
|
|
@@ -108,6 +142,8 @@ export function validateAcceptedCollectorReceipt(value: unknown): CollectorRecei
|
|
|
108
142
|
finalSnapshotId: safeGet(value, "finalSnapshotId") as string,
|
|
109
143
|
targetHead: safeGet(value, "targetHead") as string,
|
|
110
144
|
groups,
|
|
145
|
+
...(unfinishedReasons.length > 0 ? { unfinishedReasons } : {}),
|
|
146
|
+
...(submissionProjection === undefined ? {} : { submissionProjection }),
|
|
111
147
|
requestAttempts: records(safeGet(value, "requestAttempts")) as unknown as CollectorRequestAttempt[],
|
|
112
148
|
snapshots: records(safeGet(value, "snapshots")).map((snapshot) => ({
|
|
113
149
|
snapshotId: safeGet(snapshot, "snapshotId"), observedAt: safeGet(snapshot, "observedAt"), completedAt: safeGet(snapshot, "completedAt"), completedMono: safeGet(snapshot, "completedMono"), host: safeGet(snapshot, "host"), repository: safeGet(snapshot, "repository"), prNumber: safeGet(snapshot, "prNumber"), prState: safeGet(snapshot, "prState"), headOid: safeGet(snapshot, "headOid"), complete: safeGet(snapshot, "complete"), evidenceIds: strings(safeGet(snapshot, "evidenceIds")), pageDiagnostics: records(safeGet(snapshot, "pageDiagnostics")), normalizedByteLength: safeGet(snapshot, "normalizedByteLength"),
|
|
@@ -115,3 +151,39 @@ export function validateAcceptedCollectorReceipt(value: unknown): CollectorRecei
|
|
|
115
151
|
evidenceRecords: records(safeGet(value, "evidenceRecords")).map((record) => ({ evidenceId: safeGet(record, "evidenceId"), kind: safeGet(record, "kind"), versionId: safeGet(record, "versionId"), contentDigest: safeGet(record, "contentDigest"), firstObservedAt: safeGet(record, "firstObservedAt"), githubId: safeGet(record, "githubId"), authorLogin: safeGet(record, "authorLogin"), htmlUrl: safeGet(record, "htmlUrl"), authoritativeTime: safeGet(record, "authoritativeTime") } as CollectorEvidenceRecord)),
|
|
116
152
|
};
|
|
117
153
|
}
|
|
154
|
+
|
|
155
|
+
/** Readable projection facts only; omit keys that are the wrong type — never fabricate defaults. */
|
|
156
|
+
function projectSubmissionProjection(
|
|
157
|
+
raw: unknown,
|
|
158
|
+
): Partial<CollectorSubmissionProjection> | undefined {
|
|
159
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
160
|
+
const p = raw as Record<string, unknown>;
|
|
161
|
+
const out: Partial<CollectorSubmissionProjection> = {};
|
|
162
|
+
if (
|
|
163
|
+
p["findingsSource"] === "absent" ||
|
|
164
|
+
p["findingsSource"] === "array" ||
|
|
165
|
+
p["findingsSource"] === "unreadable"
|
|
166
|
+
) {
|
|
167
|
+
out.findingsSource = p["findingsSource"];
|
|
168
|
+
}
|
|
169
|
+
if (typeof p["findingsProjectedCount"] === "number") {
|
|
170
|
+
out.findingsProjectedCount = p["findingsProjectedCount"];
|
|
171
|
+
}
|
|
172
|
+
if (typeof p["findingsUnprojected"] === "boolean") {
|
|
173
|
+
out.findingsUnprojected = p["findingsUnprojected"];
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
p["unfinishedReasonsSource"] === "absent" ||
|
|
177
|
+
p["unfinishedReasonsSource"] === "array" ||
|
|
178
|
+
p["unfinishedReasonsSource"] === "unreadable"
|
|
179
|
+
) {
|
|
180
|
+
out.unfinishedReasonsSource = p["unfinishedReasonsSource"];
|
|
181
|
+
}
|
|
182
|
+
if (typeof p["unfinishedReasonsProjectedCount"] === "number") {
|
|
183
|
+
out.unfinishedReasonsProjectedCount = p["unfinishedReasonsProjectedCount"];
|
|
184
|
+
}
|
|
185
|
+
if (typeof p["unfinishedReasonsUnprojected"] === "boolean") {
|
|
186
|
+
out.unfinishedReasonsUnprojected = p["unfinishedReasonsUnprojected"];
|
|
187
|
+
}
|
|
188
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
189
|
+
}
|
|
@@ -18,21 +18,32 @@ export const INFRASTRUCTURE_FAILURE_DECLARATION_KEY =
|
|
|
18
18
|
"infrastructureFailure" as const;
|
|
19
19
|
export const INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY = "diagnostic" as const;
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Shared declaration fragment for model guidance (#541 / #676 C / ADR 0057).
|
|
23
|
+
* Nested field declarations + descriptions only — host must not pure-shape-reject
|
|
24
|
+
* the envelope (第 0 条). Runtime `failOnInfrastructureFailureDeclaration` still
|
|
25
|
+
* recognizes a real non-empty diagnostic string as the failure declaration.
|
|
26
|
+
* No required/minLength/type host gates on the declaration fragment.
|
|
27
|
+
*/
|
|
28
|
+
const infrastructureFailureNested = Type.Object(
|
|
29
|
+
{
|
|
30
|
+
[INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY]: Type.Unknown({
|
|
31
|
+
description:
|
|
32
|
+
"非空基础设施失败诊断字符串。无失败时必须省略整个 infrastructureFailure。形状指引,非 schema 闸。",
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
additionalProperties: true,
|
|
37
|
+
description:
|
|
38
|
+
"基础设施真实失败声明(如需)。规范形:{ diagnostic: 非空诊断字符串 };无失败时必须省略。形状指引,非 schema 闸。",
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
// Open nested required so host cannot pure-shape-reject the declaration fragment.
|
|
42
|
+
(infrastructureFailureNested as unknown as { required: string[] }).required = [];
|
|
43
|
+
|
|
22
44
|
const infrastructureFailureDeclarationSchema = Type.Object(
|
|
23
45
|
{
|
|
24
|
-
[INFRASTRUCTURE_FAILURE_DECLARATION_KEY]:
|
|
25
|
-
{
|
|
26
|
-
[INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY]: Type.String({
|
|
27
|
-
minLength: 1,
|
|
28
|
-
description: "非空基础设施失败诊断",
|
|
29
|
-
}),
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
additionalProperties: true,
|
|
33
|
-
description: "基础设施失败声明",
|
|
34
|
-
},
|
|
35
|
-
),
|
|
46
|
+
[INFRASTRUCTURE_FAILURE_DECLARATION_KEY]: infrastructureFailureNested,
|
|
36
47
|
},
|
|
37
48
|
{ additionalProperties: true },
|
|
38
49
|
);
|