@akagilnc/pi-workflow-roles 0.1.3771 → 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.
@@ -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
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Packaged host description table (#729 / #731).
3
- * Key = seat-table `host` value; the row is the generic ACP factory's input.
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); this table does not fallback.
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 || lookupHostDescription(host) !== undefined) {
120
+ if (host === DEFAULT_ROLE_TURN_HOST || lookupHostFamily(host) !== undefined) {
74
121
  return host;
75
122
  }
76
123
  throw new Error(`unregistered host: ${host}`);
@@ -45,7 +45,7 @@ import type { CliIo } from "./cli-io.ts";
45
45
  import type { PostAdmissionEnv } from "./post-admission.ts";
46
46
  import type { RoleTurnHost, RoleTurnRequest } from "../host-contracts.ts";
47
47
  import { packagedExternalHostNames } from "../host-descriptions.ts";
48
- import { loadProductionAcpHostFactory } from "./load-production-acp-host.ts";
48
+ import { loadProductionExternalHostFactory } from "./load-production-external-host.ts";
49
49
  import {
50
50
  createPiRoleTurnHost,
51
51
  appendPiSessionCustomEntry,
@@ -347,7 +347,7 @@ function resolveRoleTurnHost(
347
347
  ok: true as const,
348
348
  host: {
349
349
  executeTurn: async (request: RoleTurnRequest) => {
350
- hostPromise ??= loadProductionAcpHostFactory(env.packageRoot, name).then((create) =>
350
+ hostPromise ??= loadProductionExternalHostFactory(env.packageRoot, name).then((create) =>
351
351
  create({
352
352
  packageRoot: env.packageRoot,
353
353
  principalAuthority: options.principalAuthority,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Family-dispatch loader for packaged external RoleTurnHost factories (#645).
3
+ * ACP rows → production ACP host; headless rows → production headless host.
4
+ * Unregistered names fail closed before any import.
5
+ */
6
+ import { lookupHostFamily } from "../host-descriptions.ts";
7
+ import type { DurablePrincipalAuthority, RoleTurnHost } from "../host-contracts.ts";
8
+ import { loadProductionAcpHostFactory } from "./load-production-acp-host.ts";
9
+ import { loadProductionHeadlessHostFactory } from "./load-production-headless-host.ts";
10
+
11
+ export type ProductionExternalHostFactory = (options: {
12
+ packageRoot: string;
13
+ principalAuthority: DurablePrincipalAuthority;
14
+ }) => RoleTurnHost;
15
+
16
+ /** Resolve the production host factory for one registered external host key. */
17
+ export async function loadProductionExternalHostFactory(
18
+ packageRoot: string,
19
+ host: string,
20
+ ): Promise<ProductionExternalHostFactory> {
21
+ const family = lookupHostFamily(host);
22
+ if (family === "acp") {
23
+ return loadProductionAcpHostFactory(packageRoot, host);
24
+ }
25
+ if (family === "headless") {
26
+ return loadProductionHeadlessHostFactory(packageRoot, host);
27
+ }
28
+ throw new Error(`unregistered host: ${host}`);
29
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Deferred loader for the packaged generic headless RoleTurnHost factory (#645).
3
+ *
4
+ * Public ak-role bin must not statically value-import the production host (or its
5
+ * role-runtime edges). Specifier is runtime-constructed so esbuild leaves this
6
+ * import external (ADR 0052 discovery stays peer-free).
7
+ */
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+
12
+ import type { HeadlessHostDescription } from "../headless-host/description.ts";
13
+ import { lookupHeadlessHostDescription } from "../host-descriptions.ts";
14
+ import type { DurablePrincipalAuthority, RoleTurnHost } from "../host-contracts.ts";
15
+
16
+ export type ProductionHeadlessHostFactory = (options: {
17
+ packageRoot: string;
18
+ principalAuthority: DurablePrincipalAuthority;
19
+ }) => RoleTurnHost;
20
+
21
+ type GenericHeadlessHostFactory = (options: {
22
+ packageRoot: string;
23
+ principalAuthority: DurablePrincipalAuthority;
24
+ description: HeadlessHostDescription;
25
+ }) => RoleTurnHost;
26
+
27
+ /**
28
+ * Resolve the production headless host factory for one registered headless host
29
+ * key without a static graph edge into the public CLI bundle.
30
+ */
31
+ export async function loadProductionHeadlessHostFactory(
32
+ packageRoot: string,
33
+ host: string,
34
+ ): Promise<ProductionHeadlessHostFactory> {
35
+ const description = lookupHeadlessHostDescription(host);
36
+ if (description === undefined) {
37
+ throw new Error(`unregistered headless host: ${host}`);
38
+ }
39
+ const built = join(packageRoot, "dist/headless-host/production-host.js");
40
+ const source = join(packageRoot, "src/headless-host/production-host.ts");
41
+ const target = existsSync(built) ? built : source;
42
+ const href = pathToFileURL(target).href;
43
+ const mod = (await import(href)) as {
44
+ createProductionHeadlessRoleTurnHost: GenericHeadlessHostFactory;
45
+ };
46
+ const create = mod.createProductionHeadlessRoleTurnHost;
47
+ return (options) => create({ ...options, description });
48
+ }
@@ -26,6 +26,11 @@ function resolvePackageRoot(binDir: string): string {
26
26
 
27
27
  const packageRoot = resolvePackageRoot(here);
28
28
 
29
+ // Nested institutional summons resolve package root from this env (#675 / #645).
30
+ // Pin to the running bin's package so a stale outer install pin cannot load a
31
+ // host family this tree owns but the older install lacks.
32
+ process.env.AK_ROLE_PACKAGE_ROOT = packageRoot;
33
+
29
34
  // The host-provided runtime must be resolvable before the CLI module graph loads it.
30
35
  ensureHostPiRuntimeResolvable(packageRoot);
31
36