flowviant 0.125.7 → 0.125.12

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 (65) hide show
  1. package/README.md +80 -1
  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 +48 -0
  6. package/bin/lib/agentPlan.mjs +14 -2
  7. package/bin/lib/agentTaskTypes.mjs +11 -7
  8. package/bin/lib/artifactContracts.mjs +8 -0
  9. package/bin/lib/artifactIteration.mjs +18 -0
  10. package/bin/lib/artifactReporter.mjs +16 -3
  11. package/bin/lib/claudePosture.mjs +21 -0
  12. package/bin/lib/claudeStream.mjs +25 -4
  13. package/bin/lib/codexResearch.mjs +35 -5
  14. package/bin/lib/commandSpecs.mjs +8 -5
  15. package/bin/lib/daemonJournal.mjs +102 -0
  16. package/bin/lib/daemonLogging.mjs +22 -18
  17. package/bin/lib/fleet.mjs +16 -6
  18. package/bin/lib/fleetReports.mjs +12 -5
  19. package/bin/lib/hooks/orchestrator.mjs +3 -1
  20. package/bin/lib/logReplay.mjs +133 -0
  21. package/bin/lib/nativeAgentTools.mjs +20 -3
  22. package/bin/lib/observedLines.mjs +63 -0
  23. package/bin/lib/orchestrator.mjs +40 -7
  24. package/bin/lib/orchestratorNative.mjs +133 -3
  25. package/bin/lib/orchestratorPrompt.mjs +51 -4
  26. package/bin/lib/postureTools.mjs +14 -6
  27. package/bin/lib/printable.mjs +1 -1
  28. package/bin/lib/prompts.mjs +122 -7
  29. package/bin/lib/repositoryDelivery.mjs +1 -1
  30. package/bin/lib/repositoryMerge.mjs +1 -1
  31. package/bin/lib/researchHandoff.mjs +144 -0
  32. package/bin/lib/researchHandoffText.mjs +3 -0
  33. package/bin/lib/researchKnowledge.mjs +60 -0
  34. package/bin/lib/researchRestoration.mjs +38 -0
  35. package/bin/lib/resultInputs.mjs +83 -0
  36. package/bin/lib/runTurn.mjs +76 -43
  37. package/bin/lib/runtimeClaude.mjs +9 -4
  38. package/bin/lib/runtimeCodex.mjs +51 -22
  39. package/bin/lib/runtimeEvents.mjs +17 -7
  40. package/bin/lib/testFindings.mjs +60 -0
  41. package/bin/lib/testFindingsContract.mjs +2 -0
  42. package/bin/lib/turnDiagnostics.mjs +2 -1
  43. package/bin/lib/turnOutput.mjs +63 -0
  44. package/bin/lib/turnRecord.mjs +11 -0
  45. package/bin/lib/turnSubagents.mjs +13 -3
  46. package/bin/lib/ui.mjs +2 -2
  47. package/bin/lib/uplinkScrub.mjs +49 -0
  48. package/bin/lib/views.mjs +49 -58
  49. package/bin/lib/wikiRunner.mjs +2 -2
  50. package/bin/lib/work.mjs +2 -2
  51. package/bin/lib/workActivity.mjs +65 -0
  52. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  53. package/bin/lib/workAgentMerges.mjs +57 -52
  54. package/bin/lib/workAgentPlans.mjs +76 -27
  55. package/bin/lib/workAgentPrecheck.mjs +1 -1
  56. package/bin/lib/workAgentTurnExecution.mjs +55 -9
  57. package/bin/lib/workAgentTurnOutcome.mjs +12 -3
  58. package/bin/lib/workAgentTurnReports.mjs +17 -3
  59. package/bin/lib/workAgentTurnShape.mjs +8 -5
  60. package/bin/lib/workAgentTurns.mjs +8 -6
  61. package/bin/lib/workIntake.mjs +8 -2
  62. package/bin/lib/workMergeBatch.mjs +7 -1
  63. package/bin/lib/workPlanRecovery.mjs +50 -0
  64. package/bin/lib/workSessionTurns.mjs +7 -2
  65. 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,7 +66,7 @@ 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
