@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.
@@ -99,9 +99,9 @@ async function createSummonEnv(options) {
99
99
  const hostName = options.seat.host ?? "pi";
100
100
  let roleTurnHost = piHost;
101
101
  if (hostName !== "pi") {
102
- const { lookupHostDescription } = await import("./host-descriptions.js");
103
- const { loadProductionAcpHostFactory } = await import("./public-cli/load-production-acp-host.js");
104
- if (lookupHostDescription(hostName) === void 0) {
102
+ const { lookupHostFamily } = await import("./host-descriptions.js");
103
+ const { loadProductionExternalHostFactory } = await import("./public-cli/load-production-external-host.js");
104
+ if (lookupHostFamily(hostName) === void 0) {
105
105
  throw new Error(
106
106
  `public role summons host unregistered: host=${hostName} seat=${options.role}`
107
107
  );
@@ -109,7 +109,7 @@ async function createSummonEnv(options) {
109
109
  let hostPromise;
110
110
  roleTurnHost = {
111
111
  executeTurn: async (request) => {
112
- hostPromise ??= loadProductionAcpHostFactory(options.packageRoot, hostName).then(
112
+ hostPromise ??= loadProductionExternalHostFactory(options.packageRoot, hostName).then(
113
113
  (create) => create({
114
114
  packageRoot: options.packageRoot,
115
115
  principalAuthority
@@ -151,7 +151,8 @@ async function summonPublicRole(options) {
151
151
  } = await import("./public-cli/config.js");
152
152
  const credentials = options.credentials ?? await loadCredentialProviders(agentDir);
153
153
  const config = await loadPublicCliConfig(home);
154
- const seat = resolveEffectiveSeat(config, options.role, credentials);
154
+ const invocation = options.host === void 0 ? void 0 : { host: options.host };
155
+ const seat = resolveEffectiveSeat(config, options.role, credentials, invocation);
155
156
  const env = {
156
157
  ...await createSummonEnv({
157
158
  role: options.role,
@@ -263,6 +264,38 @@ async function summonPublicRole(options) {
263
264
  ...stderr === void 0 || stderr === "" ? {} : { stderr }
264
265
  };
265
266
  }
267
+ async function parentInvocationHost(sourceRunDirectory) {
268
+ const { readFile } = await import("node:fs/promises");
269
+ const path = join(sourceRunDirectory, "invocation.json");
270
+ let text;
271
+ try {
272
+ text = await readFile(path, "utf8");
273
+ } catch (error) {
274
+ const code = error instanceof Error && "code" in error ? error.code : void 0;
275
+ throw new Error(
276
+ `parent invocation.json required for gate source run at ${path}: ${code ?? (error instanceof Error ? error.message : String(error))}`,
277
+ { cause: error }
278
+ );
279
+ }
280
+ let raw;
281
+ try {
282
+ raw = JSON.parse(text);
283
+ } catch (error) {
284
+ throw new Error(
285
+ `parent invocation.json unreadable at ${path}: ${error instanceof Error ? error.message : String(error)}`,
286
+ { cause: error }
287
+ );
288
+ }
289
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
290
+ throw new Error(`parent invocation.json has non-object shape at ${path}`);
291
+ }
292
+ const host = raw.host;
293
+ if (host === void 0) return void 0;
294
+ if (typeof host !== "string" || host.trim() === "") {
295
+ throw new Error(`parent invocation.json host must be a non-empty string at ${path}`);
296
+ }
297
+ return host;
298
+ }
266
299
  async function summonGateOfficer(options) {
267
300
  let home = options.home;
268
301
  if (home === void 0) {
@@ -276,6 +309,7 @@ async function summonGateOfficer(options) {
276
309
  submission: options.submission
277
310
  });
278
311
  }
312
+ const parentHost = await parentInvocationHost(options.sourceRunDirectory);
279
313
  const common = {
280
314
  cwd: options.cwd,
281
315
  ...home === void 0 ? {} : { home },
@@ -284,6 +318,7 @@ async function summonGateOfficer(options) {
284
318
  ...options.signal === void 0 ? {} : { signal: options.signal },
285
319
  ...options.reask === void 0 ? {} : { reviewReask: options.reask },
286
320
  ...gateReviewInstruction === void 0 ? {} : { gateReviewInstruction },
321
+ ...parentHost === void 0 ? {} : { host: parentHost },
287
322
  ...options.roleTurnHost === void 0 ? {} : { roleTurnHost: options.roleTurnHost },
288
323
  ...options.createRunId === void 0 ? {} : { createRunId: options.createRunId }
289
324
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.3771",
3
+ "version": "0.1.3783",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -114,6 +114,33 @@ export async function buildAcpProductionHost(
114
114
  );
115
115
  }
116
116
 
117
+ /**
118
+ * Deferred generic headless production host artifact (#645). Same peer-free
119
+ * selection face as ACP: loaded only when a headless description-table row is
120
+ * selected.
121
+ */
122
+ export async function buildHeadlessProductionHost(
123
+ outfile = "dist/headless-host/production-host.js",
124
+ ) {
125
+ await mkdir(dirname(outfile), { recursive: true });
126
+ await build({
127
+ entryPoints: ["src/headless-host/production-host.ts"],
128
+ outfile,
129
+ format: "esm",
130
+ platform: "node",
131
+ target: "node20",
132
+ bundle: true,
133
+ packages: "external",
134
+ logLevel: "silent",
135
+ });
136
+ // Shared envelope resolves ./mcp-relay.mjs from import.meta.url of the bundle
137
+ // (#645 headless reuses the ACP relay for AK tools under --mcp-config).
138
+ await copyFile(
139
+ resolve("src/acp-host/mcp-relay.mjs"),
140
+ join(dirname(outfile), "mcp-relay.mjs"),
141
+ );
142
+ }
143
+
117
144
  export async function buildPackageArtifacts() {
118
145
  await build({
119
146
  entryPoints: entries.map((name) => `src/${name}.ts`),
@@ -139,6 +166,7 @@ export async function buildPackageArtifacts() {
139
166
  }
140
167
  await buildPublicAkRoleBin();
141
168
  await buildAcpProductionHost();
169
+ await buildHeadlessProductionHost();
142
170
  }
143
171
 
144
172
  const isMain =
@@ -15,7 +15,7 @@ import type {
15
15
  RoleTurnKnownFailure,
16
16
  RoleTurnRequest,
17
17
  } from "../host-contracts.ts";
18
- import { packagedRoleInputFlag, packagedRolePhaseFlag } from "../packaged-role-registry.ts";
18
+ import { packagedRoleInputFlag, packagedRoleOutputTool, packagedRolePhaseFlag } from "../packaged-role-registry.ts";
19
19
  import { stripSkillFrontmatter } from "../package-resources/method-skill.ts";
20
20
  import {
21
21
  createRoleRuntimeExtension,
@@ -131,10 +131,33 @@ export function createComposedAcpRoleTurnHost(
131
131
  });
132
132
  }
133
133
 
134
+ /** JSON Schema draft-07 document for host-native `--json-schema` (headless). */
135
+ export function terminatingToolJsonSchema(parameters: unknown): Readonly<Record<string, unknown>> {
136
+ const cloned = JSON.parse(JSON.stringify(parameters)) as Record<string, unknown>;
137
+ // $schema last so a newer declaration on the tool parameters cannot override draft-07.
138
+ return Object.freeze({
139
+ ...cloned,
140
+ $schema: "http://json-schema.org/draft-07/schema#",
141
+ });
142
+ }
143
+
134
144
  export async function prepareAcpRoleEnvelope(options: {
135
145
  readonly request: RoleTurnRequest;
136
146
  readonly dependencies: RoleRuntimeDependencies;
147
+ /**
148
+ * MCP unix socket path for the protocol-relay child. Always required — AK tools
149
+ * ride this single MCP path; headless structured_output reuses the same
150
+ * terminating-tool ledger path without listing the terminating tool on MCP.
151
+ */
137
152
  readonly socketPath: string;
153
+ /**
154
+ * Whether MCP `tools/list` advertises the role terminating tool.
155
+ * ACP keeps it listed (session-tool receipt). Headless hides it so the host
156
+ * native `--json-schema` / structured_output is the sole schema channel
157
+ * (#750 submission-tool-is-schema-channel) and empty MCP probes cannot
158
+ * pre-empt a later structured_output.
159
+ */
160
+ readonly listTerminatingToolOnMcp?: boolean;
138
161
  /**
139
162
  * Durable principal session path (header layout only).
140
163
  * Production passes DurablePrincipalAuthority.decode(principal).sessionFile so
@@ -143,6 +166,14 @@ export async function prepareAcpRoleEnvelope(options: {
143
166
  readonly sessionFile?: string;
144
167
  }): Promise<AcpPreparedTurn> {
145
168
  const { request } = options;
169
+ if (options.socketPath === "") {
170
+ throw new Error("prepareAcpRoleEnvelope requires socketPath");
171
+ }
172
+ const listTerminatingToolOnMcp = options.listTerminatingToolOnMcp !== false;
173
+ const earlyTerminatingTool = packagedRoleOutputTool(request.activation.role);
174
+ if (earlyTerminatingTool === undefined) {
175
+ throw new Error(`role has no terminating tool: ${request.activation.role}`);
176
+ }
146
177
  const flags = projectAcpActivationFlags(request);
147
178
  const tools = new Map<string, HostToolDefinition>();
148
179
  const handlers = new Map<string, Handler[]>();
@@ -403,6 +434,87 @@ export async function prepareAcpRoleEnvelope(options: {
403
434
  await emit("tool_execution_end", { toolCallId, toolName, isError: projected.isError });
404
435
  return projected;
405
436
  }
437
+ /**
438
+ * Shared terminating/support tool path for MCP relay and headless structured_output.
439
+ * Books the call, runs execute, projects tool_result; does not emit turn_end (closeRound).
440
+ */
441
+ async function invokeAkTool(name: string, args: unknown): Promise<{
442
+ content: ContentPart[];
443
+ isError: boolean;
444
+ blocked?: true;
445
+ }> {
446
+ const tool = tools.get(name);
447
+ if (tool === undefined) throw new Error(`Unknown AK tool: ${name}`);
448
+ const toolCallId = randomUUID();
449
+ calls.push({ toolCallId, toolName: name });
450
+ // First-record-then-audit: book the tool-call leaf in memory before execute so
451
+ // judge/doctor subject gates see the candidate on parent session books.
452
+ sessionEntries.push({
453
+ type: "message",
454
+ message: {
455
+ role: "assistant" as const,
456
+ content: [{
457
+ type: "toolCall",
458
+ id: toolCallId,
459
+ name,
460
+ arguments: args ?? {},
461
+ }],
462
+ },
463
+ });
464
+ try {
465
+ await emit("tool_execution_start", { toolCallId, toolName: name });
466
+ const blocked = (await emit("tool_call", { toolCallId, toolName: name, input: (args ?? {}) as Record<string, unknown> }))
467
+ .some((value) => typeof value === "object" && value !== null && "block" in value && value.block === true);
468
+ if (blocked) {
469
+ // Lawful seatbelt/block stays bare rejection — not infrastructure (#593 r3).
470
+ return { content: [{ type: "text", text: `AK tool blocked: ${name}` }], isError: true, blocked: true };
471
+ }
472
+ } catch (error) {
473
+ // Pre-execution emit failure shares the non-correctable infra pathway (#593 r3).
474
+ const declared = declareRoundInfrastructureFailure(error);
475
+ try {
476
+ const projected = await projectToolResult(toolCallId, name, {
477
+ content: declared.content,
478
+ details: declared.details,
479
+ isError: true,
480
+ });
481
+ return { content: projected.content, isError: true };
482
+ } catch {
483
+ return { content: declared.content, isError: true };
484
+ }
485
+ }
486
+ try {
487
+ const result = await tool.execute(toolCallId, (args ?? {}) as never, undefined, undefined, context);
488
+ const projected = await projectToolResult(toolCallId, name, {
489
+ content: result.content,
490
+ details: result.details,
491
+ isError: false,
492
+ });
493
+ // Candidate only: seal waits for closeRound after the host round boundary.
494
+ return { content: projected.content, isError: projected.isError };
495
+ } catch (error) {
496
+ let content: ContentPart[];
497
+ let details: Record<string, unknown>;
498
+ if (isCorrectableExecuteError(error)) {
499
+ const projected = projectCorrectableExecuteRejection(error);
500
+ content = [{ type: "text", text: projected.diagnostic }];
501
+ details = projected.details;
502
+ } else {
503
+ // Slot-before-abort; projectToolResult may still project durable details.
504
+ ({ content, details } = declareRoundInfrastructureFailure(error));
505
+ }
506
+ // The shared envelope's tool_result handler is the sole classifier:
507
+ // it projects either the structured submission non-pass (correctable
508
+ // rejection) or the typed infrastructure fact onto the reply.
509
+ const projected = await projectToolResult(toolCallId, name, {
510
+ content,
511
+ details,
512
+ isError: true,
513
+ });
514
+ return { content: projected.content, isError: projected.isError };
515
+ }
516
+ }
517
+
406
518
  function reply(socket: Socket, id: number, result?: unknown, error?: unknown): void {
407
519
  const rpcError = error instanceof Error
408
520
  ? { code: "ak-relay-failure", name: error.name, message: error.message }
@@ -424,7 +536,9 @@ export async function prepareAcpRoleEnvelope(options: {
424
536
  if (rpc.token !== token) { reply(socket, rpc.id, undefined, "unauthorized relay"); return; }
425
537
  try {
426
538
  if (rpc.method === "tools/list") {
427
- reply(socket, rpc.id, { tools: [...tools.values()].map((tool) => {
539
+ const listed = [...tools.values()].filter((tool) =>
540
+ listTerminatingToolOnMcp || tool.name !== earlyTerminatingTool);
541
+ reply(socket, rpc.id, { tools: listed.map((tool) => {
428
542
  return { name: tool.name, description: tool.description, inputSchema: tool.parameters };
429
543
  }) });
430
544
  return;
@@ -433,95 +547,24 @@ export async function prepareAcpRoleEnvelope(options: {
433
547
  const params = rpc.params as ToolCallParams | undefined;
434
548
  const name = params?.name;
435
549
  if (typeof name !== "string") throw new Error("MCP tool name is missing");
436
- const tool = tools.get(name);
437
- if (tool === undefined) throw new Error(`Unknown AK tool: ${name}`);
438
- const toolCallId = randomUUID();
439
- calls.push({ toolCallId, toolName: name });
440
- // First-record-then-audit: book the tool-call leaf in memory before execute so
441
- // judge/doctor subject gates see the candidate on parent session books.
442
- {
443
- const message = {
444
- role: "assistant" as const,
445
- content: [{
446
- type: "toolCall",
447
- id: toolCallId,
448
- name,
449
- arguments: params?.arguments ?? {},
450
- }],
451
- };
452
- sessionEntries.push({ type: "message", message });
550
+ // Headless schema channel owns the terminating receipt — refuse MCP
551
+ // terminating calls so an empty probe cannot book a non-sealable candidate.
552
+ if (!listTerminatingToolOnMcp && name === earlyTerminatingTool) {
553
+ throw new Error(`terminating tool ${name} is schema-channel only on this host`);
453
554
  }
454
- try {
455
- await emit("tool_execution_start", { toolCallId, toolName: name });
456
- const blocked = (await emit("tool_call", { toolCallId, toolName: name, input: params?.arguments ?? {} }))
457
- .some((value) => typeof value === "object" && value !== null && "block" in value && value.block === true);
458
- if (blocked) {
459
- // Lawful seatbelt/block stays bare RPC rejection — not infrastructure (#593 r3).
460
- reply(socket, rpc.id, undefined, new Error(`AK tool blocked: ${name}`));
461
- return;
462
- }
463
- } catch (error) {
464
- // Pre-execution emit failure (e.g. observation writer) shares the same
465
- // non-correctable infra pathway as execute throws (#593 r3).
466
- const declared = declareRoundInfrastructureFailure(error);
467
- try {
468
- const projected = await projectToolResult(toolCallId, name, {
469
- content: declared.content,
470
- details: declared.details,
471
- isError: true,
472
- });
473
- reply(socket, rpc.id, {
474
- content: projected.content,
475
- isError: true,
476
- });
477
- } catch {
478
- // Slot already filled; durable projection may have partially failed.
479
- reply(socket, rpc.id, {
480
- content: declared.content,
481
- isError: true,
482
- });
483
- }
555
+ const outcome = await invokeAkTool(name, params?.arguments ?? {});
556
+ if (outcome.blocked === true) {
557
+ reply(socket, rpc.id, undefined, new Error(outcome.content.map((p) => p.type === "text" ? p.text : "").join("")));
484
558
  return;
485
559
  }
486
- try {
487
- const result = await tool.execute(toolCallId, (params?.arguments ?? {}) as never, undefined, undefined, context);
488
- const projected = await projectToolResult(toolCallId, name, {
489
- content: result.content,
490
- details: result.details,
491
- isError: false,
492
- });
493
- // Candidate only: do not emit turn_end here. Seal waits for the typed ACP
494
- // round boundary (closeRound after session/prompt), so delayed siblings stay
495
- // in the same round instead of becoming silent post-seal anomalies.
496
- reply(socket, rpc.id, { content: projected.content, ...(projected.isError ? { isError: true } : {}) });
497
- } catch (error) {
498
- let content: ContentPart[];
499
- let details: Record<string, unknown>;
500
- if (isCorrectableExecuteError(error)) {
501
- const projected = projectCorrectableExecuteRejection(error);
502
- content = [{ type: "text", text: projected.diagnostic }];
503
- details = projected.details;
504
- } else {
505
- // Slot-before-abort; projectToolResult may still project durable details.
506
- ({ content, details } = declareRoundInfrastructureFailure(error));
507
- }
508
- // The shared envelope's tool_result handler is the sole classifier:
509
- // it projects either the structured submission non-pass (correctable
510
- // rejection) or the typed infrastructure fact onto the reply.
511
- const projected = await projectToolResult(toolCallId, name, {
512
- content,
513
- details,
514
- isError: true,
515
- });
516
- reply(socket, rpc.id, { content: projected.content, ...(projected.isError ? { isError: true } : {}) });
517
- }
560
+ reply(socket, rpc.id, { content: outcome.content, ...(outcome.isError ? { isError: true } : {}) });
518
561
  } catch (error) { reply(socket, rpc.id, undefined, error); }
519
562
  })();
520
563
  }
521
564
  });
522
565
  }
523
- await listen(server, options.socketPath);
524
566
  const relay = fileURLToPath(new URL("./mcp-relay.mjs", import.meta.url));
567
+ await listen(server, options.socketPath);
525
568
  let disposed = false;
526
569
  // Tools execute in this process (relay is protocol-only). Mirror Pi's child-env
527
570
  // AK_ROLE_RUN_DIR / AK_ROLE_COURT_ATTEMPT injection onto the parent so ledger
@@ -552,8 +595,6 @@ export async function prepareAcpRoleEnvelope(options: {
552
595
  } catch (error) {
553
596
  cleanupFailures.push(error);
554
597
  }
555
- // Server closure is unconditional: a shutdown-handler failure must not leave
556
- // the listening MCP server keeping the completed invocation alive.
557
598
  try {
558
599
  const closeAll = (server as unknown as { closeAllConnections?: () => void }).closeAllConnections;
559
600
  if (typeof closeAll === "function") closeAll.call(server);
@@ -571,6 +612,14 @@ export async function prepareAcpRoleEnvelope(options: {
571
612
  }
572
613
  };
573
614
 
615
+ const terminatingToolName: string = earlyTerminatingTool;
616
+ async function ingestStructuredOutput(params: unknown): Promise<void> {
617
+ // MCP path may already have invoked the terminating tool this round; skip the
618
+ // duplicate so structured_output + tool-call does not arm non-sole.
619
+ if (calls.some((call) => call.toolName === terminatingToolName)) return;
620
+ await invokeAkTool(terminatingToolName, params ?? {});
621
+ }
622
+
574
623
  const closeRound: AcpPreparedTurn["closeRound"] = async () => {
575
624
  // Typed round boundary: hand the complete call list to the shared ledger once.
576
625
  if (calls.length > 0) {
@@ -654,6 +703,11 @@ export async function prepareAcpRoleEnvelope(options: {
654
703
  else process.env.AK_ROLE_COURT_ATTEMPT = request.courtAttemptId;
655
704
  runDirInjected = true;
656
705
 
706
+ const terminating = tools.get(terminatingToolName);
707
+ if (terminating === undefined) {
708
+ throw new Error(`terminating tool not registered after activation: ${terminatingToolName}`);
709
+ }
710
+ const jsonSchema = terminatingToolJsonSchema(terminating.parameters);
657
711
  return {
658
712
  mcpServers: [{
659
713
  name: `ak-${request.activation.role}`,
@@ -669,6 +723,9 @@ export async function prepareAcpRoleEnvelope(options: {
669
723
  abortSignal: hostAbort.signal,
670
724
  closeRound,
671
725
  dispose,
726
+ jsonSchema,
727
+ terminatingToolName,
728
+ ingestStructuredOutput,
672
729
  };
673
730
  } catch (error) {
674
731
  // listen already succeeded; dispose is not yet caller-owned. Release the
@@ -43,6 +43,18 @@ export type AcpPreparedTurn = Readonly<{
43
43
  | { readonly accepted: false; readonly failure: RoleTurnKnownFailure }
44
44
  >;
45
45
  dispose?(): Promise<void>;
46
+ /**
47
+ * Headless CLI family (#645): role terminating-tool schema for host-native
48
+ * `--json-schema`. Present for every prepared turn; ACP ignores it.
49
+ */
50
+ jsonSchema: Readonly<Record<string, unknown>>;
51
+ /** Terminating tool name whose schema is `jsonSchema`. */
52
+ terminatingToolName: string;
53
+ /**
54
+ * Headless CLI family: feed host-native `structured_output` through the same
55
+ * terminating-tool path the MCP relay uses (ledger + gates). ACP ignores it.
56
+ */
57
+ ingestStructuredOutput(params: unknown): Promise<void>;
46
58
  }>;
47
59
 
48
60
  /** Fold structured system-prompt authority into the provider-visible ACP override. */
@@ -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
+ }