@akagilnc/pi-workflow-roles 0.1.3556 → 0.1.3565

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.
@@ -3,9 +3,10 @@ import { createInterface } from "node:readline";
3
3
 
4
4
  import type { RoleTurnHost, RoleTurnKnownFailure, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
5
5
  import { renderAgentStartMaterials } from "../agent-start-materials.ts";
6
+ import type { AcpHostDescription } from "./description.ts";
6
7
 
7
- /** ACP v1 surface used by the Grok adapter. Protocol details stay in this module. */
8
- export interface GrokAcpConnection {
8
+ /** ACP v1 surface used by the generic ACP adapter. Protocol details stay in this module. */
9
+ export interface AcpConnection {
9
10
  request(method: string, params: Readonly<Record<string, unknown>>): Promise<Readonly<Record<string, unknown>>>;
10
11
  notify(method: string, params: Readonly<Record<string, unknown>>): void;
11
12
  /** Subscribe to agent→client notifications (session/update stream, etc.). */
@@ -15,7 +16,7 @@ export interface GrokAcpConnection {
15
16
 
16
17
  /** The shared envelope, prepared before session/new (systemPrompt delivery) and
17
18
  * able to observe the host's real builtin tool surface once it arrives post-session. */
18
- export type GrokPreparedTurn = Readonly<{
19
+ export type AcpPreparedTurn = Readonly<{
19
20
  mcpServers: readonly Readonly<Record<string, unknown>>[];
20
21
  /**
21
22
  * Structured system-prompt authority. `body` is the provider-facing prompt
@@ -45,24 +46,26 @@ export type GrokPreparedTurn = Readonly<{
45
46
  }>;
46
47
 
47
48
  /** Fold structured system-prompt authority into the provider-visible ACP override. */
48
- export function renderGrokSystemPromptOverride(authority: {
49
+ export function renderAcpSystemPromptOverride(authority: {
49
50
  readonly body: string;
50
51
  readonly materials: readonly unknown[];
51
52
  }): string {
52
53
  return renderAgentStartMaterials(authority.body, authority.materials);
53
54
  }
54
55
 
55
- export type GrokSessionIdentityAuthority = Readonly<{
56
+ export type AcpSessionIdentityAuthority = Readonly<{
56
57
  load(principal: RoleTurnRequest["principal"]): Promise<string | undefined>;
57
58
  bind(principal: RoleTurnRequest["principal"], sessionId: string): Promise<void>;
58
59
  /** Durable principal session path for layout ownership / isAvailable — not a rebuild source (#617 DK-4). */
59
60
  resolveSessionFile(principal: RoleTurnRequest["principal"]): string;
60
61
  }>;
61
62
 
62
- export type GrokRoleTurnHostConfig = Readonly<{
63
- sessionIdentity: GrokSessionIdentityAuthority;
64
- connect(request: RoleTurnRequest): Promise<GrokAcpConnection>;
65
- prepare(request: RoleTurnRequest): Promise<GrokPreparedTurn>;
63
+ export type AcpRoleTurnHostConfig = Readonly<{
64
+ sessionIdentity: AcpSessionIdentityAuthority;
65
+ /** Whether a bound resume reuses the native session or mints a fresh one. */
66
+ boundResume: AcpHostDescription["boundResume"];
67
+ connect(request: RoleTurnRequest): Promise<AcpConnection>;
68
+ prepare(request: RoleTurnRequest): Promise<AcpPreparedTurn>;
66
69
  }>;
67
70
 
68
71
  function failure(cause: "activation" | "session" | "output", name: string, code: string, details?: Readonly<Record<string, unknown>>): RoleTurnResult {
@@ -81,7 +84,7 @@ function failure(cause: "activation" | "session" | "output", name: string, code:
81
84
  type RpcReply = { readonly id?: unknown; readonly method?: unknown; readonly params?: unknown; readonly result?: unknown; readonly error?: unknown };
82
85
 
83
86
  function hostAbortedError(): Error & { readonly code: "host-aborted" } {
84
- return Object.assign(new Error("Grok host aborted"), { code: "host-aborted" as const });
87
+ return Object.assign(new Error("ACP host aborted"), { code: "host-aborted" as const });
85
88
  }
86
89
 
87
90
  function acpError(code: string, message: string, cause?: unknown): Error & { readonly code: string } {
@@ -89,23 +92,14 @@ function acpError(code: string, message: string, cause?: unknown): Error & { rea
89
92
  }
90
93
 
91
94
  /** One ACP JSON-RPC stdio process. Natural close/SIGTERM are its only lifecycle exits. */
92
- export function connectGrokAcpStdio(options: {
95
+ export function connectAcpStdio(options: {
93
96
  readonly binary: string;
97
+ readonly args: readonly string[];
94
98
  readonly cwd: string;
95
99
  readonly env: NodeJS.ProcessEnv;
96
- readonly model?: string;
97
- readonly toolset?: string;
98
100
  readonly onNotification?: (method: string, params: Readonly<Record<string, unknown>>) => void;
99
- }): Promise<GrokAcpConnection> {
100
- const args = [
101
- "agent",
102
- ...(options.model === undefined ? [] : ["--model", options.model]),
103
- "stdio",
104
- ];
105
- const env = options.toolset === undefined
106
- ? options.env
107
- : { ...options.env, GROK_CONFIG: JSON.stringify({ toolset: options.toolset }) };
108
- const child = spawn(options.binary, args, { cwd: options.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
101
+ }): Promise<AcpConnection> {
102
+ const child = spawn(options.binary, [...options.args], { cwd: options.cwd, env: options.env, stdio: ["pipe", "pipe", "pipe"] });
109
103
  const pending = new Map<number, { resolve(value: Readonly<Record<string, unknown>>): void; reject(error: Error): void }>();
110
104
  const notificationHandlers: Array<(method: string, params: Readonly<Record<string, unknown>>) => void> = [];
111
105
  if (options.onNotification !== undefined) notificationHandlers.push(options.onNotification);
@@ -128,12 +122,12 @@ export function connectGrokAcpStdio(options: {
128
122
  child.kill("SIGTERM");
129
123
  };
130
124
  child.stderr.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; });
131
- child.on("error", (error) => settleClosed(acpError("acp-process-error", `Grok ACP process error: ${error.message}`, error)));
125
+ child.on("error", (error) => settleClosed(acpError("acp-process-error", `ACP process error: ${error.message}`, error)));
132
126
  createInterface({ input: child.stdout }).on("line", (line) => {
133
127
  let message: RpcReply;
134
128
  try { message = JSON.parse(line) as RpcReply; }
135
129
  catch (error) {
136
- terminate(acpError("acp-invalid-json", `Invalid Grok ACP JSON: ${String(error)}`, error));
130
+ terminate(acpError("acp-invalid-json", `Invalid ACP JSON: ${String(error)}`, error));
137
131
  return;
138
132
  }
139
133
  if (typeof message.method === "string") {
@@ -142,14 +136,14 @@ export function connectGrokAcpStdio(options: {
142
136
  for (const handler of notificationHandlers) handler(message.method, params);
143
137
  if (typeof message.id === "number") {
144
138
  if (message.method !== "session/request_permission") {
145
- terminate(acpError("acp-unsupported-client-request", `Unsupported Grok ACP client request: ${message.method}`));
139
+ terminate(acpError("acp-unsupported-client-request", `Unsupported ACP client request: ${message.method}`));
146
140
  return;
147
141
  }
148
142
  const choices = Array.isArray(params.options) ? params.options : [];
149
143
  const selected = choices.find((value) =>
150
144
  typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === "allow_once") as { optionId?: unknown } | undefined;
151
145
  if (typeof selected?.optionId !== "string") {
152
- terminate(acpError("acp-permission-missing-allow-once", "Grok ACP permission request omitted allow_once"));
146
+ terminate(acpError("acp-permission-missing-allow-once", "ACP permission request omitted allow_once"));
153
147
  return;
154
148
  }
155
149
  child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { outcome: { outcome: "selected", optionId: selected.optionId } } })}\n`);
@@ -160,13 +154,13 @@ export function connectGrokAcpStdio(options: {
160
154
  const waiter = pending.get(message.id);
161
155
  if (waiter === undefined) return;
162
156
  pending.delete(message.id);
163
- if (message.error !== undefined) waiter.reject(acpError("acp-upstream-error", `Grok ACP error: ${JSON.stringify(message.error)}`));
157
+ if (message.error !== undefined) waiter.reject(acpError("acp-upstream-error", `ACP error: ${JSON.stringify(message.error)}`));
164
158
  else waiter.resolve((message.result ?? {}) as Readonly<Record<string, unknown>>);
165
159
  });
166
- child.on("close", (code) => settleClosed(acpError("acp-closed", `Grok ACP closed (${String(code)}): ${stderr}`)));
160
+ child.on("close", (code) => settleClosed(acpError("acp-closed", `ACP closed (${String(code)}): ${stderr}`)));
167
161
  return Promise.resolve({
168
162
  request(method, params) {
169
- if (closed) return Promise.reject(terminalError ?? acpError("acp-connection-closed", "Grok ACP connection is closed"));
163
+ if (closed) return Promise.reject(terminalError ?? acpError("acp-connection-closed", "ACP connection is closed"));
170
164
  const id = ++nextId;
171
165
  return new Promise((resolve, reject) => {
172
166
  pending.set(id, { resolve, reject });
@@ -175,12 +169,12 @@ export function connectGrokAcpStdio(options: {
175
169
  const waiter = pending.get(id);
176
170
  if (waiter === undefined) return;
177
171
  pending.delete(id);
178
- waiter.reject(acpError("acp-write-failed", `Grok ACP write failed: ${error.message}`, error));
172
+ waiter.reject(acpError("acp-write-failed", `ACP write failed: ${error.message}`, error));
179
173
  });
180
174
  });
181
175
  },
182
176
  notify(method, params) {
183
- if (closed) throw terminalError ?? acpError("acp-connection-closed", "Grok ACP connection is closed");
177
+ if (closed) throw terminalError ?? acpError("acp-connection-closed", "ACP connection is closed");
184
178
  child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
185
179
  },
186
180
  onNotification(handler) {
@@ -188,7 +182,7 @@ export function connectGrokAcpStdio(options: {
188
182
  },
189
183
  async close() {
190
184
  if (closed) return;
191
- settleClosed(acpError("acp-connection-closed", "Grok ACP connection is closed"));
185
+ settleClosed(acpError("acp-connection-closed", "ACP connection is closed"));
192
186
  child.stdin.end();
193
187
  child.kill("SIGTERM");
194
188
  await new Promise<void>((resolve) => child.once("close", () => resolve()));
@@ -197,23 +191,23 @@ export function connectGrokAcpStdio(options: {
197
191
  }
198
192
 
199
193
  /**
200
- * Main-session Grok adapter. The injected composition callbacks are the shared
194
+ * Main-session ACP adapter. The injected composition callbacks are the shared
201
195
  * envelope boundary: this module owns ACP lifecycle, never role policy.
202
196
  */
203
- export function createGrokRoleTurnHost(config: GrokRoleTurnHostConfig): RoleTurnHost {
197
+ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHost {
204
198
  let serial = Promise.resolve();
205
199
  return {
206
200
  executeTurn(request) {
207
201
  const execution = serial.then(async (): Promise<RoleTurnResult> => {
208
202
  const continuation = request.continuation;
209
203
  const prepared = await config.prepare(request);
210
- let connection: GrokAcpConnection | undefined;
204
+ let connection: AcpConnection | undefined;
211
205
  let sessionId: string | undefined;
212
206
  let accepted = false;
213
207
  try {
214
208
  // AK injection proof is prepared MCP composition (envelope).
215
209
  if (prepared.mcpServers.length === 0) {
216
- return failure("activation", "UncontrolledGrokSession", "ak-config-missing");
210
+ return failure("activation", "UncontrolledAcpSession", "ak-config-missing");
217
211
  }
218
212
  connection = await config.connect(request);
219
213
  const initialized = await connection.request("initialize", {
@@ -227,60 +221,47 @@ export function createGrokRoleTurnHost(config: GrokRoleTurnHostConfig): RoleTurn
227
221
  const availableModels = Array.isArray(modelState?.availableModels) ? modelState.availableModels : undefined;
228
222
  if (request.model !== undefined && availableModels !== undefined && !availableModels.some((entry) =>
229
223
  typeof entry === "object" && entry !== null && (entry as { modelId?: unknown }).modelId === request.model?.model)) {
230
- return failure("activation", "GrokHostModelMismatch", "host-model-mismatch", {
224
+ return failure("activation", "AcpHostModelMismatch", "host-model-mismatch", {
231
225
  provider: request.model.provider,
232
226
  model: request.model.model,
233
227
  });
234
228
  }
235
229
 
236
- // #617 DK-7: hand Pi session path once; Grok reads the file itself.
230
+ // #617 DK-7 / #732: hand the projected prior-native session paths once,
231
+ // whichever record family they came from; the host reads the files itself.
237
232
  const priorNativePaths =
238
233
  continuation.kind === "resume"
239
- && request.hostTransition?.previousHost === "pi"
240
- ? request.hostTransition.priorNativePaths
234
+ ? request.hostTransition?.priorNativePaths
241
235
  : undefined;
242
- if (continuation.kind === "resume") {
236
+ if (continuation.kind === "resume" && config.boundResume === "session/load") {
237
+ // Same-host resume reuses the native ACP session via session/load.
243
238
  const boundSessionId = await config.sessionIdentity.load(request.principal);
244
239
  if (boundSessionId !== undefined && boundSessionId !== "") {
245
- // Same-host Grok resume reuses native ACP session via session/load.
246
240
  const loaded = await connection.request("session/load", {
247
241
  sessionId: boundSessionId,
248
242
  cwd: request.cwd,
249
243
  mcpServers: prepared.mcpServers,
250
- _meta: { systemPromptOverride: renderGrokSystemPromptOverride(prepared.systemPrompt), yoloMode: false },
244
+ _meta: { systemPromptOverride: renderAcpSystemPromptOverride(prepared.systemPrompt), yoloMode: false },
251
245
  });
252
246
  sessionId = typeof loaded.sessionId === "string" && loaded.sessionId !== ""
253
247
  ? loaded.sessionId
254
248
  : boundSessionId;
255
- } else {
256
- // Unbound resume (cross-host or lost binding): session/new + bind.
257
- const session = await connection.request(
258
- "session/new",
259
- {
260
- cwd: request.cwd,
261
- mcpServers: prepared.mcpServers,
262
- _meta: { systemPromptOverride: renderGrokSystemPromptOverride(prepared.systemPrompt), yoloMode: false },
263
- },
264
- );
265
- sessionId = typeof session.sessionId === "string" ? session.sessionId : undefined;
266
- if (sessionId === undefined || sessionId === "") {
267
- return failure("session", "GrokAcpSessionFailure", "session-id-missing");
268
- }
269
- await config.sessionIdentity.bind(request.principal, sessionId);
270
249
  }
271
- } else {
272
- // Initial run: session/new + bind.
250
+ }
251
+ if (sessionId === undefined) {
252
+ // Initial run, unbound resume (cross-host / lost binding), or a host
253
+ // whose bound resume is session/new: mint the session and bind it.
273
254
  const session = await connection.request(
274
255
  "session/new",
275
256
  {
276
257
  cwd: request.cwd,
277
258
  mcpServers: prepared.mcpServers,
278
- _meta: { systemPromptOverride: renderGrokSystemPromptOverride(prepared.systemPrompt), yoloMode: false },
259
+ _meta: { systemPromptOverride: renderAcpSystemPromptOverride(prepared.systemPrompt), yoloMode: false },
279
260
  },
280
261
  );
281
262
  sessionId = typeof session.sessionId === "string" ? session.sessionId : undefined;
282
263
  if (sessionId === undefined || sessionId === "") {
283
- return failure("session", "GrokAcpSessionFailure", "session-id-missing");
264
+ return failure("session", "AcpSessionFailure", "session-id-missing");
284
265
  }
285
266
  await config.sessionIdentity.bind(request.principal, sessionId);
286
267
  }
@@ -353,7 +334,7 @@ export function createGrokRoleTurnHost(config: GrokRoleTurnHostConfig): RoleTurn
353
334
  throw error;
354
335
  }
355
336
  if (result.stopReason === "refusal") {
356
- return failure("output", "GrokAcpRefusal", "refusal", { sessionId });
337
+ return failure("output", "AcpRefusal", "refusal", { sessionId });
357
338
  }
358
339
  // session/prompt resolution is the sole typed round boundary before seal
359
340
  // when the turn ends without host abort; abort path closes above.
@@ -370,7 +351,7 @@ export function createGrokRoleTurnHost(config: GrokRoleTurnHostConfig): RoleTurn
370
351
  }
371
352
  prompt = `The prior terminal submission was rejected (${closure.retry.code}). Resubmit it as the sole terminal tool call. Rejected call ids: ${closure.retry.toolCallIds.join(", ") || "none"}.`;
372
353
  }
373
- return failure("output", "GrokAcpRoundLimit", "round-retry-limit", { sessionId });
354
+ return failure("output", "AcpRoundLimit", "round-retry-limit", { sessionId });
374
355
  } finally {
375
356
  if (connection !== undefined) {
376
357
  if (sessionId !== undefined && !accepted) {
@@ -389,19 +370,3 @@ export function createGrokRoleTurnHost(config: GrokRoleTurnHostConfig): RoleTurn
389
370
  },
390
371
  };
391
372
  }
392
-
393
- const PRIVATE_COMPAT_ENV = Object.fromEntries(
394
- ["CLAUDE", "CURSOR", "CODEX"].flatMap((vendor) =>
395
- ["SKILLS", "RULES", "AGENTS", "MCPS", "HOOKS", "SESSIONS"].map((kind) =>
396
- [`GROK_${vendor}_${kind}_ENABLED`, "false"] as const)),
397
- );
398
-
399
- /** Child environment shared by ACP agent processes. */
400
- export function controlledGrokChildEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
401
- return {
402
- ...base,
403
- ...PRIVATE_COMPAT_ENV,
404
- GROK_MEMORY: "0",
405
- GROK_SUBAGENTS: "0",
406
- };
407
- }
@@ -2,15 +2,15 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
4
  import type { DurablePrincipal, DurablePrincipalAuthority } from "../host-contracts.ts";
5
- import type { GrokSessionIdentityAuthority } from "./role-turn-host.ts";
6
-
7
- /** ACP binding filename written only by the grok host on initial bind. */
8
- export const GROK_ACP_SESSION_BINDING = "grok-acp-session.json";
5
+ import type { AcpSessionIdentityAuthority } from "./role-turn-host.ts";
9
6
 
10
7
  /** Durable ACP binding stored beside the host-owned session principal. */
11
- export function createGrokSessionIdentityAuthority(authority: DurablePrincipalAuthority): GrokSessionIdentityAuthority {
8
+ export function createAcpSessionIdentityAuthority(
9
+ authority: DurablePrincipalAuthority,
10
+ sessionBindingFile: string,
11
+ ): AcpSessionIdentityAuthority {
12
12
  const bindingPath = (principal: DurablePrincipal): string =>
13
- join(authority.decode(principal).sessionDirectory, GROK_ACP_SESSION_BINDING);
13
+ join(authority.decode(principal).sessionDirectory, sessionBindingFile);
14
14
  return {
15
15
  resolveSessionFile(principal) {
16
16
  return authority.decode(principal).sessionFile;
@@ -19,7 +19,7 @@ export function createGrokSessionIdentityAuthority(authority: DurablePrincipalAu
19
19
  try {
20
20
  const value: unknown = JSON.parse(await readFile(bindingPath(principal), "utf8"));
21
21
  if (typeof value !== "object" || value === null || typeof (value as { sessionId?: unknown }).sessionId !== "string") {
22
- throw new Error("durable Grok ACP session binding is invalid");
22
+ throw new Error("durable ACP session binding is invalid");
23
23
  }
24
24
  return (value as { sessionId: string }).sessionId;
25
25
  } catch (error) {
@@ -140,12 +140,14 @@ export type RoleTurnModelConfig = {
140
140
 
141
141
  /**
142
142
  * Typed cross-host resume handoff (#617 DK-4).
143
- * Present only when post-admission projects a real switch between known hosts.
144
- * Cross-host prior volume: previous native record paths for the live host (DK-7).
143
+ * Present only when post-admission projects a real host switch.
144
+ * priorNativeKind names the record family the paths belong to — Pi's own
145
+ * session file, or sitian run records (ADR 0077) — so a consuming adapter
146
+ * reads the handoff without knowing which host wrote it.
145
147
  * Target host reads those files itself; projector never copies bytes.
146
148
  */
147
149
  export type RoleTurnHostTransition = {
148
- readonly previousHost: "pi" | "grok-build";
150
+ readonly priorNativeKind: "pi-native" | "sitian";
149
151
  readonly priorNativePaths: readonly string[];
150
152
  };
151
153
 
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Packaged host description table (#729 / #731).
3
+ * Key = seat-table `host` value; the row is the generic ACP factory's input.
4
+ * pi is the in-process default, not a row.
5
+ * Unregistered names fail closed (#510); this table does not fallback.
6
+ */
7
+ import type { AcpHostDescription } from "./acp-host/description.ts";
8
+
9
+ /** Grok CLI reads vendor-private compat surfaces unless each is disabled by name. */
10
+ const PRIVATE_COMPAT_ENV = Object.fromEntries(
11
+ ["CLAUDE", "CURSOR", "CODEX"].flatMap((vendor) =>
12
+ ["SKILLS", "RULES", "AGENTS", "MCPS", "HOOKS", "SESSIONS"].map((kind) =>
13
+ [`GROK_${vendor}_${kind}_ENABLED`, "false"] as const)),
14
+ );
15
+
16
+ export const DEFAULT_ROLE_TURN_HOST = "pi" as const;
17
+
18
+ export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = Object.freeze({
19
+ /** Operator home `~/.grok`, native session/load resume, `agent [--model X] stdio`. */
20
+ "grok-build": Object.freeze({
21
+ binaryFromHome: Object.freeze([".grok", "bin", "grok"]),
22
+ argv: Object.freeze({
23
+ prefix: Object.freeze(["agent"]),
24
+ suffix: Object.freeze(["stdio"]),
25
+ modelFlag: "--model",
26
+ }),
27
+ boundResume: "session/load",
28
+ sessionBindingFile: "grok-acp-session.json",
29
+ childEnv: Object.freeze({
30
+ ...PRIVATE_COMPAT_ENV,
31
+ GROK_MEMORY: "0",
32
+ GROK_SUBAGENTS: "0",
33
+ }),
34
+ }),
35
+ });
36
+
37
+ export function lookupHostDescription(host: string): AcpHostDescription | undefined {
38
+ return Object.hasOwn(HOST_DESCRIPTIONS, host) ? HOST_DESCRIPTIONS[host] : undefined;
39
+ }
40
+
41
+ export function packagedExternalHostNames(): readonly string[] {
42
+ return Object.keys(HOST_DESCRIPTIONS);
43
+ }
44
+
45
+ /** Legal persistent/invocation host: default pi, or a table key. */
46
+ export function assertRegisteredHostName(host: string): string {
47
+ if (host === DEFAULT_ROLE_TURN_HOST || lookupHostDescription(host) !== undefined) {
48
+ return host;
49
+ }
50
+ throw new Error(`unregistered host: ${host}`);
51
+ }
@@ -1,19 +1,13 @@
1
1
  /**
2
2
  * Single authority for #617 DK-4 cross-host prior-native projection.
3
- * Closed host discriminators only; unknown previous/live hosts never inject.
3
+ * Classifies the prior volume into the two record families that exist
4
+ * (Pi native session file / sitian run records), never by host name.
4
5
  */
5
6
  import { access, readdir } from "node:fs/promises";
6
7
  import { dirname, join } from "node:path";
7
8
 
8
9
  import type { RoleTurnHostTransition } from "./host-contracts.ts";
9
10
 
10
- const KNOWN_ROLE_TURN_HOSTS = ["pi", "grok-build"] as const;
11
- type KnownRoleTurnHost = (typeof KNOWN_ROLE_TURN_HOSTS)[number];
12
-
13
- function isKnownRoleTurnHost(value: string): value is KnownRoleTurnHost {
14
- return (KNOWN_ROLE_TURN_HOSTS as readonly string[]).includes(value);
15
- }
16
-
17
11
  function isEnoent(error: unknown): boolean {
18
12
  return typeof error === "object" && error !== null && (error as NodeJS.ErrnoException).code === "ENOENT";
19
13
  }
@@ -59,13 +53,12 @@ async function listSitianRecordPaths(sessionParent: string): Promise<string[]> {
59
53
  }
60
54
 
61
55
  /**
62
- * Project one hostTransition only for a real switch between known hosts.
63
- * Unknown host names → undefined (no inject). Empty native volume still
64
- * yields a typed switch (empty path list).
56
+ * Project one hostTransition only for a real host switch. Empty native volume
57
+ * still yields a typed switch (empty path list).
65
58
  *
66
- * Pi previous → Pi session.jsonl path.
67
- * Grok previous → sitian records on the live run (#717). Grok CLI journals
68
- * stay in the operator grok home; they are not copied here.
59
+ * Pi wrote its own session.jsonl; every other host's run volume is the sitian
60
+ * record set on the live run (ADR 0077 `record-scope-phase-two`, #717) — the CLI's
61
+ * own journals stay in the operator home and are never copied here.
69
62
  */
70
63
  export async function projectHostTransitionPriorNative(input: {
71
64
  readonly previousHost: string;
@@ -73,20 +66,15 @@ export async function projectHostTransitionPriorNative(input: {
73
66
  readonly piSessionFile: string;
74
67
  }): Promise<RoleTurnHostTransition | undefined> {
75
68
  if (input.previousHost === input.liveHost) return undefined;
76
- if (!isKnownRoleTurnHost(input.previousHost) || !isKnownRoleTurnHost(input.liveHost)) {
77
- return undefined;
78
- }
79
69
  if (input.previousHost === "pi") {
80
- const paths = await listPiNativeRecordPaths(input.piSessionFile);
81
70
  return {
82
- previousHost: "pi",
83
- priorNativePaths: paths,
71
+ priorNativeKind: "pi-native",
72
+ priorNativePaths: await listPiNativeRecordPaths(input.piSessionFile),
84
73
  };
85
74
  }
86
- // previousHost === "grok-build": sitian path handoff only — do not read bytes.
87
- const paths = await listSitianRecordPaths(input.piSessionFile);
75
+ // Sitian path handoff only — do not read bytes.
88
76
  return {
89
- previousHost: "grok-build",
90
- priorNativePaths: paths,
77
+ priorNativeKind: "sitian",
78
+ priorNativePaths: await listSitianRecordPaths(input.piSessionFile),
91
79
  };
92
80
  }
@@ -406,9 +406,10 @@ export function createPiRoleTurnHost(config: PiRoleTurnHostConfig): RoleTurnHost
406
406
  return {
407
407
  async executeTurn(request: RoleTurnRequest): Promise<RoleTurnResult> {
408
408
  // #617 DK-7: Pi argv gets projected native paths once; never record bytes.
409
+ // Pi already owns its own session file, so only sitian prior volume rides in.
409
410
  let turnRequest = request;
410
411
  const paths =
411
- request.hostTransition?.previousHost === "grok-build"
412
+ request.hostTransition?.priorNativeKind === "sitian"
412
413
  ? request.hostTransition.priorNativePaths
413
414
  : undefined;
414
415
  if (
@@ -38,8 +38,9 @@ import { seatModelOnly } from "./registry.ts";
38
38
  import { CliUsageError } from "./cli-errors.ts";
39
39
  import type { CliIo } from "./cli-io.ts";
40
40
  import type { PostAdmissionEnv } from "./post-admission.ts";
41
- import type { RoleTurnHost } from "../host-contracts.ts";
42
- import { loadProductionGrokHostFactory } from "./load-production-grok-host.ts";
41
+ import type { RoleTurnHost, RoleTurnRequest } from "../host-contracts.ts";
42
+ import { packagedExternalHostNames } from "../host-descriptions.ts";
43
+ import { loadProductionAcpHostFactory } from "./load-production-acp-host.ts";
43
44
  import {
44
45
  createPiRoleTurnHost,
45
46
  appendPiSessionCustomEntry,
@@ -321,19 +322,19 @@ function resolveRoleTurnHost(
321
322
  recordLaunchedRolePackageIdentity,
322
323
  observeLaunchedRolePackageIdentity,
323
324
  });
324
- // Composition-root unique adapter table (#522 / #580): pi + S6 grok-build true adapter.
325
- const adapters = env.hostAdapters ?? [
325
+ // Composition-root adapter table: pi (in-process default) + one ACP adapter per description-table key.
326
+ const adapters: readonly NamedRoleTurnHostAdapter[] = env.hostAdapters ?? [
326
327
  { name: "pi", create: () => ({ ok: true as const, host: piHost }) },
327
- {
328
- name: "grok-build",
328
+ ...packagedExternalHostNames().map((name) => ({
329
+ name,
329
330
  create: () => {
330
331
  // Factory loads outside the public bin static graph (ADR 0052 peer-free discovery).
331
332
  let hostPromise: Promise<RoleTurnHost> | undefined;
332
333
  return {
333
334
  ok: true as const,
334
335
  host: {
335
- executeTurn: async (request) => {
336
- hostPromise ??= loadProductionGrokHostFactory(env.packageRoot).then((create) =>
336
+ executeTurn: async (request: RoleTurnRequest) => {
337
+ hostPromise ??= loadProductionAcpHostFactory(env.packageRoot, name).then((create) =>
337
338
  create({
338
339
  packageRoot: env.packageRoot,
339
340
  principalAuthority: options.principalAuthority,
@@ -344,7 +345,7 @@ function resolveRoleTurnHost(
344
345
  },
345
346
  };
346
347
  },
347
- },
348
+ })),
348
349
  ];
349
350
  const hostName = options.seat.host;
350
351
  const adapter = adapters.find((candidate) => candidate.name === hostName);
@@ -4,6 +4,7 @@
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
+ import { assertRegisteredHostName, DEFAULT_ROLE_TURN_HOST } from "../host-descriptions.ts";
7
8
  import { assertLegalEngineName } from "../package-resources/engine-material.ts";
8
9
  import { resolveConfiguredProvinceOfficer } from "../institutional-resolution.ts";
9
10
  import {
@@ -218,7 +219,8 @@ export function setPersistentSeatHost(
218
219
  }
219
220
  return { ...config, seats: { ...config.seats, [seat]: rest } };
220
221
  }
221
- return { ...config, seats: { ...config.seats, [seat]: { ...previous, host } } };
222
+ const registered = assertRegisteredHostName(host);
223
+ return { ...config, seats: { ...config.seats, [seat]: { ...previous, host: registered } } };
222
224
  }
223
225
 
224
226
  /**
@@ -295,7 +297,7 @@ export function setAutoResumeLimit(
295
297
  * Config-parse seam: persistent call axes belong to PUBLIC_CALLABLE_ROLES;
296
298
  * engine names need only path-safety syntax (no closed material catalog;
297
299
  * #376 / #378 / #391 / ADR 0069). Syntax authority = assertLegalEngineName
298
- * (no injected duplicate).
300
+ * (no injected duplicate). Persistent host must be pi or a description-table key (#510 / #731).
299
301
  */
300
302
  export function validatePublicCliConfigAxes(
301
303
  config: PublicCliConfig,
@@ -303,14 +305,25 @@ export function validatePublicCliConfigAxes(
303
305
  ): void {
304
306
  for (const seat of Object.keys(config.seats) as PublicConfigurableSeat[]) {
305
307
  const row = config.seats[seat];
306
- if (row?.engine === undefined) continue;
307
- try {
308
- assertLegalEngineName(row.engine);
309
- } catch (error) {
310
- throw new Error(
311
- `config seat ${seat} engine is illegal: ${row.engine}`,
312
- { cause: error },
313
- );
308
+ if (row?.engine !== undefined) {
309
+ try {
310
+ assertLegalEngineName(row.engine);
311
+ } catch (error) {
312
+ throw new Error(
313
+ `config seat ${seat} engine is illegal: ${row.engine}`,
314
+ { cause: error },
315
+ );
316
+ }
317
+ }
318
+ if (row?.host !== undefined) {
319
+ try {
320
+ assertRegisteredHostName(row.host);
321
+ } catch (error) {
322
+ throw new Error(
323
+ `config seat ${seat} host is unregistered: ${row.host}`,
324
+ { cause: error },
325
+ );
326
+ }
314
327
  }
315
328
  }
316
329
  }
@@ -550,7 +563,7 @@ function attachHostAxis(
550
563
  if (invocation?.host !== undefined) return { ...seat, host: invocation.host, hostSource: "invocation" };
551
564
  const persistent = config.seats[seat.seat]?.host;
552
565
  if (persistent !== undefined) return { ...seat, host: persistent, hostSource: "persistent" };
553
- return { ...seat, host: "pi", hostSource: "default" };
566
+ return { ...seat, host: DEFAULT_ROLE_TURN_HOST, hostSource: "default" };
554
567
  }
555
568
 
556
569
  function attachEngineAxis(
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Deferred loader for the packaged generic ACP RoleTurnHost factory.
3
+ *
4
+ * Public ak-role bin must not statically value-import the production host (or its
5
+ * role-runtime / pi-coding-agent edges). Specifier is runtime-constructed so
6
+ * esbuild leaves this import external (ADR 0052 discovery stays peer-free).
7
+ *
8
+ * Host selection is a description-table lookup: the row for the seat's host key
9
+ * is the factory input. There is no host-name fork here.
10
+ */
11
+ import { existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+
15
+ import type { AcpHostDescription } from "../acp-host/description.ts";
16
+ import { lookupHostDescription } from "../host-descriptions.ts";
17
+ import type { DurablePrincipalAuthority, RoleTurnHost } from "../host-contracts.ts";
18
+
19
+ export type ProductionAcpHostFactory = (options: {
20
+ packageRoot: string;
21
+ principalAuthority: DurablePrincipalAuthority;
22
+ }) => RoleTurnHost;
23
+
24
+ type GenericAcpHostFactory = (options: {
25
+ packageRoot: string;
26
+ principalAuthority: DurablePrincipalAuthority;
27
+ description: AcpHostDescription;
28
+ }) => RoleTurnHost;
29
+
30
+ /**
31
+ * Resolve the production ACP host factory for one registered host key without a
32
+ * static graph edge into the public CLI bundle.
33
+ */
34
+ export async function loadProductionAcpHostFactory(
35
+ packageRoot: string,
36
+ host: string,
37
+ ): Promise<ProductionAcpHostFactory> {
38
+ const description = lookupHostDescription(host);
39
+ if (description === undefined) {
40
+ throw new Error(`unregistered host: ${host}`);
41
+ }
42
+ const built = join(packageRoot, "dist/acp-host/production-host.js");
43
+ const source = join(packageRoot, "src/acp-host/production-host.ts");
44
+ // Prefer the built artifact (plain node); fall back to source under tsx tests.
45
+ const target = existsSync(built) ? built : source;
46
+ const href = pathToFileURL(target).href;
47
+ const mod = (await import(href)) as {
48
+ createProductionAcpRoleTurnHost: GenericAcpHostFactory;
49
+ };
50
+ const create = mod.createProductionAcpRoleTurnHost;
51
+ return (options) => create({ ...options, description });
52
+ }