@@ -76,12 +81,14 @@ export function runTurn(opts) {
76
81
  // (codexResearch.mjs). After the teardown it copies nothing either: the
77
82
  // stand-down gate below is asked before the copy, not only before the spawn.
78
83
  if (opts.runtime === 'codex' && opts.profile === 'research')
79
- 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));
80
87
  return runPlainTurn(opts);
81
88
  }
82
89
 
83
90
  function runPlainTurn(opts) {
84
- 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;
85
92
  /**
86
93
  * AFTER THE TEARDOWN, NO TURN STARTS AND NO TURN ENDS (standDownGate.mjs).
87
94
  * A stand-down that drains a deploy keeps this process alive; without this
@@ -90,11 +97,19 @@ function runPlainTurn(opts) {
90
97
  * Both answers stay pending instead, as `process.exit` left them.
91
98
  */
92
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
+ };
93
106
  return new Promise((resolveTurn, rejectTurn) => {
94
107
  const resolve = (v) => {
108
+ output.close();
95
109
  if (!workLanesStopped()) resolveTurn(v);
96
110
  };
97
111
  const reject = (error) => {
112
+ output.close();
98
113
  if (!workLanesStopped()) rejectTurn(error);
99
114
  };
100
115
  const rt = runtimeById(runtime);
@@ -105,7 +120,7 @@ function runPlainTurn(opts) {
105
120
  // lane's `unsupported` check). Here the daemon log names the id and the
106
121
  // turn answers nothing — never Claude wearing the other runtime's name.
107
122
  if (!rt) {
108
- 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`);
109
124
  resolve('');
110
125
  return;
111
126
  }
@@ -116,7 +131,7 @@ function runPlainTurn(opts) {
116
131
  */
117
132
  const turnProfile = resolveTurnProfile(opts);
118
133
  if (turnProfile.error) {
119
- console.error(`\nerror: ${turnProfile.error}`);
134
+ output.error(`error: ${turnProfile.error}`);
120
135
  resolve('');
121
136
  return;
122
137
  }
@@ -131,7 +146,7 @@ function runPlainTurn(opts) {
131
146
  // adapter learns to express one this opens by itself.
132
147
  const offRuntime = turnProfile.onlyOn?.(rt);
133
148
  if (offRuntime) {
134
- console.error(`\nerror: ${offRuntime}`);
149
+ output.error(`error: ${offRuntime}`);
135
150
  resolve('');
136
151
  return;
137
152
  }
@@ -140,7 +155,7 @@ function runPlainTurn(opts) {
140
155
  // drive. Fail as a turn with no sentinel — the loop already treats that as
141
156
  // "the protocol did not complete" and retries, rather than inventing a
142
157
  // completion for work that never started.
143
- console.error(`\nerror: cannot run '${rt.label}' — ${rt.blocked}`);
158
+ output.error(`error: cannot run '${rt.label}' — ${rt.blocked}`);
144
159
  resolve('');
145
160
  return;
146
161
  }
@@ -371,28 +386,13 @@ function runPlainTurn(opts) {
371
386
  child.once('close', browser.cleanup);
372
387
  child.once('error', browser.cleanup);
373
388
  }
374
- if (child.pid) onSpawn?.(child);
389
+ if (child.pid) { output.event(`${rt.label} started`, 'start'); onSpawn?.(child); }
375
390
  // When this turn began, for the Codex rollout read: a `turn_context` older
376
391
  // than this is a previous turn's (turnModel.mjs).
377
392
  const spawnedAt = Date.now();
378
393
  let out = '';
379
394
  let stdinError = null;
380
395
  const input = rt.liveInput === true && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken, onContext: opts.onContext ?? null }) : null;
381
- const pfx = label ? `${label} ` : '';
382
- /**
383
- * THE ONE SINK FOR WHAT A CLI OR A MODEL WROTE (2026-10-10, the audit's
384
- * F133): activity labels — a Bash command, a Codex `say`, a file path —
385
- * and the CLI's raw stderr. A prompt-injected agent can put an OSC 52 in
386
- * a tool command as easily as in prose, and this wrote it to the
387
- * operator's terminal as-is. Scrubbed HERE, before the daemon's own
388
- * coloured `label` is added, so the prefix keeps its colour and nothing
389
- * the model wrote keeps an escape sequence (printable.mjs, `printableText`).
390
- */
391
- const emit = (s) => {
392
- const text = printableText(String(s));
393
- process.stdout.write(pfx ? text.replace(/\n/g, `\n${pfx}`) : text);
394
- };
395
-
396
396
  // EVERY TURN IS LINE-PARSED (2026-10-10, the audit). Claude's adapter
397
397
  // always asks for `--output-format stream-json` and every other runtime
398
398
  // brings its own `parse` — for Codex the JSONL stream is the only output
@@ -401,7 +401,6 @@ function runPlainTurn(opts) {
401
401
  // already drifted once (it called process.exit(1)); both are gone. A line
402
402
  // that is not JSON is still kept as text (claudeStream.mjs), and stderr
403
403
  // still lands in the haystack the sentinels read.
404
- let buf = '';
405
404
  const appendText = (t) => {
406
405
  out += t;
407
406
  };
@@ -419,13 +418,15 @@ function runPlainTurn(opts) {
419
418
  * waits on a file read. See runtimeLimits.mjs.
420
419
  */
421
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');
422
423
  recordClaudeRateLimit(info);
423
424
  onRateLimit?.(info);
424
425
  };
425
426
  // The native children this CLI starts, observed (turnSubagents.mjs).
426
427
  const natives = createTurnSubagents({ runtime: rt.id, cwd, since: spawnedAt, env: { ...process.env, ...(mcpEnv ?? {}) },
427
428
  owned: opts.orchestration === true, onSubagent: opts.onSubagent, onSpawnResult: opts.onSpawnResult, resumeThreadId,
428
- sinks: { emit, onActivity, onToolEvent, appendText } });
429
+ sinks: { emit, onActivity, onToolEvent, appendText, onDetail: output.detail } });
429
430
  let codexThread = rt.id === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
430
431
  /**
431
432
  * THE MODEL THIS SPAWN RAN ON, ONCE (2026-09-29, turnModel.mjs). Claude
@@ -459,10 +460,13 @@ function runPlainTurn(opts) {
459
460
  const taskPlan = new Map();
460
461
  /** One line of the child's stdout, in whichever dialect it speaks. */
461
462
  let cliFailed = false;
462
- 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);
463
466
  try {
464
467
  const event = JSON.parse(line);
465
468
  const parentEvent = event.parent_tool_use_id == null && !event.isSidechain;
469
+ opts.researchReceipt?.event(event);
466
470
  if (input && parentEvent) {
467
471
  input.event(event);
468
472
  if (event.type === 'system' && event.subtype === 'init' && event.session_id) opts.liveMessages.onInit(event.session_id);
@@ -478,7 +482,7 @@ function runPlainTurn(opts) {
478
482
  }
479
483
  } catch { /* ordinary non-JSON CLI output */ }
480
484
  if (!rt.parse)
481
- return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit: onInitAll, onUsage, onRateLimit: onRate, onModel: onRanOn, subagents: natives.collector, tasks: taskPlan });
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 }) });
482
486
  const ev = rt.parse(line, cwd);
483
487
  if (!ev) return;
484
488
  // The conversation id, when the runtime announces one (codex's
@@ -495,14 +499,17 @@ function runPlainTurn(opts) {
495
499
  // A receipt the controller cannot take never ends this line's handling,
496
500
  // as when the lane parsed the line again itself (2026-10-18).
497
501
  if (ev.spawnResult) try { opts.onSpawnResult?.(ev.spawnResult); } catch { /* the controller's own failure */ }
498
- if (ev.toolEvent) onToolEvent?.(...ev.toolEvent);
502
+ if (ev.toolEvent) toolObserved(...ev.toolEvent);
503
+ for (const tool of ev.toolEvents ?? []) toolObserved(...tool);
499
504
  if (ev.usage) onUsage?.(ev.usage);
500
505
  // One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
501
506
  // wins at the caller, which is what "the final answer" means.
502
507
  if (typeof ev.answer === 'string') onAnswer?.(ev.answer);
508
+ if (ev.detail) output.detail(ev.detail.text, { source: ev.detail.source, observedAt });
503
509
  if (ev.text) appendText(ev.text);
504
510
  if (ev.activity) {
505
- 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 });
506
513
  onActivity?.(ev.activity);
507
514
  }
508
515
  };
@@ -513,19 +520,18 @@ function runPlainTurn(opts) {
513
520
  // `setEncoding` holds the partial bytes over to the next chunk.
514
521
  child.stdout.setEncoding('utf8');
515
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' }));
516
526
  child.stdout.on('data', (d) => {
517
- buf += d;
518
- let nl;
519
- while ((nl = buf.indexOf('\n')) >= 0) {
520
- const line = buf.slice(0, nl);
521
- buf = buf.slice(nl + 1);
522
- if (line.trim()) onLine(line);
523
- }
527
+ if (ended) return;
528
+ stdoutLines.push(d);
524
529
  });
525
530
  // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
526
531
  child.stderr.on('data', (s) => {
532
+ if (ended) return;
527
533
  out += s;
528
- emit(s);
534
+ stderrLines.push(s);
529
535
  });
530
536
  /**
531
537
  * A SPAWN THAT FAILS FAILS THE TURN, NOT THE DAEMON — the one copy of the
@@ -541,32 +547,42 @@ function runPlainTurn(opts) {
541
547
  * this turn with the native reason; the lane records a failed report.
542
548
  */
543
549
  const onSpawnError = (e) => {
550
+ opts.researchReceipt?.fail();
544
551
  input?.close();
552
+ stdoutLines.end();
553
+ stderrLines.end();
545
554
  natives.abandon();
546
555
  if (opts.liveMessages) void opts.liveMessages.stop();
547
556
  finishDiagnostics({ pid: child.pid, spawnError: typeof e.code === 'string' ? e.code : 'SPAWN_ERROR' });
548
557
  releaseArgs();
549
558
  if (e.code === 'ENOENT') {
550
- 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}`);
551
560
  resolve('');
552
561
  return;
553
562
  }
554
- console.error(e);
563
+ output.error(e);
555
564
  reject(e);
556
565
  };
557
566
  child.on('error', onSpawnError);
558
- child.on('close', (code, signal) => {
567
+ const onClose = (code, signal) => {
568
+ if (ended) return;
569
+ ended = true;
559
570
  input?.close();
560
571
  releaseArgs();
561
572
  opts.onExit?.(code, signal);
562
- if (buf.trim()) onLine(buf);
573
+ stdoutLines.end();
574
+ stderrLines.end();
575
+ if (code !== 0 || signal) opts.researchReceipt?.fail();
563
576
  finishDiagnostics({ code, signal, pid: child.pid, output: out, cliFailed, inputError: stdinError?.code });
564
577
  const handBack = async () => {
565
578
  if (opts.liveMessages) await opts.liveMessages.stop();
566
579
  await natives.drain(CODEX_MODEL_READ_MS);
567
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');
568
583
  if (stdinError) {
569
- if (!workLanesStopped()) rejectTurn(stdinError);
584
+ output.error(stdinError);
585
+ reject(stdinError);
570
586
  return;
571
587
  }
572
588
  resolve(out);
@@ -598,12 +614,29 @@ function runPlainTurn(opts) {
598
614
  return;
599
615
  }
600
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));
601
633
  });
602
634
  if (transport.text !== null) {
603
635
  // end() buffers with backpressure and closes at EOF; Codex reads the
604
636
  // entire prompt before starting. An early close must not accept output
605
637
  // from a turn whose complete authorized input never reached the CLI.
606
638
  child.stdin.on('error', (error) => {
639
+ opts.researchReceipt?.fail('input_error');
607
640
  stdinError = Object.assign(new Error(`${rt.label} could not receive the complete turn input (${error.code || 'PIPE_ERROR'}).`), { code: error.code || 'PIPE_ERROR' });
608
641
  opts.onCliFailure?.();
609
642
  child.kill('SIGTERM');
@@ -16,9 +16,10 @@ 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';
22
23
  import { versionAtLeast } from './versionOrder.mjs';
23
24
 
24
25
  /** The release the agent lane first measured `--system-prompt-snapshot` on
@@ -122,7 +123,7 @@ export const CLAUDE_RUNTIME = {
122
123
  * strongest form of it available anywhere: `--append-system-prompt` sits
123
124
  * above the conversation rather than inside it.
124
125
  */
125
- 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, claudeVersion = null }) {
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 }) {
126
127
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
127
128
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
128
129
  // with no vault, or one no rule can name, has no list — refused here in
@@ -131,7 +132,7 @@ export const CLAUDE_RUNTIME = {
131
132
  if (profile === 'wiki' && !vaultDir) {
132
133
  throw new Error("a wiki turn needs its vault to fence — none reached claude's builder");
133
134
  }
134
- if (profile === 'wiki' && !Array.isArray(perm)) {
135
+ if (profile === 'wiki' && !Array.isArray(postureList)) {
135
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`);
136
137
  }
137
138
  const a = [];
@@ -190,6 +191,10 @@ export const CLAUDE_RUNTIME = {
190
191
  * agent renders the CURRENT base snapshot, not the first turn's cache.
191
192
  */
192
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;
193
198
  if (fenced) a.push(...NO_SETTING_SOURCES);
194
199
  // A FENCED POSTURE NAMES ITS OWN MODE (2026-10-10). Its lists fence only
195
200
  // under the CLI's `default` mode, where a tool the list does not name is
@@ -274,7 +279,7 @@ export const CLAUDE_RUNTIME = {
274
279
  const mounted = machine ? withMachineMcp(perm, machineMcp) : perm;
275
280
  // The orchestrator's hooks join that same `--settings`: the CLI applies
276
281
  // only the last one it is handed (claudeSettingsArg.mjs `withSettings`).
277
- const postured = orchestration && !fenced ? withSettings(mounted, { hooks: orchestratorHooks(), disableAllHooks: false }) : mounted;
282
+ const postured = gated ? withSettings(mounted, { hooks: orchestratorHooks(), disableAllHooks: false }) : mounted;
278
283
  let tail;
279
284
  try {
280
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,11 +190,11 @@ 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' }) {
@@ -287,8 +312,11 @@ export const CODEX_RUNTIME = {
287
312
  // THE FENCE, picked by the same keys as `codexIgnoresUserConfig`: a fenced
288
313
  // posture builds its own (CODEX_FENCES below); anything else is a build.
289
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);
290
318
  if (fenced) {
291
- a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads }));
319
+ a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads, subagents: gated, codexVersion }));
292
320
  } else {
293
321
  // The daemon's build posture, mapped: SAFE keeps writes inside the
294
322
  // workspace, the default lets the agent run its own tests and git
@@ -303,9 +331,7 @@ export const CODEX_RUNTIME = {
303
331
  if (writableDirs.length) a.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`);
304
332
  a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
305
333
  if (cardTurn) a.push('-c', 'approval_policy="never"', ...(agentTools?.codexApprovals ?? []));
306
- a.push(...(cardTurn && profile === 'build' && codexSubagentsSafe(codexVersion)
307
- ? ['-c', 'features.multi_agent=true', '-c', 'features.goals=false']
308
- : NO_SUBAGENTS));
334
+ a.push(...(cardTurn && profile === 'build' && codexSubagentsSafe(codexVersion) ? CARD_SUBAGENTS : noSubagents(codexVersion)));
309
335
  }
310
336
 
311
337
  /**
@@ -321,7 +347,7 @@ export const CODEX_RUNTIME = {
321
347
  if (a.includes('--ignore-user-config')) a.push(...(personal?.args ?? []));
322
348
 
323
349
  a.push(...mcp, ...resultSchemaArgs);
324
- if (orchestration && !fenced && codexSubagentsSafe(codexVersion)) a.push(...codexOrchestratorArgs());
350
+ if (gated) a.push(...codexOrchestratorArgs());
325
351
  a.push(streamInput ? '-' : codexPrompt({ system, prompt, profile }));
326
352
  return a;
327
353
  },
@@ -401,7 +427,7 @@ export const CODEX_RUNTIME = {
401
427
  * Claude's research list was fenced to prevent. Codex research (0.117.0) runs
402
428
  * under its own fence instead, over a copied view (CODEX_FENCES `research`).
403
429
  */
404
- export function codexArtifactFence(cwd, { name, imageGeneration = false, what }) {
430
+ export function codexArtifactFence(cwd, { name, imageGeneration = false, what, codexVersion }) {
405
431
  if (!cwd || !isAbsolute(cwd)) {
406
432
  throw new Error(`${what} needs its worktree to fence — none reached codex's builder`);
407
433
  }
@@ -423,14 +449,14 @@ export function codexArtifactFence(cwd, { name, imageGeneration = false, what })
423
449
  '-c', `default_permissions="${name}"`,
424
450
  // Nobody is on this end of the pipe to approve an escalation.
425
451
  '-c', 'approval_policy="never"',
426
- ...fencedTail({ imageGeneration }),
452
+ ...fencedTail({ imageGeneration, codexVersion }),
427
453
  ];
428
454
  }
429
455
 
430
456
  /** The design card's fence — `codexArtifactFence` under its own profile name,
431
457
  * with native image generation available for supporting assets. */
432
- export const codexDesignFence = (cwd) =>
433
- 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 });
434
460
 
435
461
  /**
436
462
  * EVERY FENCED POSTURE'S ARGV, by the keys of CODEX_FENCED (a test holds the
@@ -440,7 +466,7 @@ const CODEX_FENCES = {
440
466
  // A CONSULT, EXPRESSED THE ONLY WAY CODEX CAN EXPRESS IT — a different
441
467
  // shape from Claude's verb allowlist, which is the whole reason `profile` is
442
468
  // a promise rather than a flag list. See codexProjectRead.mjs.
443
- consult: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail()],
469
+ consult: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ codexVersion: opts.codexVersion })],
444
470
  // A PLANNING SESSION. Read-only on the filesystem, exactly like a consult —
445
471
  // the writes it makes go through the control plane, not through this box —
446
472
  // and for the same reason: its prompt is steered by anything a project
@@ -449,8 +475,8 @@ const CODEX_FENCES = {
449
475
  // turn's scoped token. The plan token has the planning tools; a capture
450
476
  // token has only staging and transcript tools. The server refuses
451
477
  // everything outside that scope.
452
- plan: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail()],
453
- wiki: ({ vaultDir }) => codexWikiFence(vaultDir),
478
+ plan: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ codexVersion: opts.codexVersion })],
479
+ wiki: ({ vaultDir, codexVersion }) => codexWikiFence(vaultDir, codexVersion),
454
480
  // AN IMAGE CARD (0.114.0): read the repo, write ONLY the worktree's
455
481
  // `.flowviant/artifacts/`, generate pictures, and nothing else. MEASURED on
456
482
  // codex-cli 0.156.1 (Linux, bubblewrap), with no model turn spent: under
@@ -477,18 +503,21 @@ const CODEX_FENCES = {
477
503
  //
478
504
  // Native images support every artifact kind; this profile still delivers
479
505
  // PNG/WebP, while design/model/deck still deliver their self-contained page.
480
- 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 }),
481
507
  // A MOCKUP, 3D-MODEL OR DECK CARD (0.115.0): the fence is
482
508
  // `codexDesignFence`'s, argued and measured at `codexArtifactFence` above.
483
- design: ({ cwd }) => codexDesignFence(cwd),
509
+ design: ({ cwd, codexVersion }) => codexDesignFence(cwd, codexVersion),
484
510
  // A WRITE-UP CARD (0.117.0): live web over a COPIED view of the worktree,
485
511
  // never the checkout or the login store, under a root-denied tool fence
486
512
  // (codexResearch.mjs `researchFence`, built by runTurn's
487
513
  // `runIsolatedResearch`). It is the one fence that keeps the web, so its
488
514
  // tail leaves NO_WEB out and closes everything else.
489
- 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 }) => {
490
519
  if (!researchIsolation) throw new Error('Codex research requires its isolated input copy.');
491
- return [...researchFence(researchIsolation), ...fencedTail({ web: true, imageGeneration: true })];
520
+ return [...researchFence(researchIsolation), ...fencedTail({ web: true, imageGeneration: true, subagents, codexVersion })];
492
521
  },
493
522
  };
494
523
  /** The fenced profile names — for the test that holds both tables to one set. */
@@ -29,12 +29,13 @@ const oneLine = (s, n = 140) =>
29
29
  * terminal console and a one-line pulse that is overwritten every two seconds,
30
30
  * and neither can show a paragraph. The turn TRACE is a third consumer with the
31
31
  * opposite need: it is scrollback, and clipping a sentence at 140 characters
32
- * there is the product summarizing its own agent.
32
+ * there is the product summarizing its own agent. Errors also use `full` in
33
+ * the native daemon console (2026-10-19), preserving their words and lines.
33
34
  *
34
35
  * So a prose activity may ALSO carry `full`: the text EXACTLY as the CLI
35
- * emitted it, uncollapsed and unclipped. Nothing derives it and nothing but the
36
- * trace reads it — `label` is untouched, so the console and the pulse are
37
- * byte-identical to before. Absent means there was no fuller text than the
36
+ * emitted it, uncollapsed and unclipped. Nothing derives it. The trace and
37
+ * the console's errors read it; routine console activity and the pulse still
38
+ * read `label`. Absent means there was no fuller text than the
38
39
  * label (a tool line, a bare thinking marker), and the trace falls back to the
39
40
  * label — which is what every pre-0.87.0 daemon does for everything.
40
41
  *
@@ -367,7 +368,14 @@ function codexEventOf(ev, cwd) {
367
368
  exitCode: item.exit_code, isError: item.status === 'failed',
368
369
  output: item.aggregated_output ?? '',
369
370
  }] : null;
