flowviant 0.125.6 → 0.125.9

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.
Files changed (73) hide show
  1. package/README.md +84 -6
  2. package/bin/cli.mjs +7 -1
  3. package/bin/lib/activityFeed.mjs +69 -0
  4. package/bin/lib/agentMergeReports.mjs +46 -0
  5. package/bin/lib/agentMergeResolution.mjs +44 -0
  6. package/bin/lib/agentPlan.mjs +6 -2
  7. package/bin/lib/claudeInput.mjs +61 -1
  8. package/bin/lib/claudePosture.mjs +21 -0
  9. package/bin/lib/claudeStream.mjs +54 -4
  10. package/bin/lib/codexContract.mjs +52 -0
  11. package/bin/lib/codexResearch.mjs +33 -5
  12. package/bin/lib/commandSpecs.mjs +8 -5
  13. package/bin/lib/config.mjs +22 -1
  14. package/bin/lib/daemonJournal.mjs +102 -0
  15. package/bin/lib/daemonLogging.mjs +22 -18
  16. package/bin/lib/fleet.mjs +16 -6
  17. package/bin/lib/fleetReports.mjs +12 -5
  18. package/bin/lib/hooks/orchestrator.mjs +3 -1
  19. package/bin/lib/logReplay.mjs +133 -0
  20. package/bin/lib/mcpInventory.mjs +2 -1
  21. package/bin/lib/mcpReasons.mjs +6 -0
  22. package/bin/lib/nativeAgentTools.mjs +20 -3
  23. package/bin/lib/observedLines.mjs +63 -0
  24. package/bin/lib/orchestrator.mjs +49 -7
  25. package/bin/lib/orchestratorNative.mjs +133 -3
  26. package/bin/lib/orchestratorPrompt.mjs +50 -4
  27. package/bin/lib/postureTools.mjs +14 -6
  28. package/bin/lib/printable.mjs +1 -1
  29. package/bin/lib/processGroup.mjs +41 -0
  30. package/bin/lib/projectTools.mjs +3 -2
  31. package/bin/lib/prompts.mjs +47 -18
  32. package/bin/lib/reportJournal.mjs +61 -0
  33. package/bin/lib/repositoryMerge.mjs +1 -1
  34. package/bin/lib/researchHandoff.mjs +144 -0
  35. package/bin/lib/researchHandoffText.mjs +3 -0
  36. package/bin/lib/researchKnowledge.mjs +60 -0
  37. package/bin/lib/researchRestoration.mjs +38 -0
  38. package/bin/lib/resumeLost.mjs +21 -1
  39. package/bin/lib/runTurn.mjs +98 -44
  40. package/bin/lib/runtimeCapabilities.mjs +7 -2
  41. package/bin/lib/runtimeClaude.mjs +30 -8
  42. package/bin/lib/runtimeCodex.mjs +54 -22
  43. package/bin/lib/runtimeEvents.mjs +17 -7
  44. package/bin/lib/runtimeOfferings.mjs +3 -1
  45. package/bin/lib/startProject.mjs +10 -0
  46. package/bin/lib/turnDiagnostics.mjs +2 -1
  47. package/bin/lib/turnOutput.mjs +63 -0
  48. package/bin/lib/turnRecord.mjs +11 -0
  49. package/bin/lib/turnSubagents.mjs +13 -3
  50. package/bin/lib/ui.mjs +2 -2
  51. package/bin/lib/update.mjs +18 -4
  52. package/bin/lib/uplinkScrub.mjs +49 -0
  53. package/bin/lib/views.mjs +49 -58
  54. package/bin/lib/wikiRunner.mjs +2 -2
  55. package/bin/lib/work.mjs +5 -5
  56. package/bin/lib/workActivity.mjs +65 -0
  57. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  58. package/bin/lib/workAgentMerges.mjs +57 -52
  59. package/bin/lib/workAgentPlans.mjs +76 -27
  60. package/bin/lib/workAgentPrecheck.mjs +1 -1
  61. package/bin/lib/workAgentTurnExecution.mjs +62 -8
  62. package/bin/lib/workAgentTurnReports.mjs +33 -3
  63. package/bin/lib/workAgentTurnShape.mjs +6 -4
  64. package/bin/lib/workAgentTurns.mjs +12 -8
  65. package/bin/lib/workAttachments.mjs +27 -5
  66. package/bin/lib/workIntake.mjs +8 -2
  67. package/bin/lib/workMergeBatch.mjs +7 -1
  68. package/bin/lib/workNarration.mjs +32 -0
  69. package/bin/lib/workPlanRecovery.mjs +50 -0
  70. package/bin/lib/workReportQueue.mjs +18 -1
  71. package/bin/lib/workSessionTurns.mjs +64 -22
  72. package/bin/lib/worktreeDiff.mjs +29 -5
  73. package/package.json +1 -1
@@ -11,6 +11,8 @@
11
11
  */
12
12
 
13
13
  import { promptTransport } from './promptTransport.mjs';
14
+ import { prepareResearchLaunch } from './researchHandoff.mjs';
15
+ import { runResearchWithReceipt } from './researchRestoration.mjs';
14
16
  import { runIsolatedResearch } from './codexResearch.mjs';
15
17
  import { spawn } from 'node:child_process';
16
18
  import { runtimeById } from './runtimes.mjs';
@@ -21,7 +23,8 @@ import { never, workLanesStopped } from './standDownGate.mjs';
21
23
  import { learnCodexLimits, recordClaudeRateLimit } from './runtimeLimits.mjs';
22
24
  import { initHarvestFor, recordMcpServers, recordSkills } from './runtimeCapabilities.mjs';
23
25
  import { codexTurnModel } from './turnModel.mjs';
24
- import { printableText } from './printable.mjs';
26
+ import { createTurnOutput } from './turnOutput.mjs';
27
+ import { createObservedLines } from './observedLines.mjs';
25
28
  import { startTurnDiagnostics } from './turnDiagnostics.mjs';
