@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.
@@ -1,8 +1,13 @@
1
- /** Headless CLI last hop (#645/#820): spawn/parse/bind. Shared loop = external-host-turn-loop. */
2
- import { spawn } from "node:child_process";
1
+ /**
2
+ * Headless CLI last hop (#645/#646/#820): spawn/parse/bind. Shared retry/resume
3
+ * loop = external-host-turn-loop. Claude print-mode and codex exec share this
4
+ * lifecycle; argv/parse are protocol-specific.
5
+ */
6
+ import { spawn, spawnSync } from "node:child_process";
3
7
  import { randomUUID } from "node:crypto";
8
+ import { existsSync } from "node:fs";
4
9
  import { writeFile } from "node:fs/promises";
5
- import { join } from "node:path";
10
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
11
 
7
12
  import type { RoleTurnHost, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
8
13
  import {
@@ -19,8 +24,13 @@ import {
19
24
 
20
25
  import { reportHostSessionEvent } from "../host-session-record.ts";
21
26
  import {
27
+ closeJsonSchemaForCodex,
28
+ codexTurnArgs,
22
29
  headlessMcpConfigDocument,
23
30
  headlessTurnArgs,
31
+ isClaudePrintDescription,
32
+ isCodexExecDescription,
33
+ isPlainObject,
24
34
  type HeadlessHostDescription,
25
35
  } from "./description.ts";
26
36
 
@@ -71,7 +81,7 @@ export type HeadlessCliResult = Readonly<{
71
81
  * envelope without stream-json `type`). Intermediate stream-json events are not.
72
82
  */
73
83
  function isHeadlessResultCandidate(value: unknown): value is HeadlessCliResult {
74
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
84
+ if (!isPlainObject(value)) return false;
75
85
  const record = value as HeadlessCliResult & { type?: unknown };
76
86
  return record.type === undefined
77
87
  || record.type === "result"
@@ -79,7 +89,7 @@ function isHeadlessResultCandidate(value: unknown): value is HeadlessCliResult {
79
89
  }
80
90
 
81
91
  /**
82
- * Parse host stdout into the result envelope.
92
+ * Parse Claude host stdout into the result envelope.
83
93
  * Production uses `--output-format stream-json` (#811 live records); last-result
84
94
  * line is the typed receipt. A single-document `json` body still parses so a
85
95
  * misconfigured description yields a typed miss rather than a silent empty parse.
@@ -101,7 +111,7 @@ export function parseHeadlessCliStdout(stdout: string): HeadlessCliResult | unde
101
111
  if (text === "") continue;
102
112
  try {
103
113
  const value = JSON.parse(text) as unknown;
104
- if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
114
+ if (!isPlainObject(value)) continue;
105
115
  const record = value as HeadlessCliResult & { type?: unknown };
106
116
  // Multi-line stream: only explicit result / structured_output lines (not bare objects).
107
117
  if (record.type === "result" || record.structured_output !== undefined) {
@@ -129,6 +139,116 @@ function resultCandidateText(line: string): string | undefined {
129
139
  }
130
140
  }
131
141
 
142
+ /**
143
+ * Minimal consumer-driven parse of `codex exec --json` JSONL (ADR 0043).
144
+ * Only takes thread_id, final agent_message text, and terminal turn.failed.
145
+ * Top-level `error` events are non-terminal (reconnect notices, skill budget
146
+ * warnings); they must not poison a later turn.completed receipt.
147
+ */
148
+ export type CodexExecTurnObservation = Readonly<{
149
+ threadId?: string;
150
+ /** Last `item.completed` agent_message text (final message / structured receipt). */
151
+ finalMessage?: string;
152
+ /** Present only for terminal `turn.failed` (not recoverable `error` events). */
153
+ failureDiagnostic?: string;
154
+ turnCompleted: boolean;
155
+ }>;
156
+
157
+ function createCodexExecTurnObserver(): {
158
+ readonly observe: (event: unknown) => void;
159
+ readonly result: () => CodexExecTurnObservation;
160
+ } {
161
+ let threadId: string | undefined;
162
+ let finalMessage: string | undefined;
163
+ let failureDiagnostic: string | undefined;
164
+ let turnCompleted = false;
165
+
166
+ return {
167
+ observe(value) {
168
+ if (!isPlainObject(value)) return;
169
+ const type = typeof value.type === "string" ? value.type : undefined;
170
+ if (type === "thread.started" && typeof value.thread_id === "string" && value.thread_id !== "") {
171
+ threadId = value.thread_id;
172
+ } else if (type === "item.completed" && isPlainObject(value.item)) {
173
+ if (value.item.type === "agent_message" && typeof value.item.text === "string") {
174
+ finalMessage = value.item.text;
175
+ }
176
+ } else if (type === "turn.completed") {
177
+ turnCompleted = true;
178
+ failureDiagnostic = undefined;
179
+ } else if (type === "turn.failed") {
180
+ turnCompleted = false;
181
+ failureDiagnostic = formatCodexFailurePayload(value.error ?? value);
182
+ }
183
+ // Top-level `error` is non-terminal; exit/receipt handling remains downstream.
184
+ },
185
+ result() {
186
+ return {
187
+ ...(threadId === undefined ? {} : { threadId }),
188
+ ...(finalMessage === undefined ? {} : { finalMessage }),
189
+ ...(failureDiagnostic === undefined ? {} : { failureDiagnostic }),
190
+ turnCompleted,
191
+ };
192
+ },
193
+ };
194
+ }
195
+
196
+ function formatCodexFailurePayload(payload: unknown): string {
197
+ if (typeof payload === "string" && payload.trim() !== "") return payload;
198
+ if (isPlainObject(payload)) {
199
+ if (typeof payload.message === "string" && payload.message.trim() !== "") return payload.message;
200
+ try {
201
+ return JSON.stringify(payload);
202
+ } catch {
203
+ return "codex turn failed";
204
+ }
205
+ }
206
+ return String(payload);
207
+ }
208
+
209
+ /** cwd or an ancestor has a `.git` entry (file or directory). */
210
+ function cwdIsGitWorkTree(cwd: string): boolean {
211
+ let dir = cwd;
212
+ for (;;) {
213
+ if (existsSync(join(dir, ".git"))) return true;
214
+ const parent = dirname(dir);
215
+ if (parent === dir) return false;
216
+ dir = parent;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Absolute git common dir for workspace-write extra roots (worktree index.lock).
222
+ * When cwd is not a git work tree → undefined (caller skips extra roots).
223
+ * When cwd is a git work tree, git non-zero / empty stdout / spawn failure
224
+ * must fail loud with the real cause — never wash into "no common dir".
225
+ */
226
+ function resolveGitCommonDir(cwd: string): string | undefined {
227
+ if (!cwdIsGitWorkTree(cwd)) return undefined;
228
+ let result: { status: number | null; stdout: string; stderr: string; error?: Error };
229
+ try {
230
+ result = spawnSync("git", ["rev-parse", "--git-common-dir"], {
231
+ cwd,
232
+ encoding: "utf8",
233
+ });
234
+ } catch (error) {
235
+ const message = error instanceof Error ? error.message : String(error);
236
+ throw new Error(`git rev-parse --git-common-dir failed: ${message}`);
237
+ }
238
+ if (result.error !== undefined) {
239
+ throw new Error(`git rev-parse --git-common-dir failed: ${result.error.message}`);
240
+ }
241
+ if (result.status !== 0) {
242
+ const detail = result.stderr.trim() || `exit ${String(result.status)}`;
243
+ throw new Error(`git rev-parse --git-common-dir failed: ${detail}`);
244
+ }
245
+ const raw = result.stdout.trim();
246
+ if (raw === "") {
247
+ throw new Error("git rev-parse --git-common-dir returned empty stdout");
248
+ }
249
+ return isAbsolute(raw) ? raw : resolve(cwd, raw);
250
+ }
251
+
132
252
  function spawnHeadlessTurn(options: {
133
253
  readonly binary: string;
134
254
  readonly args: readonly string[];
@@ -259,19 +379,71 @@ function terminalFromSpawned(
259
379
  };
260
380
  }
261
381
 
262
- /** Headless last hop (#820): session bind/resume, CLI spawn turn, MCP/json-schema mount. */
382
+ function buildTurnArgs(options: {
383
+ readonly description: HeadlessHostDescription;
384
+ readonly prompt: string;
385
+ readonly systemPromptPath: string;
386
+ readonly jsonSchema: Readonly<Record<string, unknown>>;
387
+ readonly mcpServers: readonly Readonly<Record<string, unknown>>[];
388
+ readonly mcpConfigPath?: string;
389
+ readonly outputSchemaPath?: string;
390
+ readonly model?: string;
391
+ readonly effort?: string;
392
+ readonly sessionId: string | undefined;
393
+ readonly sessionKind: "new" | "resume";
394
+ readonly cwd: string;
395
+ readonly writableRoots?: readonly string[];
396
+ }): readonly string[] {
397
+ if (isCodexExecDescription(options.description)) {
398
+ if (options.sessionKind === "resume" && !options.sessionId) {
399
+ throw new Error("codex resume requires a bound thread_id");
400
+ }
401
+ if (options.outputSchemaPath === undefined) throw new Error("codex requires an output schema path");
402
+ return codexTurnArgs({
403
+ prompt: options.prompt,
404
+ systemPromptPath: options.systemPromptPath,
405
+ outputSchemaPath: options.outputSchemaPath,
406
+ mcpServers: options.mcpServers,
407
+ ...(options.model === undefined ? {} : { model: options.model }),
408
+ ...(options.effort === undefined ? {} : { effort: options.effort }),
409
+ session: options.sessionKind === "resume"
410
+ ? { kind: "resume", id: options.sessionId! }
411
+ : { kind: "new" },
412
+ skipGitRepoCheck: !cwdIsGitWorkTree(options.cwd),
413
+ ...(!options.writableRoots?.length ? {} : { writableRoots: options.writableRoots }),
414
+ });
415
+ }
416
+
417
+ if (!isClaudePrintDescription(options.description)) throw new Error("unsupported headless host protocol");
418
+ if (!options.sessionId) throw new Error("claude print-mode requires a session id");
419
+ if (options.mcpConfigPath === undefined) throw new Error("claude print-mode requires an MCP config path");
420
+ return headlessTurnArgs({
421
+ description: options.description,
422
+ prompt: options.prompt,
423
+ systemPromptPath: options.systemPromptPath,
424
+ jsonSchema: options.jsonSchema,
425
+ mcpConfigPath: options.mcpConfigPath,
426
+ ...(options.model === undefined ? {} : { model: options.model }),
427
+ ...(options.effort === undefined ? {} : { effort: options.effort }),
428
+ session: { kind: options.sessionKind, id: options.sessionId },
429
+ });
430
+ }
431
+
432
+ /** Headless last hop (#820): session bind/resume, CLI spawn turn, MCP/json-schema/output-schema mount. */
263
433
  export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig): RoleTurnHost {
264
434
  return createSerializedRoleTurnHost(async (request): Promise<RoleTurnResult> => {
265
435
  const prepared = await config.prepare(request);
266
436
  const systemPrompt = renderSystemPromptOverride(prepared.systemPrompt);
437
+ const codex = isCodexExecDescription(config.description);
267
438
  let outcome: RoleTurnResult = failure("session", "HeadlessNoOutcome", "no-outcome");
268
439
  try {
440
+ // Claude mints a package UUID for --session-id; codex waits for thread.started.
269
441
  let sessionId = await config.sessionIdentity.load(request.principal);
270
442
  let sessionKind: "new" | "resume" =
271
443
  request.continuation.kind === "resume" && sessionId !== undefined && sessionId !== ""
272
444
  ? "resume"
273
445
  : "new";
274
- if (sessionKind === "new") {
446
+ if (sessionKind === "new" && !codex) {
275
447
  sessionId = randomUUID();
276
448
  await config.sessionIdentity.bind(request.principal, sessionId);
277
449
  }
@@ -280,12 +452,21 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
280
452
  const env: NodeJS.ProcessEnv = { ...process.env, ...(config.env ?? {}) };
281
453
  const systemPromptPath = join(request.runDirectory, "headless-system-prompt.txt");
282
454
  await writeFile(systemPromptPath, systemPrompt, "utf8");
283
- const mcpConfigPath = join(request.runDirectory, "headless-mcp-config.json");
284
- await writeFile(
285
- mcpConfigPath,
286
- `${JSON.stringify(headlessMcpConfigDocument(prepared.mcpServers), null, 2)}\n`,
287
- "utf8",
288
- );
455
+ let mcpConfigPath: string | undefined;
456
+ let outputSchemaPath: string | undefined;
457
+ if (codex) {
458
+ // Codex --output-schema needs a closed transport projection on disk.
459
+ outputSchemaPath = join(request.runDirectory, "headless-output-schema.json");
460
+ const closed = closeJsonSchemaForCodex(prepared.jsonSchema);
461
+ await writeFile(outputSchemaPath, `${JSON.stringify(closed, null, 2)}\n`, "utf8");
462
+ } else {
463
+ mcpConfigPath = join(request.runDirectory, "headless-mcp-config.json");
464
+ await writeFile(
465
+ mcpConfigPath,
466
+ `${JSON.stringify(headlessMcpConfigDocument(prepared.mcpServers), null, 2)}\n`,
467
+ "utf8",
468
+ );
469
+ }
289
470
 
290
471
  const sessionParent = config.sessionIdentity.resolveSessionFile(request.principal);
291
472
  outcome = await driveExternalRoleTurnRounds(prepared, request, {
@@ -293,19 +474,33 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
293
474
  currentSessionId: () => sessionId,
294
475
  afterRetry() { sessionKind = "resume"; },
295
476
  async runRound({ prompt, abortSignal }) {
296
- const args = headlessTurnArgs({
297
- description: config.description,
298
- prompt,
299
- systemPromptPath,
300
- jsonSchema: prepared.jsonSchema,
301
- mcpConfigPath,
302
- ...(request.model?.model !== undefined ? { model: request.model.model } : {}),
303
- ...(request.model?.thinking !== undefined ? { effort: request.model.thinking } : {}),
304
- session: sessionKind === "new"
305
- ? { kind: "new", id: sessionId! }
306
- : { kind: "resume", id: sessionId! },
307
- });
477
+ let args: readonly string[];
478
+ try {
479
+ const gitCommonDir = codex ? resolveGitCommonDir(request.cwd) : undefined;
480
+ args = buildTurnArgs({
481
+ description: config.description,
482
+ prompt,
483
+ systemPromptPath,
484
+ jsonSchema: prepared.jsonSchema,
485
+ mcpServers: prepared.mcpServers,
486
+ ...(mcpConfigPath === undefined ? {} : { mcpConfigPath }),
487
+ ...(outputSchemaPath === undefined ? {} : { outputSchemaPath }),
488
+ ...(request.model?.model !== undefined ? { model: request.model.model } : {}),
489
+ ...(request.model?.thinking !== undefined ? { effort: request.model.thinking } : {}),
490
+ sessionId,
491
+ sessionKind,
492
+ cwd: request.cwd,
493
+ ...(gitCommonDir === undefined ? {} : { writableRoots: [gitCommonDir] }),
494
+ });
495
+ } catch (error) {
496
+ const message = error instanceof Error ? error.message : String(error);
497
+ return {
498
+ status: "terminal",
499
+ result: failure("session", "HeadlessArgvFailure", "argv-failed", { diagnostic: message }, message),
500
+ };
501
+ }
308
502
 
503
+ const codexObserver = codex ? createCodexExecTurnObserver() : undefined;
309
504
  let spawned: { code: number | null; stdout: string; stderr: string; timedOut: boolean };
310
505
  try {
311
506
  spawned = await spawnHeadlessTurn({
@@ -325,7 +520,7 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
325
520
  // Non-JSON noise on stdout is not a host structured event.
326
521
  return;
327
522
  }
328
- // Sitian write failures propagate → spawn rejects → session failure.
523
+ // One bounded live seam owns both recording and host-specific reduction.
329
524
  reportHostSessionEvent({
330
525
  host: config.hostName,
331
526
  cwd: request.cwd,
@@ -333,6 +528,7 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
333
528
  source: "headless-host",
334
529
  event,
335
530
  });
531
+ codexObserver?.observe(event);
336
532
  },
337
533
  });
338
534
  } catch (error) {
@@ -373,6 +569,72 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
373
569
  });
374
570
  }
375
571
 
572
+ if (codex) {
573
+ const observation = codexObserver!.result();
574
+ if (observation.threadId === undefined || observation.threadId === "") {
575
+ return terminalFromSpawned(spawned, {
576
+ cause: "session",
577
+ identity: { name: "HeadlessMissingThreadId", code: "missing-thread-id" },
578
+ diagnostic: "codex exec emitted no thread.started thread_id",
579
+ details: { sessionId, exitCode: spawned.code },
580
+ });
581
+ }
582
+ sessionId = observation.threadId;
583
+ await config.sessionIdentity.bind(request.principal, sessionId);
584
+
585
+ if (observation.failureDiagnostic !== undefined) {
586
+ return terminalFromSpawned(spawned, {
587
+ cause: "output",
588
+ identity: { name: "HeadlessCliError", code: "codex-turn-failed" },
589
+ diagnostic: observation.failureDiagnostic,
590
+ details: { sessionId, exitCode: spawned.code },
591
+ });
592
+ }
593
+
594
+ // Non-zero exit without a parseable failure event still fails loud.
595
+ if (spawned.code !== 0 && spawned.code !== null) {
596
+ return terminalFromSpawned(spawned, {
597
+ cause: "output",
598
+ identity: { name: "HeadlessCliError", code: "codex-nonzero-exit" },
599
+ diagnostic: spawned.stderr.trim() || `codex exec exited ${String(spawned.code)}`,
600
+ details: { sessionId, exitCode: spawned.code },
601
+ });
602
+ }
603
+
604
+ if (!observation.turnCompleted) {
605
+ return terminalFromSpawned(spawned, {
606
+ cause: "output",
607
+ identity: { name: "HeadlessCliError", code: "codex-missing-terminal-event" },
608
+ diagnostic: "codex exec exited without turn.completed",
609
+ details: { sessionId, exitCode: spawned.code },
610
+ });
611
+ }
612
+
613
+ if (observation.finalMessage === undefined) {
614
+ return terminalFromSpawned(spawned, {
615
+ cause: "output",
616
+ identity: { name: "HeadlessEmptyOutput", code: "empty-stdout" },
617
+ diagnostic: spawned.stderr.trim() || "codex exec produced no agent_message",
618
+ details: { sessionId, exitCode: spawned.code },
619
+ });
620
+ }
621
+
622
+ let receipt: unknown;
623
+ try {
624
+ receipt = JSON.parse(observation.finalMessage);
625
+ } catch {
626
+ return terminalFromSpawned(spawned, {
627
+ cause: "output",
628
+ identity: { name: "HeadlessEmptyOutput", code: "unparseable-final-message" },
629
+ diagnostic: "codex final agent_message was not JSON",
630
+ details: { sessionId, exitCode: spawned.code },
631
+ });
632
+ }
633
+ await prepared.ingestStructuredOutput(receipt);
634
+ return { status: "delivered", stderr: spawned.stderr };
635
+ }
636
+
637
+ // Claude print-mode path.
376
638
  const envelope = parseHeadlessCliStdout(spawned.stdout);
377
639
  if (envelope === undefined) {
378
640
  return terminalFromSpawned(spawned, {
@@ -383,6 +645,7 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
383
645
  });
384
646
  }
385
647
 
648
+ // Bind the host-reported session id (authoritative for --resume).
386
649
  if (typeof envelope.session_id === "string" && envelope.session_id !== "") {
387
650
  sessionId = envelope.session_id;
388
651
  await config.sessionIdentity.bind(request.principal, sessionId);
@@ -2,7 +2,7 @@
2
2
  * Packaged host description tables (#729 / #731 / #645).
3
3
  * Key = seat-table `host` value.
4
4
  * - ACP family rows feed the generic ACP factory.
5
- * - Headless CLI family rows feed the generic headless factory (#645; #646 codex next).
5
+ * - Headless CLI family rows feed the generic headless factory (#645 claude / #646 codex).
6
6
  * pi is the in-process default, not a row.
7
7
  * Unregistered names fail closed (#510); these tables do not fallback.
8
8
  */
@@ -66,15 +66,19 @@ export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = O
66
66
  });
67
67
 
68
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
- * stream-json + verbose: live host events for sitian records (#811); result is last line.
72
- * `--setting-sources` empty = load no user/project/local CLAUDE.md/hooks/skills
73
- * (role envelope is delivered via `--system-prompt` wholesale replace).
74
- * `--strict-mcp-config` with no `--mcp-config` drops operator MCP + claude.ai connectors.
69
+ * Headless CLI family (#645 / #646). Claude print-mode is the first row;
70
+ * codex exec (#646) adds another. Protocol-specific argv/parse live in
71
+ * headless-host helpers (#752 per-host impl).
72
+ * Claude fixedArgs: print mode, isolation without `--bare` (OAuth stays), full
73
+ * permissions. stream-json + verbose: live host events for sitian records
74
+ * (#811); result is last line. `--setting-sources` empty = load no
75
+ * user/project/local CLAUDE.md/hooks/skills (role envelope is delivered via
76
+ * `--system-prompt` wholesale replace). `--strict-mcp-config` with no
77
+ * `--mcp-config` drops operator MCP + claude.ai connectors.
75
78
  */
76
79
  export const HEADLESS_HOST_DESCRIPTIONS: Readonly<Record<string, HeadlessHostDescription>> = Object.freeze({
77
80
  "claude": Object.freeze({
81
+ protocol: "claude-print",
78
82
  binaryFromHome: Object.freeze([".local", "bin", "claude"]),
79
83
  sessionBindingFile: "claude-headless-session.json",
80
84
  fixedArgs: Object.freeze([
@@ -98,6 +102,15 @@ export const HEADLESS_HOST_DESCRIPTIONS: Readonly<Record<string, HeadlessHostDes
98
102
  sessionIdFlag: "--session-id",
99
103
  resumeFlag: "--resume",
100
104
  }),
105
+ /**
106
+ * Codex headless (#646). Binary under operator home; auth stays in CODEX_HOME.
107
+ * Argv/parse/schema-close are codex-exec helpers — not Claude flag mapping.
108
+ */
109
+ "codex": Object.freeze({
110
+ protocol: "codex-exec",
111
+ binaryFromHome: Object.freeze([".local", "bin", "codex"]),
112
+ sessionBindingFile: "codex-headless-session.json",
113
+ }),
101
114
  });
102
115
 
103
116
  export function lookupHostDescription(host: string): AcpHostDescription | undefined {