370
- return { activity, text, ...(toolEvent ? { toolEvent } : {}), ...(isMessage ? { answer: String(item.text ?? item.message ?? '') } : {}) };
371
+ const mcp = (item.item_type ?? item.type) === 'mcp_tool_call';
372
+ const toolEvents = mcp ? [
373
+ ...(item.arguments !== undefined ? [[`${item.server ?? 'mcp'}.${item.tool ?? ''}`, item.arguments, { id: item.id, runtime: 'codex' }]] : []),
374
+ [null, {}, { id: item.id, result: true, runtime: 'codex', isError: item.status === 'failed',
375
+ output: item.error?.message ?? (item.result === undefined ? '' : JSON.stringify(item.result)),
376
+ detail: item.error?.message ?? item.result ?? '' }],
377
+ ] : [];
378
+ return { activity, text, ...(toolEvent ? { toolEvent } : {}), ...(toolEvents.length ? { toolEvents } : {}), ...(isMessage ? { answer: String(item.text ?? item.message ?? '') } : {}) };
371
379
  }
372
380
  // THE TURN'S OWN TOKEN COUNT (2026-09-24). `codex exec --json` closes a
373
381
  // turn with `turn.completed` carrying `usage` — the codex twin of Claude's
@@ -500,18 +508,19 @@ export function parseAgyLine(line, cwd) {
500
508
  const ti = su.tool_info;
501
509
  if (!ti) return null;
502
510
  const err = ti.error?.message;
511
+ const toolEvent = ti.name && ti.parameters !== undefined ? [ti.name, ti.parameters, { runtime: 'antigravity', ...(ti.id ? { id: ti.id } : {}) }] : null;
503
512
  // Same rule as codex's error lines: the label is the console's, `full` is
504
513
  // the trace's, and a tool error's message is exactly the kind of sentence a
505
514
  // 140-char clamp cuts the cause out of.
506
515
  if (err)
507
516
  return {
508
517
  activity: { kind: 'error', label: oneLine(err), full: String(err) },
509
- text: '',
518
+ ...(toolEvent ? { toolEvent } : {}), text: '',
510
519
  };
511
520
  // Each tool is reported twice — once ACTIVE, once DONE — so only the
512
521
  // terminal state emits, otherwise every action appears in the thread twice.
513
522
  if (su.state && su.state !== 'DONE') return null;
514
- return { activity: humanizeAgyTool(ti.name, ti.parameters ?? {}, cwd), text: '' };
523
+ return { activity: humanizeAgyTool(ti.name, ti.parameters ?? {}, cwd), ...(toolEvent ? { toolEvent } : {}), text: '' };
515
524
  }
516
525
  if (ev.event === 'result') {
517
526
  const r = ev.result ?? {};
@@ -521,6 +530,7 @@ export function parseAgyLine(line, cwd) {
521
530
  activity: r.error
522
531
  ? { kind: 'error', label: oneLine(r.error), full: String(r.error) }
523
532
  : null,
533
+ ...(typeof r.response === 'string' && r.response ? { detail: { source: 'answer', text: r.response } } : {}),
524
534
  text: `${r.response ?? ''}${r.error ? `\n${r.error}` : ''}\n`,
525
535
  };
526
536
  }