26
29
  import { createClaudeInput } from './claudeInput.mjs';
27
30
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
@@ -29,6 +32,8 @@ import { createTurnSubagents } from './turnSubagents.mjs';
29
32
 
30
33
  /** How long a Codex turn's answer may wait on its rollout's model line. */
31
34
  const CODEX_MODEL_READ_MS = 1_500;
35
+ /** How long an exited CLI's turn waits for its streams to close (see the `exit` handler). */
36
+ export const EXIT_CLOSE_GRACE_MS = 2_000;
32
37
 
33
38
  /**
34
39
  * THE ENVIRONMENT A CLI TURN IS SPAWNED WITH — the daemon's own, plus the
@@ -61,23 +66,29 @@ export function cliEnv(mcpEnv, { display = false, browserHome = null } = {}) {
61
66
  }
62
67
 
63
68
  // One CLI turn. Output is captured (for sentinel detection) and streamed
64
- // through, line-prefixed with the worker label so a fleet stays legible.
69
+ // through as complete timestamped records carrying work and turn identity.
65
70
  //
66
71
  // The event stream is parsed, always: only the humanized tool activity reaches
67
72
  // the console (a legible stream of `read …`, `grep …`, `+ node …`), assistant
68
73
  // text is folded into the returned string for sentinel detection, and each
69
74
  // activity is handed to `onActivity` so the caller can forward progress.
75
+ /** Linux's per-argument ceiling (`MAX_ARG_STRLEN`, 32 pages): one argv
76
+ * string past it is refused at launch with E2BIG. */
77
+ const MAX_ARG_BYTES = 128 * 1024;
78
+
70
79
  export function runTurn(opts) {
71
80
  // A Codex write-up runs over an isolated copy of its worktree
72
81
  // (codexResearch.mjs). After the teardown it copies nothing either: the
73
82
  // stand-down gate below is asked before the copy, not only before the spawn.
74
83
  if (opts.runtime === 'codex' && opts.profile === 'research')
75
- return workLanesStopped() ? never() : runIsolatedResearch(opts, runPlainTurn);
84
+ return workLanesStopped() ? never() : runIsolatedResearch(opts, prepared => runResearchWithReceipt(prepared, runPlainTurn));
85
+ if (opts.profile === 'research' && opts.researchHandoff)
86
+ return prepareResearchLaunch(opts).then(prepared => runResearchWithReceipt(prepared, runPlainTurn));
76
87
  return runPlainTurn(opts);
77
88
  }
78
89
 
