@akagilnc/pi-workflow-roles 0.1.3999 → 0.1.4021

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.
@@ -46,15 +46,19 @@ export const HOST_DESCRIPTIONS = Object.freeze({
46
46
  }),
47
47
  });
48
48
  /**
49
- * Headless CLI family (#645). Claude is the first row; codex (#646) adds another.
50
- * fixedArgs: print mode, isolation without `--bare` (OAuth stays), full permissions.
51
- * stream-json + verbose: live host events for sitian records (#811); result is last line.
52
- * `--setting-sources` empty = load no user/project/local CLAUDE.md/hooks/skills
53
- * (role envelope is delivered via `--system-prompt` wholesale replace).
54
- * `--strict-mcp-config` with no `--mcp-config` drops operator MCP + claude.ai connectors.
49
+ * Headless CLI family (#645 / #646). Claude print-mode is the first row;
50
+ * codex exec (#646) adds another. Protocol-specific argv/parse live in
51
+ * headless-host helpers (#752 per-host impl).
52
+ * Claude fixedArgs: print mode, isolation without `--bare` (OAuth stays), full
53
+ * permissions. stream-json + verbose: live host events for sitian records
54
+ * (#811); result is last line. `--setting-sources` empty = load no
55
+ * user/project/local CLAUDE.md/hooks/skills (role envelope is delivered via
56
+ * `--system-prompt` wholesale replace). `--strict-mcp-config` with no
57
+ * `--mcp-config` drops operator MCP + claude.ai connectors.
55
58
  */
