@akagilnc/pi-workflow-roles 0.1.3771 → 0.1.3789
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/acp-host/production-host.js +793 -393
- package/dist/atomic-write.js +23 -0
- package/dist/collector-handbook.js +142 -0
- package/dist/collector-ledger.js +39 -47
- package/dist/collector-role.js +103 -14
- package/dist/collector-tool-schemas.js +25 -1
- package/dist/headless-host/description.js +77 -0
- package/dist/headless-host/mcp-relay.mjs +119 -0
- package/dist/headless-host/production-host.js +26841 -0
- package/dist/host-descriptions.js +44 -3
- 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 +244 -234
- package/dist/public-cli/option-definitions.js +2 -2
- package/dist/public-role-summons.js +40 -5
- package/dist/role-runtime.js +3 -0
- package/extensions/role-runtime.ts +2 -0
- package/package.json +1 -1
- package/resources/collector-bot-handbook.md +33 -0
- package/scripts/build-package.mjs +28 -0
- package/src/acp-host/production-host.ts +4 -0
- package/src/acp-host/role-envelope.ts +141 -84
- package/src/acp-host/role-turn-host.ts +12 -0
- package/src/collector-handbook.ts +194 -0
- package/src/collector-ledger.ts +42 -62
- package/src/collector-role.ts +117 -11
- package/src/collector-tool-schemas.ts +25 -1
- 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-descriptions.ts +53 -6
- package/src/public-cli/cli.ts +2 -2
- 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 +2 -2
- package/src/public-role-summons.ts +62 -9
- package/src/role-runtime.ts +5 -0
|
@@ -4,9 +4,33 @@ import { openToolObject } from "./open-tool-schema.ts";
|
|
|
4
4
|
import { withInfrastructureFailureDeclaration } from "./package-contracts/terminating-infrastructure.ts";
|
|
5
5
|
|
|
6
6
|
export const collectorObserveArgsSchema = Type.Object({}, { additionalProperties: false });
|
|
7
|
+
/**
|
|
8
|
+
* Stable request identity: non-empty after trim, no leading/trailing whitespace.
|
|
9
|
+
* Host schema + ledger identity seam share this contract (#677 / D2 dedup).
|
|
10
|
+
*/
|
|
11
|
+
export const COLLECTOR_REQUEST_ID_PATTERN = /^\S(?:.*\S)?$/;
|
|
7
12
|
export const collectorRequestArgsSchema = Type.Object({
|
|
8
|
-
requestId: Type.String({
|
|
13
|
+
requestId: Type.String({
|
|
14
|
+
minLength: 1,
|
|
15
|
+
pattern: COLLECTOR_REQUEST_ID_PATTERN.source,
|
|
16
|
+
description: "请求身份(配置 id 或角色判定的稳定 id;首尾无空白)",
|
|
17
|
+
}),
|
|
9
18
|
snapshotId: Type.String({ minLength: 1, description: "最新留存观察快照" }),
|
|
19
|
+
body: Type.Optional(Type.String({
|
|
20
|
+
minLength: 1,
|
|
21
|
+
description: "角色判定的请求正文;request-manifest 未收录该 requestId 时必填",
|
|
22
|
+
})),
|
|
23
|
+
}, { additionalProperties: false });
|
|
24
|
+
/** UTF-8 byte ceiling per handbook body — keeps next activation materials inside context budget. */
|
|
25
|
+
export const COLLECTOR_HANDBOOK_MAX_BYTES = 64 * 1024;
|
|
26
|
+
export const collectorHandbookWriteArgsSchema = Type.Object({
|
|
27
|
+
scope: Type.Union([
|
|
28
|
+
Type.Literal("general"),
|
|
29
|
+
Type.Literal("repo"),
|
|
30
|
+
], { description: "general=通用手册;repo=当前仓库差异" }),
|
|
31
|
+
body: Type.String({
|
|
32
|
+
description: `手册全文(opaque 工作记忆;整份替换;UTF-8 至多 ${COLLECTOR_HANDBOOK_MAX_BYTES} 字节)`,
|
|
33
|
+
}),
|
|
10
34
|
}, { additionalProperties: false });
|
|
11
35
|
export const collectorReadArgsSchema = Type.Object({
|
|
12
36
|
evidenceId: Type.String({ minLength: 1, description: "observe 返回的材料证据 id(evidenceId)" }),
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One headless CLI host description (#645 / #752).
|
|
3
|
+
* Every host-specific value the generic headless adapter needs — binary, argv
|
|
4
|
+
* shape, session binding — is data here; lifecycle stays one copy so #646 codex
|
|
5
|
+
* is another row, not a fork.
|
|
6
|
+
*/
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export type HeadlessHostDescription = Readonly<{
|
|
10
|
+
/** Binary path segments relative to the operator home. */
|
|
11
|
+
binaryFromHome: readonly string[];
|
|
12
|
+
/** Durable session-id binding filename beside the session principal. */
|
|
13
|
+
sessionBindingFile: string;
|
|
14
|
+
/**
|
|
15
|
+
* Host-native print-mode flags that never change per turn (no prompt).
|
|
16
|
+
* Model / effort / system-prompt / schema / session / resume / mcp-config
|
|
17
|
+
* are composed by the adapter from the turn request — not listed here.
|
|
18
|
+
*/
|
|
19
|
+
fixedArgs: readonly string[];
|
|
20
|
+
/** Print-mode flag that takes the user prompt as its value (e.g. `-p`). */
|
|
21
|
+
promptFlag: string;
|
|
22
|
+
/** CLI flag for the seat model (e.g. `--model`). */
|
|
23
|
+
modelFlag: string;
|
|
24
|
+
/** CLI flag for the seat thinking level (e.g. `--effort`); value is opaque pass-through. */
|
|
25
|
+
effortFlag: string;
|
|
26
|
+
/**
|
|
27
|
+
* CLI flag whose value is a path to the system-prompt file
|
|
28
|
+
* (`--system-prompt-file`). File delivery keeps ARG_MAX off the critical path.
|
|
29
|
+
*/
|
|
30
|
+
systemPromptFlag: string;
|
|
31
|
+
/** CLI flag whose value is a JSON Schema document string. */
|
|
32
|
+
jsonSchemaFlag: string;
|
|
33
|
+
/** CLI flag whose value is a path or JSON string for MCP servers. */
|
|
34
|
+
mcpConfigFlag: string;
|
|
35
|
+
/** CLI flag to mint a fresh session id (initial turn). */
|
|
36
|
+
sessionIdFlag: string;
|
|
37
|
+
/** CLI flag to resume a prior session id. */
|
|
38
|
+
resumeFlag: string;
|
|
39
|
+
}>;
|
|
40
|
+
|
|
41
|
+
/** Absolute agent binary for one operator home. */
|
|
42
|
+
export function resolveHeadlessBinary(
|
|
43
|
+
description: HeadlessHostDescription,
|
|
44
|
+
operatorHome: string,
|
|
45
|
+
): string {
|
|
46
|
+
return join(operatorHome, ...description.binaryFromHome);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build one headless CLI argv for a single process turn.
|
|
51
|
+
* Shape: `<promptFlag> <prompt> <fixedArgs…> <system/schema/mcp/model/effort/session…>`.
|
|
52
|
+
*/
|
|
53
|
+
export function headlessTurnArgs(options: {
|
|
54
|
+
readonly description: HeadlessHostDescription;
|
|
55
|
+
readonly prompt: string;
|
|
56
|
+
/** Absolute path written by the adapter; paired with `systemPromptFlag`. */
|
|
57
|
+
readonly systemPromptPath: string;
|
|
58
|
+
readonly jsonSchema: Readonly<Record<string, unknown>>;
|
|
59
|
+
/** Absolute path to host-native MCP config JSON; omitted when no AK MCP servers. */
|
|
60
|
+
readonly mcpConfigPath?: string;
|
|
61
|
+
readonly model?: string;
|
|
62
|
+
readonly effort?: string;
|
|
63
|
+
/** Fresh session: pass as session id. Resume: pass as resume id. */
|
|
64
|
+
readonly session: { readonly kind: "new"; readonly id: string } | { readonly kind: "resume"; readonly id: string };
|
|
65
|
+
}): string[] {
|
|
66
|
+
const { description } = options;
|
|
67
|
+
const args: string[] = [
|
|
68
|
+
description.promptFlag,
|
|
69
|
+
options.prompt,
|
|
70
|
+
...description.fixedArgs,
|
|
71
|
+
description.systemPromptFlag,
|
|
72
|
+
options.systemPromptPath,
|
|
73
|
+
description.jsonSchemaFlag,
|
|
74
|
+
JSON.stringify(options.jsonSchema),
|
|
75
|
+
];
|
|
76
|
+
if (options.mcpConfigPath !== undefined && options.mcpConfigPath !== "") {
|
|
77
|
+
args.push(description.mcpConfigFlag, options.mcpConfigPath);
|
|
78
|
+
}
|
|
79
|
+
if (options.model !== undefined && options.model !== "") {
|
|
80
|
+
args.push(description.modelFlag, options.model);
|
|
81
|
+
}
|
|
82
|
+
if (options.effort !== undefined && options.effort !== "") {
|
|
83
|
+
args.push(description.effortFlag, options.effort);
|
|
84
|
+
}
|
|
85
|
+
if (options.session.kind === "new") {
|
|
86
|
+
args.push(description.sessionIdFlag, options.session.id);
|
|
87
|
+
} else {
|
|
88
|
+
args.push(description.resumeFlag, options.session.id);
|
|
89
|
+
}
|
|
90
|
+
return args;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Project shared-envelope MCP server rows into Claude `--mcp-config` JSON.
|
|
95
|
+
* Env stays a plain object (Claude CLI shape); ACP rows use `{name,value}[]`.
|
|
96
|
+
*/
|
|
97
|
+
export function headlessMcpConfigDocument(
|
|
98
|
+
mcpServers: readonly Readonly<Record<string, unknown>>[],
|
|
99
|
+
): Readonly<{ mcpServers: Readonly<Record<string, Readonly<Record<string, unknown>>>> }> {
|
|
100
|
+
const servers: Record<string, Record<string, unknown>> = {};
|
|
101
|
+
for (const row of mcpServers) {
|
|
102
|
+
const name = typeof row.name === "string" ? row.name : undefined;
|
|
103
|
+
const command = typeof row.command === "string" ? row.command : undefined;
|
|
104
|
+
if (name === undefined || name === "" || command === undefined || command === "") continue;
|
|
105
|
+
const entry: Record<string, unknown> = { command };
|
|
106
|
+
if (Array.isArray(row.args)) entry.args = row.args;
|
|
107
|
+
if (Array.isArray(row.env)) {
|
|
108
|
+
const env: Record<string, string> = {};
|
|
109
|
+
for (const item of row.env) {
|
|
110
|
+
if (typeof item !== "object" || item === null) continue;
|
|
111
|
+
const record = item as { name?: unknown; value?: unknown };
|
|
112
|
+
if (typeof record.name === "string" && typeof record.value === "string") {
|
|
113
|
+
env[record.name] = record.value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (Object.keys(env).length > 0) entry.env = env;
|
|
117
|
+
} else if (typeof row.env === "object" && row.env !== null && !Array.isArray(row.env)) {
|
|
118
|
+
entry.env = row.env;
|
|
119
|
+
}
|
|
120
|
+
servers[name] = entry;
|
|
121
|
+
}
|
|
122
|
+
return Object.freeze({ mcpServers: Object.freeze(servers) });
|
|
123
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production composition for the generic headless CLI RoleTurnHost (#645).
|
|
3
|
+
* Agent subprocesses inherit the operator home and credentials in place.
|
|
4
|
+
* No HOME rewrite, no isolated home, no credential parameters — CLI owns auth.
|
|
5
|
+
* Sitian records on the run are the dossier; host private sessions stay private.
|
|
6
|
+
*
|
|
7
|
+
* Intermediate AK tools ride the shared envelope MCP relay via host-native
|
|
8
|
+
* `--mcp-config` under `--strict-mcp-config`. The terminating receipt is the
|
|
9
|
+
* host-native `--json-schema` / structured_output schema channel only
|
|
10
|
+
* (#750 submission-tool-is-schema-channel) — terminating tool is not listed on MCP.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
import type { DurablePrincipalAuthority, RoleTurnHost } from "../host-contracts.ts";
|
|
15
|
+
import { createAcpRoleRuntimeDependencies } from "../acp-host/production-host.ts";
|
|
16
|
+
import { prepareAcpRoleEnvelope } from "../acp-host/role-envelope.ts";
|
|
17
|
+
import { createAcpSessionIdentityAuthority } from "../acp-host/session-identity.ts";
|
|
18
|
+
import { resolveHeadlessBinary, type HeadlessHostDescription } from "./description.ts";
|
|
19
|
+
import { createHeadlessRoleTurnHost } from "./role-turn-host.ts";
|
|
20
|
+
|
|
21
|
+
export type ProductionHeadlessHostOptions = Readonly<{
|
|
22
|
+
packageRoot: string;
|
|
23
|
+
principalAuthority: DurablePrincipalAuthority;
|
|
24
|
+
description: HeadlessHostDescription;
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Assemble a production headless RoleTurnHost from the shared envelope prepare
|
|
29
|
+
* (MCP relay for AK tools) and one host description row. Binary is resolved
|
|
30
|
+
* from each turn's operator home (`request.home`).
|
|
31
|
+
*/
|
|
32
|
+
export function createProductionHeadlessRoleTurnHost(
|
|
33
|
+
options: ProductionHeadlessHostOptions,
|
|
34
|
+
): RoleTurnHost {
|
|
35
|
+
const { packageRoot, principalAuthority, description } = options;
|
|
36
|
+
const sessionIdentity = createAcpSessionIdentityAuthority(
|
|
37
|
+
principalAuthority,
|
|
38
|
+
description.sessionBindingFile,
|
|
39
|
+
);
|
|
40
|
+
const roleRuntimeDependencies = createAcpRoleRuntimeDependencies(packageRoot);
|
|
41
|
+
|
|
42
|
+
const innerFor = (operatorHome: string): RoleTurnHost =>
|
|
43
|
+
createHeadlessRoleTurnHost({
|
|
44
|
+
description,
|
|
45
|
+
sessionIdentity,
|
|
46
|
+
binary: resolveHeadlessBinary(description, operatorHome),
|
|
47
|
+
env: {
|
|
48
|
+
...process.env,
|
|
49
|
+
AK_PACKAGE_ROOT: packageRoot,
|
|
50
|
+
},
|
|
51
|
+
prepare: (request) =>
|
|
52
|
+
prepareAcpRoleEnvelope({
|
|
53
|
+
request,
|
|
54
|
+
dependencies: roleRuntimeDependencies,
|
|
55
|
+
sessionFile: sessionIdentity.resolveSessionFile(request.principal),
|
|
56
|
+
// Same MCP relay as ACP so intermediate AK tools stay reachable;
|
|
57
|
+
// headless adapter projects the row into --mcp-config.
|
|
58
|
+
socketPath: `/tmp/ak-headless-mcp-${randomUUID()}.sock`,
|
|
59
|
+
// Schema channel owns the terminating receipt; hide it from MCP list.
|
|
60
|
+
listTerminatingToolOnMcp: false,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let cachedHome: string | undefined;
|
|
65
|
+
let cachedHost: RoleTurnHost | undefined;
|
|
66
|
+
return {
|
|
67
|
+
executeTurn(request) {
|
|
68
|
+
if (cachedHost === undefined || cachedHome !== request.home) {
|
|
69
|
+
cachedHome = request.home;
|
|
70
|
+
cachedHost = innerFor(request.home);
|
|
71
|
+
}
|
|
72
|
+
return cachedHost.executeTurn(request);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -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
|
+
}
|