79
90
  function runPlainTurn(opts) {
80
- const { prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onRateLimit, onModel, onThreadId, onAnswer, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId, display, browserHome } = opts;
91
+ const { prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', onSpawn, answerFromResult, onActivity, onToolEvent: callerOnToolEvent, onInit, onUsage, onRateLimit, onModel, onThreadId, onAnswer, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId, display, browserHome } = opts;
81
92
  /**
82
93
  * AFTER THE TEARDOWN, NO TURN STARTS AND NO TURN ENDS (standDownGate.mjs).
83
94
  * A stand-down that drains a deploy keeps this process alive; without this
@@ -86,11 +97,19 @@ function runPlainTurn(opts) {
86
97
  * Both answers stay pending instead, as `process.exit` left them.
87
98
  */
88
99
  if (workLanesStopped()) return never();
100
+ const output = createTurnOutput(opts);
101
+ const emit = output.emit;
102
+ const onToolEvent = (name, input, meta, observedAt) => {
103
+ output.tool(name, input, meta, observedAt);
104
+ callerOnToolEvent?.(name, input, meta);
105
+ };
89
106
  return new Promise((resolveTurn, rejectTurn) => {
90
107
  const resolve = (v) => {
108
+ output.close();
91
109
  if (!workLanesStopped()) resolveTurn(v);
92
110
  };
93
111
  const reject = (error) => {
112
+ output.close();
94
113
  if (!workLanesStopped()) rejectTurn(error);
95
114
  };
96
115
  const rt = runtimeById(runtime);
@@ -101,7 +120,7 @@ function runPlainTurn(opts) {
101
120
  // lane's `unsupported` check). Here the daemon log names the id and the
102
121
  // turn answers nothing — never Claude wearing the other runtime's name.
103
122
  if (!rt) {
104
- console.error(`\nerror: cannot run '${runtime}' — this daemon does not know that runtime; update flowviant`);
123
+ output.error(`error: cannot run '${runtime}' — this daemon does not know that runtime; update flowviant`);
105
124
  resolve('');
106
125
  return;
107
126
  }
@@ -112,7 +131,7 @@ function runPlainTurn(opts) {
112
131
  */
113
132
  const turnProfile = resolveTurnProfile(opts);
114
133
  if (turnProfile.error) {
115
- console.error(`\nerror: ${turnProfile.error}`);
134
+ output.error(`error: ${turnProfile.error}`);
116
135
  resolve('');
117
136
  return;
118
137
  }
@@ -127,7 +146,7 @@ function runPlainTurn(opts) {
127
146
  // adapter learns to express one this opens by itself.
128
147
  const offRuntime = turnProfile.onlyOn?.(rt);
129
148
  if (offRuntime) {
130
- console.error(`\nerror: ${offRuntime}`);
149
+ output.error(`error: ${offRuntime}`);
131
150
  resolve('');
132
151
  return;
133
152
  }
@@ -136,7 +155,7 @@ function runPlainTurn(opts) {
136
155
  // drive. Fail as a turn with no sentinel — the loop already treats that as
137
156
  // "the protocol did not complete" and retries, rather than inventing a
138
157
  // completion for work that never started.
139
- console.error(`\nerror: cannot run '${rt.label}' — ${rt.blocked}`);
158
+ output.error(`error: cannot run '${rt.label}' — ${rt.blocked}`);
140
159
  resolve('');
141
160
  return;
142
161
  }
@@ -181,6 +200,7 @@ function runPlainTurn(opts) {
181
200
  // The lane's own measurement when it decided to orchestrate, so the
182
201
  // hooks and the controller cannot disagree (workAgentTurnExecution.mjs).
183
202
  codexVersion: opts.codexVersion ?? measuredRuntimeVersion('codex')?.version,
203
+ claudeVersion: opts.claudeVersion ?? measuredRuntimeVersion('claude')?.version ?? null,
184
204
 
185
205
  streamInput: stdinText !== null || (rt.liveInput === true && Boolean(opts.liveMessages)),
186
206
  writableDirs: opts.writableDirs,
@@ -346,34 +366,33 @@ function runPlainTurn(opts) {
346
366
  // own browser home.
347
367
  releaseArgs();
348
368
  browser?.cleanup();
369
+ // THE COMMAND LINE'S OWN LIMIT, IN WORDS (2026-10-19). A runtime whose
370
+ // prompt still rides argv (Antigravity's `-p`) hits the OS's per-argument
371
+ // ceiling on a long message, and the turn failed as "spawn E2BIG". The
372
+ // refusal is the system's and stays; only its words are new.
373
+ // Said only when the message IS the oversized argument: E2BIG also
374
+ // answers an environment too large for the launch, which is not the
375
+ // person's message and keeps the system's own words.
376
+ const carrying = error?.code === 'E2BIG' && typeof prompt === 'string' && prompt
377
+ ? args.find((a) => typeof a === 'string' && a.includes(prompt))
378
+ : undefined;
379
+ const bytes = carrying ? Buffer.byteLength(carrying) : 0;
380
+ if (bytes > MAX_ARG_BYTES) {
381
+ throw Object.assign(new Error(`${rt.label} takes its message on the command line, and this one is too long for it (${Math.ceil(bytes / 1024)} KB; the system allows ${MAX_ARG_BYTES / 1024} KB per argument, E2BIG). Send it in shorter parts, or attach the long text as a file.`), { code: 'E2BIG' });
382
+ }
349
383
  throw error;
350
384
  }
351
385
  if (browser) {
352
386
  child.once('close', browser.cleanup);
353
387
  child.once('error', browser.cleanup);
354
388
  }
355
- if (child.pid) onSpawn?.(child);
389
+ if (child.pid) { output.event(`${rt.label} started`, 'start'); onSpawn?.(child); }
356
390
  // When this turn began, for the Codex rollout read: a `turn_context` older
357
391
  // than this is a previous turn's (turnModel.mjs).
358
392
  const spawnedAt = Date.now();
359
393
  let out = '';
360
394
  let stdinError = null;
361
- const input = rt.liveInput === true && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken }) : null;
362
- const pfx = label ? `${label} ` : '';
363
- /**
364
- * THE ONE SINK FOR WHAT A CLI OR A MODEL WROTE (2026-10-10, the audit's
365
- * F133): activity labels — a Bash command, a Codex `say`, a file path —
366
- * and the CLI's raw stderr. A prompt-injected agent can put an OSC 52 in
367
- * a tool command as easily as in prose, and this wrote it to the
368
- * operator's terminal as-is. Scrubbed HERE, before the daemon's own
369
- * coloured `label` is added, so the prefix keeps its colour and nothing
370
- * the model wrote keeps an escape sequence (printable.mjs, `printableText`).
371
- */
372
- const emit = (s) => {
373
- const text = printableText(String(s));
374
- process.stdout.write(pfx ? text.replace(/\n/g, `\n${pfx}`) : text);
375
- };
376
-
395
+ const input = rt.liveInput === true && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken, onContext: opts.onContext ?? null }) : null;
377
396
  // EVERY TURN IS LINE-PARSED (2026-10-10, the audit). Claude's adapter
378
397
  // always asks for `--output-format stream-json` and every other runtime
379
398
  // brings its own `parse` — for Codex the JSONL stream is the only output
@@ -382,7 +401,6 @@ function runPlainTurn(opts) {
382
401
  // already drifted once (it called process.exit(1)); both are gone. A line
383
402
  // that is not JSON is still kept as text (claudeStream.mjs), and stderr
384
403
  // still lands in the haystack the sentinels read.
385
- let buf = '';
386
404
  const appendText = (t) => {
387
405
  out += t;
388
406
  };
@@ -400,13 +418,15 @@ function runPlainTurn(opts) {
400
418
  * waits on a file read. See runtimeLimits.mjs.
401
419
  */
402
420
  const onRate = (info) => {
421
+ if (info?.status === 'rejected' || info?.status === 'allowed_warning')
422
+ output.event(`${rt.label} plan limit: ${info.status}${typeof info.rateLimitType === 'string' ? ` (${info.rateLimitType})` : ''}`, 'limit');
403
423
  recordClaudeRateLimit(info);
404
424
  onRateLimit?.(info);
405
425
  };
406
426
  // The native children this CLI starts, observed (turnSubagents.mjs).
407
427
  const natives = createTurnSubagents({ runtime: rt.id, cwd, since: spawnedAt, env: { ...process.env, ...(mcpEnv ?? {}) },
408
428
  owned: opts.orchestration === true, onSubagent: opts.onSubagent, onSpawnResult: opts.onSpawnResult, resumeThreadId,
409
- sinks: { emit, onActivity, onToolEvent, appendText } });
429
+ sinks: { emit, onActivity, onToolEvent, appendText, onDetail: output.detail } });
410
430
  let codexThread = rt.id === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
411
431
  /**
412
432
  * THE MODEL THIS SPAWN RAN ON, ONCE (2026-09-29, turnModel.mjs). Claude
@@ -436,12 +456,17 @@ function runPlainTurn(opts) {
436
456
  if (harvest.mcp) recordMcpServers(i?.mcpServers, i?.tools);
437
457
  onInit?.(i);
438
458
  };
459
+ /** This turn's Claude task list, as the plan card reads it (claudeStream.mjs). */
460
+ const taskPlan = new Map();
439
461
  /** One line of the child's stdout, in whichever dialect it speaks. */
440
462
  let cliFailed = false;
441
- const onLine = (line) => {
463
+ const onLine = (line, observedAt) => {
464
+ const emitObserved = (text, meta) => emit(text, { ...meta, observedAt });
465
+ const toolObserved = (name, input, meta) => onToolEvent(name, input, meta, observedAt);
442
466
  try {
443
467
  const event = JSON.parse(line);
444
468
  const parentEvent = event.parent_tool_use_id == null && !event.isSidechain;
469
+ opts.researchReceipt?.event(event);
445
470
  if (input && parentEvent) {
446
471
  input.event(event);
447
472
  if (event.type === 'system' && event.subtype === 'init' && event.session_id) opts.liveMessages.onInit(event.session_id);
@@ -457,7 +482,7 @@ function runPlainTurn(opts) {
457
482
  }
458
483
  } catch { /* ordinary non-JSON CLI output */ }
459
484
  if (!rt.parse)
460
- return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit: onInitAll, onUsage, onRateLimit: onRate, onModel: onRanOn, subagents: natives.collector });
485
+ return handleStreamLine(line, { cwd, emit: emitObserved, onActivity, onToolEvent: toolObserved, appendText, answerFromResult, onInit: onInitAll, onUsage, onRateLimit: onRate, onModel: onRanOn, subagents: natives.collector, tasks: taskPlan, onDetail: (text, meta) => output.detail(text, { ...meta, observedAt }) });
461
486
  const ev = rt.parse(line, cwd);
462
487
  if (!ev) return;
463
488
  // The conversation id, when the runtime announces one (codex's
@@ -474,14 +499,17 @@ function runPlainTurn(opts) {
474
499
  // A receipt the controller cannot take never ends this line's handling,
475
500
  // as when the lane parsed the line again itself (2026-10-18).
476
501
  if (ev.spawnResult) try { opts.onSpawnResult?.(ev.spawnResult); } catch { /* the controller's own failure */ }
477
- if (ev.toolEvent) onToolEvent?.(...ev.toolEvent);
502
+ if (ev.toolEvent) toolObserved(...ev.toolEvent);
503
+ for (const tool of ev.toolEvents ?? []) toolObserved(...tool);
478
504
  if (ev.usage) onUsage?.(ev.usage);
479
505
  // One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
480
506
  // wins at the caller, which is what "the final answer" means.
481
507
  if (typeof ev.answer === 'string') onAnswer?.(ev.answer);
508
+ if (ev.detail) output.detail(ev.detail.text, { source: ev.detail.source, observedAt });
482
509
  if (ev.text) appendText(ev.text);
483
510
  if (ev.activity) {
484
- emit(`${ev.activity.label}\n`);
511
+ emitObserved(ev.activity.kind === 'error' ? ev.activity.full ?? ev.activity.label : ev.activity.label + '\n', ev.activity);
512
+ if (ev.activity.error) emitObserved(ev.activity.error, { ...ev.activity, kind: 'error', full: ev.activity.error });
485
513
  onActivity?.(ev.activity);
486
514
  }
487
515
  };
@@ -492,19 +520,18 @@ function runPlainTurn(opts) {
492
520
  // `setEncoding` holds the partial bytes over to the next chunk.
493
521
  child.stdout.setEncoding('utf8');
494
522
  child.stderr.setEncoding('utf8');
523
+ let ended = false;
524
+ const stdoutLines = createObservedLines((line, at) => { if (line.trim()) onLine(line, at); });
525
+ const stderrLines = createObservedLines((line, observedAt) => emit(line + '\n', { observedAt, source: 'stderr' }));
495
526
  child.stdout.on('data', (d) => {
496
- buf += d;
497
- let nl;
498
- while ((nl = buf.indexOf('\n')) >= 0) {
499
- const line = buf.slice(0, nl);
500
- buf = buf.slice(nl + 1);
501
- if (line.trim()) onLine(line);
502
- }
527
+ if (ended) return;
528
+ stdoutLines.push(d);
503
529
  });
504
530
  // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
505
531
  child.stderr.on('data', (s) => {
532
+ if (ended) return;
506
533
  out += s;
507
- emit(s);
534
+ stderrLines.push(s);
508
535
  });
509
536
  /**
510
537
  * A SPAWN THAT FAILS FAILS THE TURN, NOT THE DAEMON — the one copy of the
@@ -520,32 +547,42 @@ function runPlainTurn(opts) {
520
547
  * this turn with the native reason; the lane records a failed report.
521
548
  */
522
549
  const onSpawnError = (e) => {
550
+ opts.researchReceipt?.fail();
523
551
  input?.close();
552
+ stdoutLines.end();
553
+ stderrLines.end();
524
554
  natives.abandon();
525
555
  if (opts.liveMessages) void opts.liveMessages.stop();
526
556
  finishDiagnostics({ pid: child.pid, spawnError: typeof e.code === 'string' ? e.code : 'SPAWN_ERROR' });
527
557
  releaseArgs();
528
558
  if (e.code === 'ENOENT') {
529
- console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
559
+ output.error(`error: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
530
560
  resolve('');
531
561
  return;
532
562
  }
533
- console.error(e);
563
+ output.error(e);
534
564
  reject(e);
535
565
  };
536
566
  child.on('error', onSpawnError);
537
- child.on('close', (code, signal) => {
567
+ const onClose = (code, signal) => {
568
+ if (ended) return;
569
+ ended = true;
538
570
  input?.close();
539
571
  releaseArgs();
540
572
  opts.onExit?.(code, signal);
541
- if (buf.trim()) onLine(buf);
573
+ stdoutLines.end();
574
+ stderrLines.end();
575
+ if (code !== 0 || signal) opts.researchReceipt?.fail();
542
576
  finishDiagnostics({ code, signal, pid: child.pid, output: out, cliFailed, inputError: stdinError?.code });
543
577
  const handBack = async () => {
544
578
  if (opts.liveMessages) await opts.liveMessages.stop();
545
579
  await natives.drain(CODEX_MODEL_READ_MS);
546
580
  natives.sweep();
581
+ if (signal) output.event(`${rt.label} interrupted (${signal})`, 'interrupted');
582
+ else if (code !== 0) output.event(`${rt.label} exited with code ${code}`, 'error');
547
583
  if (stdinError) {
548
- if (!workLanesStopped()) rejectTurn(stdinError);
584
+ output.error(stdinError);
585
+ reject(stdinError);
549
586
  return;
550
587
  }
551
588
  resolve(out);
@@ -577,12 +614,29 @@ function runPlainTurn(opts) {
577
614
  return;
578
615
  }
579
616
  handBack();
617
+ };
618
+ child.on('close', onClose);
619
+ /**
620
+ * THE CLI'S EXIT ENDS ITS TURN (2026-10-19). `close` waits for every
621
+ * stdio stream, and anything the CLI started that inherited them holds
622
+ * them open after the CLI is gone. A New task chat's Codex aborted its
623
+ * turn and exited; its turn stayed pending under a renewed lease for six
624
+ * and a half hours, until the machine restarted. An exited CLI says
625
+ * nothing more, so a turn its streams still hold settles with what it
626
+ * said. The streams keep draining, since the holder is not this turn's
627
+ * to break (`interruptThenKill`): a later line is dropped, never parsed
628
+ * into a settled turn.
629
+ */
630
+ child.once('exit', (code, signal) => {
631
+ const timer = setTimeout(() => onClose(code, signal), EXIT_CLOSE_GRACE_MS);
632
+ child.once('close', () => clearTimeout(timer));
580
633
  });
581
634
  if (transport.text !== null) {
582
635
  // end() buffers with backpressure and closes at EOF; Codex reads the
583
636
  // entire prompt before starting. An early close must not accept output
584
637
  // from a turn whose complete authorized input never reached the CLI.
585
638
  child.stdin.on('error', (error) => {
639
+ opts.researchReceipt?.fail('input_error');
586
640
  stdinError = Object.assign(new Error(`${rt.label} could not receive the complete turn input (${error.code || 'PIPE_ERROR'}).`), { code: error.code || 'PIPE_ERROR' });
587
641
  opts.onCliFailure?.();
588
642
  child.kill('SIGTERM');
@@ -168,9 +168,14 @@ export function claudeMcpInventoryOf(list, tools) {
168
168
  for (const e of list) {
169
169
  if (!e || typeof e !== 'object' || typeof e.name !== 'string') continue;
170
170
  const name = e.name.trim().slice(0, MCP_NAME_MAX).trim();
171
- if (!name || name === 'flowviant' || seen.has(name)) continue;
171
+ const scope = typeof e.scope === 'string' ? e.scope : typeof e.source === 'string' ? e.source : null;
172
+ // The daemon's own `flowviant` rides `--mcp-config`, reported `dynamic`.
173
+ // One in the person's own config is theirs (2026-10-17, the owner) and is
174
+ // listed, so a revoked token there reads failed (2026-10-19). An
175
+ // unscoped report cannot say which, and stays hidden.
176
+ if (!name || name === 'flowviant' && (scope === 'dynamic' || scope == null) || seen.has(name)) continue;
172
177
  const status = MCP_STATUSES.has(e.status) ? e.status : 'other';
173
- seen.set(name, { name, status, scope: typeof e.scope === 'string' ? e.scope : typeof e.source === 'string' ? e.source : null, prefix: mcpToolPrefix(e.name.trim()), tools: status === 'connected' ? [] : null });
178
+ seen.set(name, { name, status, scope, prefix: mcpToolPrefix(e.name.trim()), tools: status === 'connected' ? [] : null });
174
179
  }
175
180
  // The longest prefix wins, so `a_b` never claims `a`'s tools or the reverse.
176
181
  const byPrefix = [...seen.values()].sort((a, b) => b.prefix.length - a.prefix.length);
@@ -16,9 +16,19 @@ import { join } from 'node:path';
16
16
  import { MODEL, USER_AGENT } from './config.mjs';
17
17
  import { NO_SETTING_SOURCES, personalClaudeSettings, releaseTurnSettings, withPersonalSettings, writeTurnSettings } from './claudePersonal.mjs';
18
18
  import { machineMcpServers } from './claudeMachineMcp.mjs';
19
- import { claudePermFor, withMachineMcp } from './claudePosture.mjs';
19
+ import { claudePermFor, withMachineMcp, withNativeChildren } from './claudePosture.mjs';
20
20
  import { withSettings } from './claudeSettingsArg.mjs';
21
21
  import { orchestratorHooks } from './orchestratorHooks.mjs';
22
+ import { orchestratesPosture } from './orchestratorPrompt.mjs';
23
+ import { versionAtLeast } from './versionOrder.mjs';
24
+
25
+ /** The release the agent lane first measured `--system-prompt-snapshot` on
26
+ * (decisions.md, 2026-09-24). */
27
+ export const SYSTEM_PROMPT_SNAPSHOT_MIN = '2.1.281';
28
+ /** `--version` prints "2.1.303 (Claude Code)"; the first word is the version. */
29
+ export function claudeSnapshotFlagKnown(version) {
30
+ return versionAtLeast(String(version ?? '').trim().split(/\s+/)[0], SYSTEM_PROMPT_SNAPSHOT_MIN);
31
+ }
22
32
 
23
33
  function claudeMcp(token, mcpUrl) {
24
34
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
@@ -113,7 +123,7 @@ export const CLAUDE_RUNTIME = {
113
123
  * strongest form of it available anywhere: `--append-system-prompt` sits
114
124
  * above the conversation rather than inside it.
115
125
  */
116
- args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false }) {
126
+ args({ writableDirs = [], prompt, system, model, effort, resume, perm: postureList, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false, claudeVersion = null }) {
117
127
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
118
128
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
119
129
  // with no vault, or one no rule can name, has no list — refused here in
@@ -122,7 +132,7 @@ export const CLAUDE_RUNTIME = {
122
132
  if (profile === 'wiki' && !vaultDir) {
123
133
  throw new Error("a wiki turn needs its vault to fence — none reached claude's builder");
124
134
  }
125
- if (profile === 'wiki' && !Array.isArray(perm)) {
135
+ if (profile === 'wiki' && !Array.isArray(postureList)) {
126
136
  throw new Error(`a wiki turn's vault must be a path a Claude Code permission rule can name — ${JSON.stringify(vaultDir)} is not`);
127
137
  }
128
138
  const a = [];
@@ -181,6 +191,10 @@ export const CLAUDE_RUNTIME = {
181
191
  * agent renders the CURRENT base snapshot, not the first turn's cache.
182
192
  */
183
193
  const fenced = profile !== 'build';
194
+ // THE GATE RIDES A CODE OR RESEARCH CARD (2026-10-19, orchestratorPrompt.mjs);
195
+ // a research card's list then admits the children its fence denied.
196
+ const gated = orchestration && orchestratesPosture(profile);
197
+ const perm = gated && fenced ? withNativeChildren(postureList) : postureList;
184
198
  if (fenced) a.push(...NO_SETTING_SOURCES);
185
199
  // A FENCED POSTURE NAMES ITS OWN MODE (2026-10-10). Its lists fence only
186
200
  // under the CLI's `default` mode, where a tool the list does not name is
@@ -191,10 +205,18 @@ export const CLAUDE_RUNTIME = {
191
205
  // states `default` itself, unless its own list names a mode (plan-mode).
192
206
  if (fenced && !perm.includes('--permission-mode')) a.push('--permission-mode', 'default');
193
207
  else if (agentTools) a.push('--setting-sources', 'user');
194
- if (agentTools) {
195
- a.push('--system-prompt-snapshot', 'off');
196
- if (agentTools.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
197
- }
208
+ // EVERY TURN RENDERS ITS OWN SYSTEM PROMPT (2026-10-19). By default the
209
+ // CLI records a conversation's first system prompt and resends that record
210
+ // on every resume "even when a later launch passes different text" —
211
+ // measured offline on 2.1.303: a resumed turn appended BETA and the model
212
+ // was sent the first turn's ALPHA. A tab's appended prompt moves between
213
+ // turns (plan mode on and off, the knowledge and artifact paragraphs), so
214
+ // a tab switched out of plan mode kept being told to change nothing. An
215
+ // agent turn always passed this (its current base instructions). A CLI too
216
+ // old to name the flag refuses it, so an unmeasured version keeps the old
217
+ // argv.
218
+ if (agentTools || claudeSnapshotFlagKnown(claudeVersion)) a.push('--system-prompt-snapshot', 'off');
219
+ if (agentTools?.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
198
220
  // WHICH MCP SERVERS A TURN MOUNTS. An agent's code, write-up and
199
221
  // mockup/3D-model/deck turns (2026-10-11, the owner) mount this machine's
200
222
  // own — its user and local servers and the account's claude.ai connectors —
@@ -257,7 +279,7 @@ export const CLAUDE_RUNTIME = {
257
279
  const mounted = machine ? withMachineMcp(perm, machineMcp) : perm;
258
280
  // The orchestrator's hooks join that same `--settings`: the CLI applies
259
281
  // only the last one it is handed (claudeSettingsArg.mjs `withSettings`).
260
- const postured = orchestration && !fenced ? withSettings(mounted, { hooks: orchestratorHooks(), disableAllHooks: false }) : mounted;
282
+ const postured = gated ? withSettings(mounted, { hooks: orchestratorHooks(), disableAllHooks: false }) : mounted;
261
283
  let tail;
262
284
  try {
263
285
  tail = fenced ? withPersonalSettings(postured, personalSettings ?? personalClaudeSettings()) : postured;
@@ -18,6 +18,7 @@ import { researchFence } from './codexResearch.mjs';
18
18
  import { projectReadBoundary, projectReadFence } from './codexProjectRead.mjs';
19
19
  import { versionAtLeast } from './versionOrder.mjs';
20
20
  import { codexOrchestratorArgs } from './orchestratorHooks.mjs';
21
+ import { orchestratesPosture } from './orchestratorPrompt.mjs';
21
22
 
22
23
  /**
23
24
  * FLOWVIANT'S OWN TOOLS NEED NO APPROVAL FROM A CHAT NOBODY CAN ANSWER
@@ -84,8 +85,31 @@ export const NO_REACH_OUT = Object.freeze([
84
85
  */
85
86
  export const NO_WEB = Object.freeze(['-c', 'tools.web_search=false', '-c', 'web_search="disabled"']);
86
87
 
87
- /** Fenced lanes and non-card turns never delegate. */
88
- export const NO_SUBAGENTS = Object.freeze(['-c', 'features.multi_agent=false', '-c', 'features.goals=false']);
88
+ /** Fenced lanes and non-card turns never delegate — but an orchestrated
89
+ * research card, whose children read under the gate (2026-10-19).
90
+ * `features.multi_agent_v2=false` closes a person's own V2 switch, which
91
+ * beats every other key (measured on 0.163; harmless on 0.144). */
92
+ export const NO_SUBAGENTS = Object.freeze(['-c', 'features.multi_agent=false', '-c', 'features.goals=false', '-c', 'features.multi_agent_v2=false']);
93
+
94
+ /**
95
+ * …AND ON A CATALOG V2 MODEL, `agents.enabled=false` (2026-10-19). Measured on
96
+ * codex-cli 0.163.0, offline: `features.multi_agent=false` did not remove
97
+ * native subagents on gpt-6.1-sol, gpt-6-luna or gpt-5.6-sol, whose catalog
98
+ * entry names multi-agent V2 — a stock research turn listed `collaboration`
99
+ * and a spawn really started a child no gate saw (core/src/config/mod.rs:
100
+ * V2 if `features.multi_agent_v2`, else disabled only by `agents.enabled =
101
+ * false`, else the model's catalog version, and only then the feature).
102
+ * With the pair the namespace is gone and the call fails "unsupported call".
103
+ * `agents.enabled` first exists in 0.145.0: 0.144.x reads `[agents]` as a role
104
+ * map and refuses to start ("invalid type: boolean `false`, expected struct
105
+ * AgentRoleToml"), and there the catalog does not outrank the feature yet.
106
+ * So it rides only a CLI measured at 0.145.0 or later.
107
+ */
108
+ export const AGENTS_ENABLED_MIN = '0.145.0';
109
+ export const noSubagents = (codexVersion) => [...NO_SUBAGENTS,
110
+ ...(versionAtLeast(String(codexVersion ?? '').replace(/^codex-cli\s+/, ''), AGENTS_ENABLED_MIN) ? ['-c', 'agents.enabled=false'] : [])];
111
+ /** A card turn whose children the gate admits: native multi-agent on, goals off. */
112
+ export const CARD_SUBAGENTS = Object.freeze(['-c', 'features.multi_agent=true', '-c', 'features.goals=false']);
89
113
 
90
114
  /** rust-v0.160.0 core/src/agent/child_config.rs applies the invoking turn's
91
115
  * approval policy, permission profile and cwd AFTER role overrides, for V1
@@ -111,10 +135,11 @@ export const HERMETIC = Object.freeze(['--ignore-user-config', '--ignore-rules']
111
135
  * needs the isolated research read fence because broad-read tools can read
112
136
  * host credentials. Feature flags do not prove account/model entitlement.
113
137
  */
114
- export const fencedTail = ({ imageGeneration = false, web = false } = {}) => [
138
+ export const fencedTail = ({ imageGeneration = false, web = false, subagents = false, codexVersion } = {}) => [
115
139
  // Native web is paired with the copied, root-denied read fence.
116
140
  ...(web ? [] : NO_WEB),
117
- ...NO_SUBAGENTS,
141
+ // Only an orchestrated research card's fence admits children (CODEX_FENCES).
142
+ ...(subagents ? CARD_SUBAGENTS : noSubagents(codexVersion)),
118
143
  ...NO_REACH_OUT,
119
144
  imageGeneration ? '--enable' : '--disable',
120
145
  'image_generation',
@@ -165,14 +190,17 @@ export const codexIgnoresUserConfig = ({ profile = 'build', vaultDir } = {}) =>
165
190
  * config: `codex exec` (0.156.1) has no `-P`, so every Codex wiki turn was
166
191
  * refused before it started (0.114.0).
167
192
  */
168
- const codexWikiFence = (vaultDir) => [
193
+ const codexWikiFence = (vaultDir, codexVersion) => [
169
194
  '-c', 'permissions.flowviantwiki.extends=":read-only"',
170
195
  '-c', `permissions.flowviantwiki.filesystem={${JSON.stringify(vaultDir)}="write"}`,
171
196
  '-c', 'default_permissions="flowviantwiki"',
172
- ...fencedTail(),
197
+ ...fencedTail({ codexVersion }),
173
198
  ];
174
199
 
175
200
  function codexPrompt({ system, prompt, profile = 'build' }) {
201
+ // A resumed tab whose thread already holds the contract sends none
202
+ // (codexContract.mjs): the message alone.
203
+ if (!system) return prompt;
176
204
  const readers = profile === 'consult' || profile === 'plan'
177
205
  ? '\nUse exec_command with shell="/bin/bash" and login=false for project discovery. Project files, local Git history and the project knowledge directory are readable; host files are outside this turn’s read boundary.'
178
206
  : '';
@@ -284,8 +312,11 @@ export const CODEX_RUNTIME = {
284
312
  // THE FENCE, picked by the same keys as `codexIgnoresUserConfig`: a fenced
285
313
  // posture builds its own (CODEX_FENCES below); anything else is a build.
286
314
  const fenced = codexIgnoresUserConfig({ profile, vaultDir });
315
+ // THE GATE RIDES A CODE OR RESEARCH CARD on a CLI whose children inherit
316
+ // the parent's sandbox (2026-10-19, orchestratorPrompt.mjs).
317
+ const gated = orchestration && orchestratesPosture(fenced ? profile : 'build') && codexSubagentsSafe(codexVersion);
287
318
  if (fenced) {
288
- a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads }));
319
+ a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads, subagents: gated, codexVersion }));
289
320
  } else {
290
321
  // The daemon's build posture, mapped: SAFE keeps writes inside the
291
322
  // workspace, the default lets the agent run its own tests and git
@@ -300,9 +331,7 @@ export const CODEX_RUNTIME = {
300
331
  if (writableDirs.length) a.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`);
301
332
  a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
302
333
  if (cardTurn) a.push('-c', 'approval_policy="never"', ...(agentTools?.codexApprovals ?? []));
303
- a.push(...(cardTurn && profile === 'build' && codexSubagentsSafe(codexVersion)
304
- ? ['-c', 'features.multi_agent=true', '-c', 'features.goals=false']
305
- : NO_SUBAGENTS));
334
+ a.push(...(cardTurn && profile === 'build' && codexSubagentsSafe(codexVersion) ? CARD_SUBAGENTS : noSubagents(codexVersion)));
306
335
  }
307
336
 
308
337
  /**
@@ -318,7 +347,7 @@ export const CODEX_RUNTIME = {
318
347
  if (a.includes('--ignore-user-config')) a.push(...(personal?.args ?? []));
319
348
 
320
349
  a.push(...mcp, ...resultSchemaArgs);
321
- if (orchestration && !fenced && codexSubagentsSafe(codexVersion)) a.push(...codexOrchestratorArgs());
350
+ if (gated) a.push(...codexOrchestratorArgs());
322
351
  a.push(streamInput ? '-' : codexPrompt({ system, prompt, profile }));
323
352
  return a;
324
353
  },
@@ -398,7 +427,7 @@ export const CODEX_RUNTIME = {
398
427
  * Claude's research list was fenced to prevent. Codex research (0.117.0) runs
399
428
  * under its own fence instead, over a copied view (CODEX_FENCES `research`).
400
429
  */
401
- export function codexArtifactFence(cwd, { name, imageGeneration = false, what }) {
430
+ export function codexArtifactFence(cwd, { name, imageGeneration = false, what, codexVersion }) {
402
431
  if (!cwd || !isAbsolute(cwd)) {
403
432
  throw new Error(`${what} needs its worktree to fence — none reached codex's builder`);
404
433
  }
@@ -420,14 +449,14 @@ export function codexArtifactFence(cwd, { name, imageGeneration = false, what })
420
449
  '-c', `default_permissions="${name}"`,
421
450
  // Nobody is on this end of the pipe to approve an escalation.
422
451
  '-c', 'approval_policy="never"',
423
- ...fencedTail({ imageGeneration }),
452
+ ...fencedTail({ imageGeneration, codexVersion }),
424
453
  ];
425
454
  }
426
455
 
427
456
  /** The design card's fence — `codexArtifactFence` under its own profile name,
428
457
  * with native image generation available for supporting assets. */
429
- export const codexDesignFence = (cwd) =>
430
- codexArtifactFence(cwd, { name: 'flowviantdesign', imageGeneration: true, what: 'a design turn' });
458
+ export const codexDesignFence = (cwd, codexVersion) =>
459
+ codexArtifactFence(cwd, { name: 'flowviantdesign', imageGeneration: true, what: 'a design turn', codexVersion });
431
460
 
432
461
  /**
433
462
  * EVERY FENCED POSTURE'S ARGV, by the keys of CODEX_FENCED (a test holds the
@@ -437,7 +466,7 @@ const CODEX_FENCES = {
437
466
  // A CONSULT, EXPRESSED THE ONLY WAY CODEX CAN EXPRESS IT — a different
438
467
  // shape from Claude's verb allowlist, which is the whole reason `profile` is
439
468
  // a promise rather than a flag list. See codexProjectRead.mjs.
440
- consult: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail()],
469
+ consult: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ codexVersion: opts.codexVersion })],
441
470
  // A PLANNING SESSION. Read-only on the filesystem, exactly like a consult —
442
471
  // the writes it makes go through the control plane, not through this box —
443
472
  // and for the same reason: its prompt is steered by anything a project
@@ -446,8 +475,8 @@ const CODEX_FENCES = {
446
475
  // turn's scoped token. The plan token has the planning tools; a capture
447
476
  // token has only staging and transcript tools. The server refuses
448
477
  // everything outside that scope.
449
- plan: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail()],
450
- wiki: ({ vaultDir }) => codexWikiFence(vaultDir),
478
+ plan: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ codexVersion: opts.codexVersion })],
479
+ wiki: ({ vaultDir, codexVersion }) => codexWikiFence(vaultDir, codexVersion),
451
480
  // AN IMAGE CARD (0.114.0): read the repo, write ONLY the worktree's
452
481
  // `.flowviant/artifacts/`, generate pictures, and nothing else. MEASURED on
453
482
  // codex-cli 0.156.1 (Linux, bubblewrap), with no model turn spent: under
@@ -474,18 +503,21 @@ const CODEX_FENCES = {
474
503
  //
475
504
  // Native images support every artifact kind; this profile still delivers
476
505
  // PNG/WebP, while design/model/deck still deliver their self-contained page.
477
- image: ({ cwd }) => codexArtifactFence(cwd, { name: 'flowviantimage', imageGeneration: true, what: 'an image turn' }),
506
+ image: ({ cwd, codexVersion }) => codexArtifactFence(cwd, { name: 'flowviantimage', imageGeneration: true, what: 'an image turn', codexVersion }),
478
507
  // A MOCKUP, 3D-MODEL OR DECK CARD (0.115.0): the fence is
479
508
  // `codexDesignFence`'s, argued and measured at `codexArtifactFence` above.
480
- design: ({ cwd }) => codexDesignFence(cwd),
509
+ design: ({ cwd, codexVersion }) => codexDesignFence(cwd, codexVersion),
481
510
  // A WRITE-UP CARD (0.117.0): live web over a COPIED view of the worktree,
482
511
  // never the checkout or the login store, under a root-denied tool fence
483
512
  // (codexResearch.mjs `researchFence`, built by runTurn's
484
513
  // `runIsolatedResearch`). It is the one fence that keeps the web, so its
485
514
  // tail leaves NO_WEB out and closes everything else.
486
- research: ({ researchIsolation }) => {
515
+ // An orchestrated research card's children (2026-10-19) inherit this same
516
+ // permission profile, approval policy and cwd (0.160.0 child_config.rs);
517
+ // the gate refuses their writes.
518
+ research: ({ researchIsolation, subagents = false, codexVersion }) => {
487
519
  if (!researchIsolation) throw new Error('Codex research requires its isolated input copy.');
488
- return [...researchFence(researchIsolation), ...fencedTail({ web: true, imageGeneration: true })];
520
+ return [...researchFence(researchIsolation), ...fencedTail({ web: true, imageGeneration: true, subagents, codexVersion })];
489
521
  },
490
522
  };
491
523
  /** The fenced profile names — for the test that holds both tables to one set. */