56
59
  export const HEADLESS_HOST_DESCRIPTIONS = Object.freeze({
57
60
  "claude": Object.freeze({
61
+ protocol: "claude-print",
58
62
  binaryFromHome: Object.freeze([".local", "bin", "claude"]),
59
63
  sessionBindingFile: "claude-headless-session.json",
60
64
  fixedArgs: Object.freeze([
@@ -78,6 +82,15 @@ export const HEADLESS_HOST_DESCRIPTIONS = Object.freeze({
78
82
  sessionIdFlag: "--session-id",
79
83
  resumeFlag: "--resume",
80
84
  }),
85
+ /**
86
+ * Codex headless (#646). Binary under operator home; auth stays in CODEX_HOME.
87
+ * Argv/parse/schema-close are codex-exec helpers — not Claude flag mapping.
88
+ */
89
+ "codex": Object.freeze({
90
+ protocol: "codex-exec",
91
+ binaryFromHome: Object.freeze([".local", "bin", "codex"]),
92
+ sessionBindingFile: "codex-headless-session.json",
93
+ }),
81
94
  });
82
95
  export function lookupHostDescription(host) {
83
96
  return Object.hasOwn(HOST_DESCRIPTIONS, host) ? HOST_DESCRIPTIONS[host] : undefined;
@@ -15162,6 +15162,7 @@ var init_host_descriptions = __esm({
15162
15162
  });
15163
15163
  HEADLESS_HOST_DESCRIPTIONS = Object.freeze({
15164
15164
  "claude": Object.freeze({
15165
+ protocol: "claude-print",
15165
15166
  binaryFromHome: Object.freeze([".local", "bin", "claude"]),
15166
15167
  sessionBindingFile: "claude-headless-session.json",
15167
15168
  fixedArgs: Object.freeze([
@@ -15187,6 +15188,15 @@ var init_host_descriptions = __esm({
15187
15188
  mcpConfigFlag: "--mcp-config",
15188
15189
  sessionIdFlag: "--session-id",
15189
15190
  resumeFlag: "--resume"
15191
+ }),
15192
+ /**
15193
+ * Codex headless (#646). Binary under operator home; auth stays in CODEX_HOME.
15194
+ * Argv/parse/schema-close are codex-exec helpers — not Claude flag mapping.
15195
+ */
15196
+ "codex": Object.freeze({
15197
+ protocol: "codex-exec",
15198
+ binaryFromHome: Object.freeze([".local", "bin", "codex"]),
15199
+ sessionBindingFile: "codex-headless-session.json"
15190
15200
  })
15191
15201
  });
15192
15202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.3999",
3
+ "version": "0.1.4021",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,16 +1,21 @@
1
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.
2
+ * Headless CLI host descriptions (#645 / #646 / #752).
3
+ * Shared lifecycle owns spawn/bind/close; each protocol owns argv + parse shape.
4
+ * Claude print-mode and codex exec differ enough that #752 host-specific
5
+ * assembly lives here as sibling helpers — not a third unified abstraction.
6
6
  */
7
7
  import { join } from "node:path";
8
8
 
9
- export type HeadlessHostDescription = Readonly<{
9
+ type HeadlessHostBase = Readonly<{
10
10
  /** Binary path segments relative to the operator home. */
11
11
  binaryFromHome: readonly string[];
12
12
  /** Durable session-id binding filename beside the session principal. */
13
13
  sessionBindingFile: string;
14
+ }>;
15
+
16
+ /** Claude Code print-mode (#645). */
17
+ export type ClaudePrintHostDescription = HeadlessHostBase & Readonly<{
18
+ protocol: "claude-print";
14
19
  /**
15
20
  * Host-native print-mode flags that never change per turn (no prompt).
16
21
  * Model / effort / system-prompt / schema / session / resume / mcp-config
@@ -38,6 +43,29 @@ export type HeadlessHostDescription = Readonly<{
38
43
  resumeFlag: string;
39
44
  }>;
40
45
 
46
+ /**
47
+ * Codex `exec` / `exec resume` (#646).
48
+ * Protocol differences (JSONL, resume subcommand, schema file, `-c` MCP) stay
49
+ * in codex-specific helpers — description only carries identity + binary path.
50
+ */
51
+ export type CodexExecHostDescription = HeadlessHostBase & Readonly<{
52
+ protocol: "codex-exec";
53
+ }>;
54
+
55
+ export type HeadlessHostDescription = ClaudePrintHostDescription | CodexExecHostDescription;
56
+
57
+ export function isClaudePrintDescription(
58
+ description: HeadlessHostDescription,
59
+ ): description is ClaudePrintHostDescription {
60
+ return description.protocol === "claude-print";
61
+ }
62
+
63
+ export function isCodexExecDescription(
64
+ description: HeadlessHostDescription,
65
+ ): description is CodexExecHostDescription {
66
+ return description.protocol === "codex-exec";
67
+ }
68
+
41
69
  /** Absolute agent binary for one operator home. */
42
70
  export function resolveHeadlessBinary(
43
71
  description: HeadlessHostDescription,
@@ -47,11 +75,11 @@ export function resolveHeadlessBinary(
47
75
  }
48
76
 
49
77
  /**
50
- * Build one headless CLI argv for a single process turn.
78
+ * Build one Claude print-mode argv for a single process turn.
51
79
  * Shape: `<promptFlag> <prompt> <fixedArgs…> <system/schema/mcp/model/effort/session…>`.
52
80
  */
53
81
  export function headlessTurnArgs(options: {
54
- readonly description: HeadlessHostDescription;
82
+ readonly description: ClaudePrintHostDescription;
55
83
  readonly prompt: string;
56
84
  /** Absolute path written by the adapter; paired with `systemPromptFlag`. */
57
85
  readonly systemPromptPath: string;
@@ -90,6 +118,350 @@ export function headlessTurnArgs(options: {
90
118
  return args;
91
119
  }
92
120
 
121
+ /**
122
+ * TOML string literal for `codex -c key=<value>` (values are TOML-parsed).
123
+ * Double-quoted form; escapes backslash and quote only.
124
+ */
125
+ function codexTomlString(value: string): string {
126
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
127
+ }
128
+
129
+ /** TOML array of strings for `-c key=["a","b"]`. */
130
+ function codexTomlStringArray(values: readonly string[]): string {
131
+ return `[${values.map(codexTomlString).join(",")}]`;
132
+ }
133
+
134
+ /** TOML inline table of string→string for `-c key={a="b"}`. */
135
+ function codexTomlStringTable(entries: Readonly<Record<string, string>>): string {
136
+ const parts = Object.entries(entries).map(
137
+ ([key, value]) => `${key}=${codexTomlString(value)}`,
138
+ );
139
+ return `{${parts.join(",")}}`;
140
+ }
141
+
142
+ /**
143
+ * Free-form JSON leaf for Type.Unknown under Codex strict transport.
144
+ * Strict rejects bare untyped nodes and root-level additionalProperties:true;
145
+ * a $defs anyOf of JSON values (with nested additionalProperties as $ref)
146
+ * is accepted and keeps array/object receipts expressible (navigator candidates).
147
+ * Package code still does not validate or reject the receipt against schema.
148
+ */
149
+ const CODEX_JSON_VALUE_DEF = "codexJsonValue";
150
+ const CODEX_JSON_VALUE_REF = `#/$defs/${CODEX_JSON_VALUE_DEF}`;
151
+ const CODEX_JSON_VALUE_SCHEMA = Object.freeze({
152
+ anyOf: Object.freeze([
153
+ Object.freeze({ type: "string" }),
154
+ Object.freeze({ type: "number" }),
155
+ Object.freeze({ type: "boolean" }),
156
+ Object.freeze({ type: "null" }),
157
+ Object.freeze({ type: "array", items: Object.freeze({ $ref: CODEX_JSON_VALUE_REF }) }),
158
+ Object.freeze({
159
+ type: "object",
160
+ properties: Object.freeze({}),
161
+ required: Object.freeze([] as string[]),
162
+ additionalProperties: Object.freeze({ $ref: CODEX_JSON_VALUE_REF }),
163
+ }),
164
+ ]),
165
+ });
166
+
167
+ /**
168
+ * Derive a Codex/OpenAI-strict transport schema from the package open schema.
169
+ * Legal open schema is untouched; this is a host-only transmission projection
170
+ * (#646 / 0057 法意 / 0054 strict): every object closes, every property is
171
+ * required, and only originally-optional fields become a null union (official
172
+ * guidance: emulate optional via type|null). Originally-required fields stay
173
+ * non-nullable. Nested open-tool anyOf wrappers are flattened so every branch
174
+ * carries `type`. Type.Unknown / description-only leaves become a free JSON
175
+ * $ref. Package code still does not validate or reject the receipt.
176
+ */
177
+ export function closeJsonSchemaForCodex(
178
+ schema: Readonly<Record<string, unknown>>,
179
+ ): Record<string, unknown> {
180
+ const closed = closeSchemaNode(schema) as Record<string, unknown>;
181
+ const existingDefs = isPlainObject(closed.$defs)
182
+ ? (closed.$defs as Record<string, unknown>)
183
+ : {};
184
+ return {
185
+ ...closed,
186
+ $defs: {
187
+ ...existingDefs,
188
+ [CODEX_JSON_VALUE_DEF]: CODEX_JSON_VALUE_SCHEMA,
189
+ },
190
+ };
191
+ }
192
+
193
+ /** Shared plain-object guard for headless host schema/event reduction. */
194
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
195
+ return typeof value === "object" && value !== null && !Array.isArray(value);
196
+ }
197
+
198
+ /** Pure null leaf only — composite type|null is stripped in-leaf, not dropped whole. */
199
+ function isPureNullTypeSchema(value: unknown): boolean {
200
+ if (!isPlainObject(value)) return false;
201
+ if (value.type === "null") return true;
202
+ if (Array.isArray(value.type) && value.type.length > 0 && value.type.every((t) => t === "null")) {
203
+ return true;
204
+ }
205
+ return false;
206
+ }
207
+
208
+ /**
209
+ * Within a leaf, remove null members from type arrays.
210
+ * Required edges keep the non-null type(s); optional edges re-add null once.
211
+ */
212
+ function stripNullFromLeafType(leaf: unknown): unknown {
213
+ if (!isPlainObject(leaf) || !Array.isArray(leaf.type)) return leaf;
214
+ const nonNull = leaf.type.filter((t) => t !== "null");
215
+ if (nonNull.length === leaf.type.length) return leaf;
216
+ if (nonNull.length === 0) return { ...leaf, type: "null" };
217
+ if (nonNull.length === 1) return { ...leaf, type: nonNull[0] };
218
+ return { ...leaf, type: nonNull };
219
+ }
220
+
221
+ /**
222
+ * Flatten nested anyOf wrappers into concrete leaf schemas.
223
+ * Open-tool unions often wrap leaves in description-only anyOf shells that
224
+ * lack `type`; strict structured output rejects those intermediate nodes.
225
+ */
226
+ function flattenUnionLeaves(schema: unknown): unknown[] {
227
+ if (!isPlainObject(schema)) return [schema];
228
+ if (Array.isArray(schema.anyOf)) {
229
+ return schema.anyOf.flatMap(flattenUnionLeaves);
230
+ }
231
+ return [schema];
232
+ }
233
+
234
+ /** Drop pure-null leaves after in-leaf null stripping; optional edges re-add null once. */
235
+ function nonNullLeaves(schema: unknown): unknown[] {
236
+ return flattenUnionLeaves(schema)
237
+ .map(stripNullFromLeafType)
238
+ .filter((leaf) => !isPureNullTypeSchema(leaf));
239
+ }
240
+
241
+ /**
242
+ * Property edge under strict transport.
243
+ * Required → closed non-null leaf(s). Optional → closed leaf(s) + null.
244
+ */
245
+ function closePropertySchema(schema: unknown, optional: boolean): unknown {
246
+ const leaves = nonNullLeaves(schema).map(closeSchemaNode);
247
+ if (leaves.length === 0) return { type: "null" };
248
+ if (!optional) {
249
+ return leaves.length === 1 ? leaves[0] : { anyOf: leaves };
250
+ }
251
+ return { anyOf: [...leaves, { type: "null" }] };
252
+ }
253
+
254
+ /**
255
+ * Strict generators require every schema node to declare `type` (or a $ref).
256
+ * Type.Unknown / description-only leaves → free JSON $ref (not string): array
257
+ * and object receipts stay expressible under --output-schema.
258
+ */
259
+ function ensureTypedLeaf(schema: Record<string, unknown>): Record<string, unknown> {
260
+ if (schema.type !== undefined) return schema;
261
+ if (typeof schema.$ref === "string") return schema;
262
+ if (isPlainObject(schema.properties) || schema.additionalProperties !== undefined) {
263
+ return { ...schema, type: "object" };
264
+ }
265
+ if (schema.items !== undefined) {
266
+ return { ...schema, type: "array" };
267
+ }
268
+ if (schema.const !== undefined) {
269
+ const value = schema.const;
270
+ if (value === null) return { ...schema, type: "null" };
271
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
272
+ return { ...schema, type: typeof value };
273
+ }
274
+ }
275
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
276
+ const sample = schema.enum.find((item) => item !== null);
277
+ if (typeof sample === "string" || typeof sample === "number" || typeof sample === "boolean") {
278
+ return { ...schema, type: typeof sample };
279
+ }
280
+ }
281
+ // Free JSON via $defs. $ref must stand alone (strict rejects sibling keys).
282
+ return { $ref: CODEX_JSON_VALUE_REF };
283
+ }
284
+
285
+ function originalRequiredNames(node: Record<string, unknown>): ReadonlySet<string> {
286
+ if (!Array.isArray(node.required)) return new Set();
287
+ return new Set(node.required.filter((item): item is string => typeof item === "string"));
288
+ }
289
+
290
+ function closeSchemaNode(node: unknown): unknown {
291
+ if (!isPlainObject(node)) return node;
292
+
293
+ // Already a ref (free-JSON leaf or pre-existing) — do not retype.
294
+ if (typeof node.$ref === "string") return node;
295
+
296
+ // Union node: flatten then close each concrete leaf (do not keep untyped shells).
297
+ if (Array.isArray(node.anyOf)) {
298
+ const leaves = nonNullLeaves(node).map(closeSchemaNode);
299
+ if (leaves.length === 0) return { type: "null" };
300
+ if (leaves.length === 1) return leaves[0];
301
+ return { anyOf: leaves };
302
+ }
303
+
304
+ let out: Record<string, unknown> = { ...node };
305
+
306
+ if (node.items !== undefined) {
307
+ out.items = closeSchemaNode(node.items);
308
+ }
309
+ if (isPlainObject(node.$defs)) {
310
+ out.$defs = Object.fromEntries(
311
+ Object.entries(node.$defs).map(([key, value]) => [key, closeSchemaNode(value)]),
312
+ );
313
+ }
314
+
315
+ const hasProperties = isPlainObject(node.properties);
316
+ const isObjectType =
317
+ node.type === "object"
318
+ || (Array.isArray(node.type) && node.type.includes("object"))
319
+ || hasProperties
320
+ || node.additionalProperties !== undefined;
321
+
322
+ if (hasProperties) {
323
+ const props = node.properties as Record<string, unknown>;
324
+ const wasRequired = originalRequiredNames(node);
325
+ const closedProps: Record<string, unknown> = {};
326
+ const required: string[] = [];
327
+ for (const [name, propSchema] of Object.entries(props)) {
328
+ required.push(name);
329
+ closedProps[name] = closePropertySchema(propSchema, !wasRequired.has(name));
330
+ }
331
+ out.properties = closedProps;
332
+ out.required = required;
333
+ out.additionalProperties = false;
334
+ if (out.type === undefined) out.type = "object";
335
+ // Object nodes must not also carry residual anyOf from the open copy.
336
+ delete out.anyOf;
337
+ } else if (isObjectType) {
338
+ out.additionalProperties = false;
339
+ if (!Array.isArray(out.required)) out.required = [];
340
+ if (out.type === undefined) out.type = "object";
341
+ } else {
342
+ out = ensureTypedLeaf(out);
343
+ }
344
+
345
+ return out;
346
+ }
347
+
348
+ /**
349
+ * Project shared-envelope MCP rows into `codex -c mcp_servers.<name>.*` argv pairs.
350
+ * Dot-path + TOML values per official config-advanced; spawn argv (no shell).
351
+ */
352
+ function stringEnvironment(value: unknown): Record<string, string> | undefined {
353
+ const entries = Array.isArray(value)
354
+ ? value.flatMap((item) => {
355
+ if (!isPlainObject(item) || typeof item.name !== "string" || typeof item.value !== "string") return [];
356
+ return [[item.name, item.value] as const];
357
+ })
358
+ : isPlainObject(value)
359
+ ? Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")
360
+ : [];
361
+ return entries.length === 0 ? undefined : Object.fromEntries(entries);
362
+ }
363
+
364
+ function codexMcpConfigArgs(
365
+ mcpServers: readonly Readonly<Record<string, unknown>>[],
366
+ ): string[] {
367
+ const args: string[] = [];
368
+ for (const row of mcpServers) {
369
+ const name = typeof row.name === "string" ? row.name : undefined;
370
+ const command = typeof row.command === "string" ? row.command : undefined;
371
+ if (name === undefined || name === "" || command === undefined || command === "") continue;
372
+ const prefix = `mcp_servers.${name}`;
373
+ // required=true: fail the exec if the AK relay cannot handshake (official:
374
+ // required MCP init failure exits with error). Without it, a silent drop
375
+ // would hide a broken intermediate-tool channel (#646 须真跑证②).
376
+ args.push("-c", `${prefix}.command=${codexTomlString(command)}`);
377
+ args.push("-c", `${prefix}.required=true`);
378
+ if (Array.isArray(row.args) && row.args.every((item): item is string => typeof item === "string")) {
379
+ args.push("-c", `${prefix}.args=${codexTomlStringArray(row.args)}`);
380
+ }
381
+ const env = stringEnvironment(row.env);
382
+ if (env !== undefined) {
383
+ args.push("-c", `${prefix}.env=${codexTomlStringTable(env)}`);
384
+ }
385
+ }
386
+ return args;
387
+ }
388
+
389
+ /**
390
+ * Build one `codex exec` / `codex exec resume` argv (#646).
391
+ * New: `exec --approve-for-me … prompt`.
392
+ * Resume: `exec --approve-for-me resume <thread_id> … prompt`.
393
+ * Approval stays on the parent command so new and resumed turns use Codex's
394
+ * automatic review with its workspace-write sandbox.
395
+ */
396
+ export function codexTurnArgs(options: {
397
+ readonly prompt: string;
398
+ /** Absolute path for `-c model_instructions_file=…`. */
399
+ readonly systemPromptPath: string;
400
+ /** Absolute path for `--output-schema` (closed transport schema). */
401
+ readonly outputSchemaPath: string;
402
+ readonly mcpServers: readonly Readonly<Record<string, unknown>>[];
403
+ readonly model?: string;
404
+ readonly effort?: string;
405
+ /**
406
+ * New turn: host mints thread_id (captured from JSONL).
407
+ * Resume: package-bound thread_id via `exec resume <id>`.
408
+ */
409
+ readonly session: { readonly kind: "new" } | { readonly kind: "resume"; readonly id: string };
410
+ /** When cwd is not a git work tree, pass `--skip-git-repo-check`. */
411
+ readonly skipGitRepoCheck?: boolean;
412
+ /**
413
+ * Extra writable roots under workspace-write (absolute paths).
414
+ * Git worktrees need the common dir writable for index.lock / commit
415
+ * (official `sandbox_workspace_write.writable_roots` / `--add-dir`).
416
+ */
417
+ readonly writableRoots?: readonly string[];
418
+ }): string[] {
419
+ // Parent-command flag must precede the resume subcommand.
420
+ const args: string[] = ["exec", "--approve-for-me"];
421
+ if (options.session.kind === "resume") {
422
+ args.push("resume", options.session.id);
423
+ }
424
+
425
+ // JSONL event stream: thread_id + final agent_message + turn.completed/failed.
426
+ args.push("--json");
427
+ // Operator config/MCP off; auth still uses CODEX_HOME (official).
428
+ // Project/system config and AGENTS.md have no official suppression switch.
429
+ args.push("--ignore-user-config", "--ignore-rules");
430
+ const roots = (options.writableRoots ?? []).filter((root) => root !== "");
431
+ if (roots.length > 0) {
432
+ // Resume has no --add-dir; the config key keeps extra roots available on both paths.
433
+ args.push(
434
+ "-c",
435
+ `sandbox_workspace_write.writable_roots=${codexTomlStringArray(roots)}`,
436
+ );
437
+ if (options.session.kind === "new") {
438
+ for (const root of roots) {
439
+ args.push("--add-dir", root);
440
+ }
441
+ }
442
+ }
443
+
444
+ args.push("-c", `model_instructions_file=${codexTomlString(options.systemPromptPath)}`);
445
+ args.push("--output-schema", options.outputSchemaPath);
446
+ args.push(...codexMcpConfigArgs(options.mcpServers));
447
+
448
+ if (options.model !== undefined && options.model !== "") {
449
+ args.push("-m", options.model);
450
+ }
451
+ if (options.effort !== undefined && options.effort !== "") {
452
+ args.push("-c", `model_reasoning_effort=${codexTomlString(options.effort)}`);
453
+ }
454
+ if (options.skipGitRepoCheck === true) {
455
+ args.push("--skip-git-repo-check");
456
+ }
457
+
458
+ // Prompt last as positional. `--` stops option parsing so a leading `-`
459
+ // (markdown lists, pasted flags, rulings) is not eaten by clap (codex tip:
460
+ // "use '-- -s'"). Same for new and resume — both end here.
461
+ args.push("--", options.prompt);
462
+ return args;
463
+ }
464
+
93
465
  /**
94
466
  * Project shared-envelope MCP server rows into Claude `--mcp-config` JSON.
95
467
  * Env stays a plain object (Claude CLI shape); ACP rows use `{name,value}[]`.
@@ -104,19 +476,8 @@ export function headlessMcpConfigDocument(
104
476
  if (name === undefined || name === "" || command === undefined || command === "") continue;
105
477
  const entry: Record<string, unknown> = { command };
106
478
  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
- }
479
+ const env = stringEnvironment(row.env);
480
+ if (env !== undefined) entry.env = env;
120
481
  servers[name] = entry;
121
482
  }
122
483
  return Object.freeze({ mcpServers: Object.freeze(servers) });
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Production composition for the generic headless CLI RoleTurnHost (#645).
2
+ * Production composition for the generic headless CLI RoleTurnHost (#645 / #646).
3
3
  * Agent subprocesses inherit the operator home and credentials in place.
4
4
  * No HOME rewrite, no isolated home, no credential parameters — CLI owns auth.
5
5
  * Sitian records on the run are the dossier; host private sessions stay private.
6
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.
7
+ * Intermediate AK tools ride the shared envelope MCP relay (Claude `--mcp-config`,
8
+ * codex `-c mcp_servers.*`). The terminating receipt is the host-native schema
9
+ * channel only (#750 submission-tool-is-schema-channel) — terminating tool is not
10
+ * listed on MCP (Claude `--json-schema`, codex `--output-schema` closed projection).
11
11
  */
12
12
  import { randomUUID } from "node:crypto";
13
13