flowviant 0.125.1 → 0.125.3

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.
@@ -0,0 +1,47 @@
1
+ // A whole-event memory guard, aligned with the authenticated API's 24 MiB
2
+ // request budget. It is not an allowance on a prompt or tool-result field.
3
+ export const ORCHESTRATOR_REQUEST_BYTES = 24 * 1024 * 1024;
4
+ export const ORCHESTRATOR_INPUT_TOO_LARGE = 'ORCHESTRATOR_INPUT_TOO_LARGE';
5
+
6
+ export class OrchestratorInputTooLarge extends Error {
7
+ constructor() {
8
+ super('The orchestration hook request exceeds the 24 MiB transport limit.');
9
+ this.code = ORCHESTRATOR_INPUT_TOO_LARGE;
10
+ }
11
+ }
12
+
13
+ /** Read bounded raw bytes before decoding: pipe/HTTP chunks can split UTF-8.
14
+ * On refusal, drain without buffering so HTTP can still return its reason. */
15
+ export function readOrchestratorInput(stream) {
16
+ return new Promise((resolve, reject) => {
17
+ let chunks = [], bytes = 0;
18
+ const cleanup = () => {
19
+ stream.off('data', data); stream.off('end', end);
20
+ stream.off('error', fail); stream.off('close', closed);
21
+ };
22
+ const fail = error => {
23
+ cleanup(); chunks = [];
24
+ // The producer can abort after seeing the refusal. That late stream
25
+ // error must not crash the controller while the remainder is drained.
26
+ const ignore = () => {};
27
+ stream.on('error', ignore);
28
+ stream.once('close', () => stream.off('error', ignore));
29
+ stream.resume(); reject(error);
30
+ };
31
+ const closed = () => fail(new Error('The orchestration input closed before completion.'));
32
+ const data = chunk => {
33
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
34
+ bytes += buffer.length;
35
+ if (bytes > ORCHESTRATOR_REQUEST_BYTES) { fail(new OrchestratorInputTooLarge()); return; }
36
+ chunks.push(buffer);
37
+ };
38
+ const end = () => {
39
+ cleanup();
40
+ try { resolve(JSON.parse(Buffer.concat(chunks, bytes).toString('utf8'))); }
41
+ catch (error) { reject(error); }
42
+ chunks = [];
43
+ };
44
+ stream.on('data', data); stream.once('end', end);
45
+ stream.once('error', fail); stream.once('close', closed);
46
+ });
47
+ }
@@ -0,0 +1,19 @@
1
+ export const ORCHESTRATOR_CONTRACT = `CARD ORCHESTRATOR — this applies to this card's one CLI and worktree.
2
+ You are the model the person chose and remain responsible for the card and its final JSON.
3
+ Work yourself when the card spans one area. Only split work spanning several areas into native in-process subagents by kind (Backend/UI/Tests…). This is authorization to use those native agents on this card. Give each a concrete title, prompt and kind, and post short updates of your own. Do not create extra branches or Flowviant agents. Use a native agent type that can call card_orchestrator MCP (Claude general-purpose); keep the work yourself if none can. After spawning, use describe_agent to report the kind and title you chose when its native role is generic.
4
+
5
+ The daemon gates every native spawn against this project's Turns at once pool, shared with its other turns. Claude children run in the background so you remain available for messages and their questions; Codex spawns are already asynchronous. A refused spawn with a waiting id has not started. Keep working yourself; do not wait for it. When notified of a free slot, retry its native spawn with the same prompt. Never bypass this gate with another CLI process. The orchestrator itself also holds a slot. If no slot becomes available, finish the work yourself.
6
+
7
+ The card_orchestrator MCP tools report routing and child questions. A live person’s note has a Message id. Report each note using route_message:
8
+ - Handle it yourself: taken.
9
+ - Delegate it: queued, with the native agent id, its kind, and the safePoint chosen FOR THIS NOTE: "when it finishes", "after its current edit", or a precise stated checkpoint.
10
+ - When it finishes: wait for that child's completion, then put the note in its next native task/follow-up.
11
+ - After its current edit: the gate pauses further tool use at the next boundary. Use the native stop/interrupt tool, preserve its work so far, and resume it or start a follow-up with that context and the note. Do not interrupt in the middle of an edit.
12
+ - Only after native delivery at that checkpoint: handed with that agent id/kind/safePoint. This appends "From {orchestrator} …" to its stream. Never report a handoff before doing it.
13
+ Use the tools' agent record id when one is supplied by a native spawn gate. Keep a later native id with that record.
14
+
15
+ All child prompts must say: share this worktree, preserve others' edits, return questions to the orchestrator with agent_question, read its answer before continuing, never escalate directly to the person, and do not commit independently of the card's orchestrator. Answer child questions from the card, code and earlier notes with answer_agent when possible. If only the person can decide, mark escalate with answer_agent, then use the existing blocked JSON question card naming the asking child. Leave that child asked until answered. When the person answers in a resumed run, call answer_agent with the earlier agentId, nativeAgentId, kind/title and question from your conversation plus their answer; then give it to that same child through a native follow-up. Never guess a missing identity.
16
+
17
+ Urgent is the person's explicit exception. The daemon uses the whole-turn interrupt/resume fallback when a child cannot be targeted reliably; the same stored note becomes the resumed prompt and reads "interrupted the run". Stop ends the whole run. Do not keep working or hand off notes after Stop.
18
+
19
+ Claude receives notes through its native live stdin. Codex exec cannot accept stdin mid-turn: on supported code-card CLIs, the daemon injects pending notes through the next native tool hook boundary in the same turn. During a tool-free answer or on fenced/older Codex turns, the note remains for the next run; do not claim it was already read. No new model is involved.`;
@@ -0,0 +1,15 @@
1
+ /** The run-scoped MCP vocabulary; safe to load in a credential-free client. */
2
+ export const ORCHESTRATOR_TOOLS = [
3
+ { name: 'register_agent', description: 'A native child calls this first with the agent record id in its prompt, before working. It binds native events to that record.',
4
+ inputSchema: { type: 'object', properties: { agentId: { type: 'string' } }, required: ['agentId'], additionalProperties: false } },
5
+ { name: 'describe_agent', description: 'The orchestrator reports the kind and title it chose for a child when the native role is generic. Use Backend, UI, Tests or the actual area of work.',
6
+ inputSchema: { type: 'object', properties: { agentId: { type: 'string' }, kind: { type: 'string' }, title: { type: 'string' } }, required: ['agentId', 'kind', 'title'], additionalProperties: false } },
7
+ { name: 'route_message', description: 'Report where you routed a person’s live note. Choose its safe point per note. Report handed only after native delivery at that checkpoint; taken means you handled it yourself.',
8
+ inputSchema: { type: 'object', properties: { messageId: { type: 'string' }, state: { enum: ['queued', 'handed', 'taken'] },
9
+ agentId: { type: 'string' }, kind: { type: 'string' }, safePoint: { type: 'string' } }, required: ['messageId', 'state'], additionalProperties: false } },
10
+ { name: 'agent_question', description: 'A child asks its orchestrator before asking the person. Call again to read an answer. The orchestrator answers from the card/code/earlier notes or escalates a real person-only decision.',
11
+ inputSchema: { type: 'object', properties: { agentId: { type: 'string' }, question: { type: 'string' } }, required: ['agentId', 'question'], additionalProperties: false } },
12
+ { name: 'answer_agent', description: 'Answer a child question from known facts, or mark it for escalation to the person. An escalation returns the attribution to put on the existing blocked question card.',
13
+ inputSchema: { type: 'object', properties: { agentId: { type: 'string' }, answer: { type: 'string' }, escalate: { type: 'boolean' },
14
+ question: { type: 'string' }, nativeAgentId: { type: 'string' }, kind: { type: 'string' }, title: { type: 'string' } }, required: ['agentId'], additionalProperties: false } },
15
+ ];
@@ -0,0 +1,34 @@
1
+ /** Move complete prompts off argv's OS byte limits. Keep each adapter's
2
+ * assembled framing verbatim; stdin carries the very string argv held.
3
+ * Claude's separate system prompt uses its native file flag. */
4
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ export function promptTransport(runtime, original, stdinText = null) {
9
+ const args = [...original];
10
+ let text = null;
11
+ let dir = null;
12
+ try {
13
+ if (runtime === 'codex') {
14
+ text = stdinText ?? args[args.length - 1];
15
+ args[args.length - 1] = '-';
16
+ } else if (runtime === 'claude') {
17
+ if (!args.includes('--input-format')) {
18
+ const at = args.indexOf('-p');
19
+ text = args.splice(at + 1, 1)[0];
20
+ }
21
+ const at = args.indexOf('--append-system-prompt');
22
+ if (at !== -1) {
23
+ dir = mkdtempSync(join(tmpdir(), 'flowviant-system-'));
24
+ const file = join(dir, 'system.txt');
25
+ writeFileSync(file, args[at + 1], { mode: 0o600 });
26
+ args.splice(at, 2, '--append-system-prompt-file', file);
27
+ }
28
+ }
29
+ return { args, text, cleanup: () => { if (dir) rmSync(dir, { recursive: true, force: true }); } };
30
+ } catch (error) {
31
+ if (dir) rmSync(dir, { recursive: true, force: true });
32
+ throw error;
33
+ }
34
+ }
@@ -10,6 +10,7 @@
10
10
  * (runtimeEvents.mjs).
11
11
  */
12
12
 
13
+ import { promptTransport } from './promptTransport.mjs';
13
14
  import { runIsolatedResearch } from './codexResearch.mjs';
14
15
  import { spawn } from 'node:child_process';
15
16
  import { runtimeById } from './runtimes.mjs';
@@ -24,6 +25,11 @@ import { codexTurnModel } from './turnModel.mjs';
24
25
  import { printableText } from './printable.mjs';
25
26
  import { startTurnDiagnostics } from './turnDiagnostics.mjs';
26
27
  import { createClaudeInput } from './claudeInput.mjs';
28
+ import { createClaudeSubagents } from './claudeSubagents.mjs';
29
+ import { createCodexSubagentReader } from './codexSubagents.mjs';
30
+ import { measuredRuntimeVersion } from './runtimeDetection.mjs';
31
+ import { createClaudeSubagentProgress } from './claudeSubagentProgress.mjs';
32
+ import { codexSpawnResult } from './codexSubagentEvents.mjs';
27
33
 
28
34
  /** How long a Codex turn's answer may wait on its rollout's model line. */
29
35
  const CODEX_MODEL_READ_MS = 1_500;
@@ -88,6 +94,9 @@ function runPlainTurn(opts) {
88
94
  const resolve = (v) => {
89
95
  if (!workLanesStopped()) resolveTurn(v);
90
96
  };
97
+ const reject = (error) => {
98
+ if (!workLanesStopped()) rejectTurn(error);
99
+ };
91
100
  const rt = runtimeById(runtime);
92
101
  // AN UNKNOWN RUNTIME IS REFUSED, NEVER RUN AS CLAUDE (2026-09-26). Asked
93
102
  // before anything is spawned. This is the belt, not the relay: the words
@@ -169,6 +178,10 @@ function runPlainTurn(opts) {
169
178
  // to get wrong: the caller named one profile (turnProfile.mjs).
170
179
  const stdinText = rt.stdin?.({ prompt, system, profile: turnProfile.adapterProfile }) ?? null;
171
180
  const args = rt.args({
181
+ cardTurn: opts.cardTurn === true,
182
+ orchestration: opts.orchestration === true,
183
+ codexVersion: measuredRuntimeVersion('codex')?.version,
184
+
172
185
  streamInput: stdinText !== null || (rt.id === 'claude' && Boolean(opts.liveMessages)),
173
186
  writableDirs: opts.writableDirs,
174
187
  personal,
@@ -255,10 +268,15 @@ function runPlainTurn(opts) {
255
268
  * so no throw on the way leaves that file behind: the sweep takes only a
256
269
  * dead process's leftovers, and this one names the daemon's own pid.
257
270
  */
271
+ let transport;
272
+ try { transport = promptTransport(rt.id, args, stdinText); }
273
+ catch (error) { rt.releaseArgs?.(args); throw error; }
274
+ args.splice(0, args.length, ...transport.args);
258
275
  let argsReleased = false;
259
276
  const releaseArgs = () => {
260
277
  if (argsReleased) return;
261
278
  argsReleased = true;
279
+ transport.cleanup();
262
280
  try {
263
281
  rt.releaseArgs?.(args);
264
282
  } catch {
@@ -278,11 +296,11 @@ function runPlainTurn(opts) {
278
296
  browserHome: browser?.dir,
279
297
  });
280
298
  finishDiagnostics = startTurnDiagnostics({ runtime, bin: rt.bin, env: childEnv,
281
- cwd, args, stdinBytes: stdinText === null ? 0 : Buffer.byteLength(stdinText),
299
+ cwd, args, stdinBytes: transport.text === null ? 0 : Buffer.byteLength(transport.text),
282
300
  profile: turnProfile.name, planId: opts.diagnosticPlanId });
283
301
  child = spawn(rt.bin, args, {
284
302
  cwd,
285
- stdio: [stdinText !== null || (rt.id === 'claude' && opts.liveMessages) ? 'pipe' : 'ignore', 'pipe', 'pipe'],
303
+ stdio: [transport.text !== null || (rt.id === 'claude' && opts.liveMessages) ? 'pipe' : 'ignore', 'pipe', 'pipe'],
286
304
  /**
287
305
  * ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
288
306
  *
@@ -324,7 +342,7 @@ function runPlainTurn(opts) {
324
342
  child.once('close', browser.cleanup);
325
343
  child.once('error', browser.cleanup);
326
344
  }
327
- onSpawn?.(child);
345
+ if (child.pid) onSpawn?.(child);
328
346
  // When this turn began, for the Codex rollout read: a `turn_context` older
329
347
  // than this is a previous turn's (turnModel.mjs).
330
348
  const spawnedAt = Date.now();
@@ -375,7 +393,25 @@ function runPlainTurn(opts) {
375
393
  recordClaudeRateLimit(info);
376
394
  onRateLimit?.(info);
377
395
  };
396
+ const agentStates = new Map();
397
+ const reportSubagent = (update) => {
398
+ if (!update?.id || (!agentStates.has(update.id) && agentStates.size >= 32)) return;
399
+ agentStates.set(update.id, update.status ?? agentStates.get(update.id) ?? 'running');
400
+ opts.onSubagent?.(update);
401
+ };
402
+ const subagents = createClaudeSubagents(reportSubagent);
403
+ const claudeProgress = rt.id === 'claude' && opts.onSubagent ? createClaudeSubagentProgress({
404
+ cwd, since: spawnedAt, env: { ...process.env, ...(mcpEnv ?? {}) },
405
+ onLine: line => handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText,
406
+ answerFromResult: true, subagents }),
407
+ }) : null;
408
+ const codexAgents = rt.id === 'codex' && opts.onSubagent ? createCodexSubagentReader({
409
+ since: spawnedAt, env: { ...process.env, ...(mcpEnv ?? {}) },
410
+ onSubagent: reportSubagent, onActivity, onToolEvent,
411
+ onSpawnResult: opts.onSpawnResult,
412
+ }) : null;
378
413
  let codexThread = rt.id === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
414
+ if (codexThread) codexAgents?.thread(codexThread);
379
415
  /**
380
416
  * THE MODEL THIS SPAWN RAN ON, ONCE (2026-09-29, turnModel.mjs). Claude
381
417
  * names it in its init event and again on every reply; the first valid
@@ -399,6 +435,7 @@ function runPlainTurn(opts) {
399
435
  */
400
436
  const harvest = initHarvestFor(rt, args);
401
437
  const onInitAll = (i) => {
438
+ claudeProgress?.session(i?.sessionId);
402
439
  if (harvest.skills) recordSkills(i?.skills);
403
440
  if (harvest.mcp) recordMcpServers(i?.mcpServers, i?.tools);
404
441
  onInit?.(i);
@@ -408,7 +445,8 @@ function runPlainTurn(opts) {
408
445
  const onLine = (line) => {
409
446
  try {
410
447
  const event = JSON.parse(line);
411
- if (input) {
448
+ const parentEvent = event.parent_tool_use_id == null && !event.isSidechain;
449
+ if (input && parentEvent) {
412
450
  input.event(event);
413
451
  if (event.type === 'system' && event.subtype === 'init' && event.session_id) opts.liveMessages.onInit(event.session_id);
414
452
  // A message written just before result may become a second native
@@ -416,26 +454,30 @@ function runPlainTurn(opts) {
416
454
  // structured objects joined together; every result still reports spend.
417
455
  if (event.type === 'result') out = '';
418
456
  }
419
- if ((event.type === 'result' && event.is_error === true) ||
420
- event.type === 'error' || event.type === 'turn.failed') {
457
+ if (parentEvent && ((event.type === 'result' && event.is_error === true) ||
458
+ event.type === 'error' || event.type === 'turn.failed')) {
421
459
  cliFailed = true;
422
460
  opts.onCliFailure?.();
423
461
  }
424
462
  } catch { /* ordinary non-JSON CLI output */ }
425
463
  if (!rt.parse)
426
- return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit: onInitAll, onUsage, onRateLimit: onRate, onModel: onRanOn });
464
+ return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit: onInitAll, onUsage, onRateLimit: onRate, onModel: onRanOn, subagents });
427
465
  const ev = rt.parse(line, cwd);
428
466
  if (!ev) return;
429
467
  // The conversation id, when the runtime announces one (codex's
430
468
  // thread.started). Purely additive: callers that pass no onThreadId —
431
469
  // every dispatch path — see zero behavior change.
432
470
  if (ev.threadId) {
433
- if (rt.id === 'codex') codexThread = ev.threadId;
471
+ if (rt.id === 'codex') { codexThread = ev.threadId; codexAgents?.thread(ev.threadId); }
434
472
  onThreadId?.(ev.threadId);
435
473
  }
436
474
  // The runtime's own token count (codex's `turn.completed`) — the same
437
475
  // `onUsage` Claude's result event feeds, so a caller charges a turn
438
476
  // identically whichever CLI ran it.
477
+ for (const update of ev.subagents ?? []) codexAgents?.update(update);
478
+ if (rt.id === 'codex') {
479
+ try { const item = JSON.parse(line)?.item; const result = codexSpawnResult(item); if (result) opts.onSpawnResult?.(result); } catch { /* non-JSON */ }
480
+ }
439
481
  if (ev.toolEvent) onToolEvent?.(...ev.toolEvent);
440
482
  if (ev.usage) onUsage?.(ev.usage);
441
483
  // One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
@@ -478,11 +520,13 @@ function runPlainTurn(opts) {
478
520
  * legitimately, and the wiki/plan-check/consult turns still ask for Claude
479
521
  * by default. On such a machine the first wiki sweep would have killed the
480
522
  * whole daemon, taking every in-flight build with it, because one
481
- * background job could not find one binary. Any other spawn error
482
- * resolves what was captured so far.
523
+ * background job could not find one binary. Other spawn errors reject
524
+ * this turn with the native reason; the lane records a failed report.
483
525
  */
484
526
  const onSpawnError = (e) => {
485
527
  input?.close();
528
+ void codexAgents?.stop();
529
+ void claudeProgress?.stop();
486
530
  if (opts.liveMessages) void opts.liveMessages.stop();
487
531
  finishDiagnostics({ pid: child.pid, spawnError: typeof e.code === 'string' ? e.code : 'SPAWN_ERROR' });
488
532
  releaseArgs();
@@ -492,7 +536,7 @@ function runPlainTurn(opts) {
492
536
  return;
493
537
  }
494
538
  console.error(e);
495
- resolve(out);
539
+ reject(e);
496
540
  };
497
541
  child.on('error', onSpawnError);
498
542
  child.on('close', (code, signal) => {
@@ -503,6 +547,17 @@ function runPlainTurn(opts) {
503
547
  finishDiagnostics({ code, signal, pid: child.pid, output: out, cliFailed, inputError: stdinError?.code });
504
548
  const handBack = async () => {
505
549
  if (opts.liveMessages) await opts.liveMessages.stop();
550
+ // Native transcript reads are readouts too: a stuck filesystem must
551
+ // not hold the card's answer. Readers stop scheduling before draining.
552
+ let progressTimer;
553
+ try {
554
+ await Promise.race([
555
+ Promise.all([codexAgents?.stop(), claudeProgress?.stop()]),
556
+ new Promise(resolve => { progressTimer = setTimeout(resolve, CODEX_MODEL_READ_MS); }),
557
+ ]);
558
+ } finally { clearTimeout(progressTimer); }
559
+ for (const [id, status] of agentStates)
560
+ if (status === 'running' || status === 'waiting') reportSubagent({ id, status: 'stopped' });
506
561
  if (stdinError) {
507
562
  if (!workLanesStopped()) rejectTurn(stdinError);
508
563
  return;
@@ -537,7 +592,7 @@ function runPlainTurn(opts) {
537
592
  }
538
593
  handBack();
539
594
  });
540
- if (stdinText !== null) {
595
+ if (transport.text !== null) {
541
596
  // end() buffers with backpressure and closes at EOF; Codex reads the
542
597
  // entire prompt before starting. An early close must not accept output
543
598
  // from a turn whose complete authorized input never reached the CLI.
@@ -546,7 +601,7 @@ function runPlainTurn(opts) {
546
601
  opts.onCliFailure?.();
547
602
  child.kill('SIGTERM');
548
603
  });
549
- child.stdin.end(stdinText, 'utf8');
604
+ child.stdin.end(transport.text, 'utf8');
550
605
  }
551
606
  if (input) {
552
607
  const initial = opts.liveMessages.initialPrompt(prompt, resumeThreadId, resume, opts.liveContinuation === true);
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
  import { MODEL, USER_AGENT } from './config.mjs';
17
17
  import { NO_SETTING_SOURCES, personalClaudeSettings, releaseTurnSettings, withPersonalSettings } from './claudePersonal.mjs';
18
18
  import { withMachineMcp } from './claudePosture.mjs';
19
+ import { orchestratorHooks } from './orchestratorHooks.mjs';
19
20
 
20
21
  /** The profiles an agent lane mounts the machine's MCP servers on (2026-10-11):
21
22
  * the ones whose turn profile says `projectMcp` and that Claude declares. */
@@ -101,7 +102,7 @@ export const CLAUDE_RUNTIME = {
101
102
  * strongest form of it available anywhere: `--append-system-prompt` sits
102
103
  * above the conversation rather than inside it.
103
104
  */
104
- args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, streamInput = false }) {
105
+ args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, streamInput = false, orchestration = false }) {
105
106
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
106
107
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
107
108
  // with no vault, or one no rule can name, has no list — refused here in
@@ -169,6 +170,7 @@ export const CLAUDE_RUNTIME = {
169
170
  * agent renders the CURRENT base snapshot, not the first turn's cache.
170
171
  */
171
172
  const fenced = profile !== 'build';
173
+ if (orchestration && !fenced) a.push('--settings', JSON.stringify({ hooks: orchestratorHooks(), disableAllHooks: false }));
172
174
  if (fenced) a.push(...NO_SETTING_SOURCES);
173
175
  // A FENCED POSTURE NAMES ITS OWN MODE (2026-10-10). Its lists fence only
174
176
  // under the CLI's `default` mode, where a tool the list does not name is
@@ -16,6 +16,8 @@ import { ARTIFACT_DIR, FENCE_TMP_DIR } from './artifacts.mjs';
16
16
  import { codexPersonalTurn } from './codexPersonal.mjs';
17
17
  import { researchFence } from './codexResearch.mjs';
18
18
  import { projectReadBoundary, projectReadFence } from './codexProjectRead.mjs';
19
+ import { compareVersions } from './versionOrder.mjs';
20
+ import { codexOrchestratorArgs } from './orchestratorHooks.mjs';
19
21
 
20
22
  /**
21
23
  * FLOWVIANT'S OWN TOOLS NEED NO APPROVAL FROM A CHAT NOBODY CAN ANSWER
@@ -78,9 +80,18 @@ export const NO_REACH_OUT = Object.freeze([
78
80
  */
79
81
  export const NO_WEB = Object.freeze(['-c', 'tools.web_search=false', '-c', 'web_search="disabled"']);
80
82
 
81
- /** Sub-agents would be a second turn whose posture nobody here chose. */
83
+ /** Fenced lanes and non-card turns never delegate. */
82
84
  export const NO_SUBAGENTS = Object.freeze(['-c', 'features.multi_agent=false', '-c', 'features.goals=false']);
83
85
 
86
+ /** rust-v0.160.0 core/src/agent/child_config.rs applies the invoking turn's
87
+ * approval policy, permission profile and cwd AFTER role overrides, for V1
88
+ * and V2. Older/unmeasured binaries have no inheritance evidence here. */
89
+ export const CODEX_SUBAGENTS_MIN = '0.160.0';
90
+ export function codexSubagentsSafe(version) {
91
+ const order = compareVersions(String(version ?? '').replace(/^codex-cli\s+/, ''), CODEX_SUBAGENTS_MIN);
92
+ return order === 0 || order === 1;
93
+ }
94
+
84
95
  /**
85
96
  * HERMETIC. Without these a user's ~/.codex/config.toml, a project `.rules`
86
97
  * execpolicy file, or their own MCP servers can widen a posture we are
@@ -239,7 +250,7 @@ export const CODEX_RUNTIME = {
239
250
  * limit even when its argv plus environment is far below ARG_MAX. Both exec
240
251
  * and exec resume support `-` (verified on Codex 0.160.0, 2026-10-16).
241
252
  */
242
- args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads }) {
253
+ args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, codexVersion, orchestration = false }) {
243
254
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
244
255
  // agy moves) — codex has no adoptable store wired yet. Reaching here
245
256
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -285,6 +296,10 @@ export const CODEX_RUNTIME = {
285
296
  // before it started. `-c sandbox_mode=` means the same on both.
286
297
  if (writableDirs.length) a.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`);
287
298
  a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
299
+ if (cardTurn) a.push('-c', 'approval_policy="never"');
300
+ a.push(...(cardTurn && profile === 'build' && codexSubagentsSafe(codexVersion)
301
+ ? ['-c', 'features.multi_agent=true', '-c', 'features.goals=false']
302
+ : NO_SUBAGENTS));
288
303
  }
289
304
 
290
305
  /**
@@ -300,6 +315,7 @@ export const CODEX_RUNTIME = {
300
315
  if (a.includes('--ignore-user-config')) a.push(...(personal?.args ?? []));
301
316
 
302
317
  a.push(...mcp, ...resultSchemaArgs);
318
+ if (orchestration && !fenced && codexSubagentsSafe(codexVersion)) a.push(...codexOrchestratorArgs());
303
319
  a.push(streamInput ? '-' : codexPrompt({ system, prompt, profile }));
304
320
  return a;
305
321
  },
@@ -14,6 +14,8 @@
14
14
  * own stream loop and calls `humanizeClaudeTool`/`toolEventOf` from it.
15
15
  */
16
16
 
17
+ import { codexCollabUpdates } from './codexSubagentEvents.mjs';
18
+
17
19
  /** Truncate for a one-line activity label. */
18
20
  const oneLine = (s, n = 140) =>
19
21
  String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
@@ -139,6 +141,11 @@ export function countLines(s) {
139
141
  * window minus the cap to straddle out of it — no real credential is. */
140
142
  const SCRUB_WINDOW = 8_192;
141
143
 
144
+ /** The same window/scrub/cap order for native agent metadata and tools. */
145
+ export function scrubNativeText(value, cap, scrub) {
146
+ return scrub(String(value ?? '').slice(0, SCRUB_WINDOW)).slice(0, cap);
147
+ }
148
+
142
149
  /**
143
150
  * Tool-call → ONE STRUCTURED EVENT for the transcript's tool cards — the
144
151
  * relay's durable form, where `humanizeClaudeTool` above is its one-line live
@@ -165,7 +172,7 @@ const SCRUB_WINDOW = 8_192;
165
172
  export function toolEventOf(name, input = {}, cwd = '', scrub = (s) => s) {
166
173
  // Window → scrub → cap. The window bounds what a multi-MB input costs; the
167
174
  // cap is applied AFTER the scrub so a replacement cannot overflow it.
168
- const clean = (v, cap) => scrub(String(v ?? '').slice(0, SCRUB_WINDOW)).slice(0, cap);
175
+ const clean = (v, cap) => scrubNativeText(v, cap, scrub);
169
176
  const rel = (p) => clean(shortPath(p, cwd), 300);
170
177
  // The first k lines of a side, from a scrubbed bounded prefix — never a
171
178
  // full split of the raw string.
@@ -197,6 +204,7 @@ export function toolEventOf(name, input = {}, cwd = '', scrub = (s) => s) {
197
204
  return { t: 'glob', q: clean(input.pattern, 200) };
198
205
  case 'Bash':
199
206
  return { t: 'bash', c: clean(input.command, 200) };
207
+ case 'Agent':
200
208
  case 'Task':
201
209
  return { t: 'task', q: clean(input.description, 200) };
202
210
  case 'TodoWrite': {
@@ -313,6 +321,9 @@ export function parseCodexLine(line, cwd) {
313
321
  } catch {
314
322
  return null; // not every line is JSON (warnings go to stderr, but be safe)
315
323
  }
324
+ if (['item.started', 'item.updated', 'item.completed'].includes(ev.type) &&
325
+ (ev.item?.type ?? ev.item?.item_type) === 'collab_tool_call')
326
+ return { subagents: codexCollabUpdates(ev.item), activity: null, text: '' };
316
327
  switch (ev.type) {
317
328
  // The conversation's own id, announced before any item (`ThreadStarted` on
318
329
  // the shipped 0.147 binary, like the measured events below). Surfaced so a
package/bin/lib/trace.mjs CHANGED
@@ -59,7 +59,7 @@
59
59
  * tail of a trace, never the settle behind it.
60
60
  */
61
61
 
62
- import { THINK_MARKER } from './runtimeEvents.mjs';
62
+ import { THINK_MARKER, scrubNativeText } from './runtimeEvents.mjs';
63
63
  import { withoutTurnDeclaration } from './turnDeclaration.mjs';
64
64
 
65
65
  /** One batch every two seconds — the discipline the tab's narrator keeps, for
@@ -107,7 +107,7 @@ function newRunId() {
107
107
  * with no such route, a body the server will never accept). Resolving falsy
108
108
  * keeps them queued for the next flush.
109
109
  */
110
- export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run = newRunId() }) {
110
+ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run = newRunId(), orchestrationVersion }) {
111
111
  /** Entries not yet delivered. `base` is the ABSOLUTE index of queue[0] in
112
112
  * THIS RUN's stream, so `base + queue.length` is everything this relay has
113
113
  * ever accepted — sent, queued or shed. Absolute for the TURN is the
@@ -119,7 +119,9 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
119
119
  * empties the queue and a batch boundary is not a change in the stream. Read
120
120
  * by the bare-marker collapse in `prose()` and nothing else. */
121
121
  let lastEntry = null;
122
- let dirty = false;
122
+ const agentReadings = new Map();
123
+ let dirty = orchestrationVersion === 2;
124
+ let markerPending = dirty;
123
125
  let timer = null;
124
126
  let stopped = false;
125
127
  /** The await chain. Every flush appends to it, so batches leave in order
@@ -149,7 +151,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
149
151
 
150
152
  const drain = async (deadlineMs) => {
151
153
  const until = deadlineMs > 0 ? Date.now() + deadlineMs : 0;
152
- while (queue.length) {
154
+ while (queue.length || markerPending) {
153
155
  if (until && Date.now() > until) {
154
156
  dirty = true;
155
157
  return;
@@ -159,7 +161,9 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
159
161
  dirty = false;
160
162
  let ok = false;
161
163
  try {
162
- ok = (await post({ agentId, turnId, run, seq, entries })) !== false;
164
+ ok = (await post({ agentId, turnId, run, seq, entries,
165
+ ...(orchestrationVersion === 2 ? { orchestrationVersion } : {}),
166
+ })) !== false;
163
167
  } catch {
164
168
  ok = false;
165
169
  }
@@ -169,6 +173,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
169
173
  dirty = true;
170
174
  return;
171
175
  }
176
+ markerPending = false;
172
177
  // Remove only what of the batch is STILL at the head: a push during the
173
178
  // await may have shed some of it already (advancing `base`), and
174
179
  // splicing the whole batch length would then drop entries that were
@@ -188,7 +193,11 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
188
193
  return done;
189
194
  };
190
195
 
196
+ // The first report stamps even a silent or immediately failed CLI invocation.
197
+ schedule();
198
+ const scoped = (id) => id ? `${run}:${scrubNativeText(id, 96, scrub)}` : undefined;
191
199
  return {
200
+ agentId: scoped,
192
201
  /**
193
202
  * A prose line from the stream. `kind` is the daemon's activity vocabulary
194
203
  * (runtimeEvents.mjs); anything that is not thinking or the model speaking is a
@@ -207,7 +216,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
207
216
  * CLI's own stdout, and a secret in a thought must not ride further than it
208
217
  * did when a thought was 300 characters.
209
218
  */
210
- prose(kind, text) {
219
+ prose(kind, text, agentId) {
211
220
  const readable = kind === 'think' ? String(text ?? '') : withoutTurnDeclaration(text);
212
221
  const t = scrub(readable)
213
222
  // One line ending, whatever the CLI printed.
@@ -239,10 +248,10 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
239
248
  * never takes a seq, exactly like the empty line above it. The "N earlier
240
249
  * steps are not shown" count stays a count of steps that existed.
241
250
  */
242
- if (k === 'think' && t === THINK_MARKER && lastEntry?.k === 'think' && lastEntry.t === t) {
251
+ if (k === 'think' && t === THINK_MARKER && lastEntry?.k === 'think' && lastEntry.t === t && lastEntry.agentId === scoped(agentId)) {
243
252
  return readable;
244
253
  }
245
- push({ k, t });
254
+ push({ k, t, ...(agentId ? { agentId: scoped(agentId) } : {}) });
246
255
  // The activity pulse uses this same filtering; its original compact
247
256
  // label stands unless protocol text was removed from the full message.
248
257
  return readable;
@@ -250,13 +259,42 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
250
259
  /** One structured tool event, exactly as `toolEventOf` built it. A tool this
251
260
  * builder does not know returns null there and nothing is pushed here — a
252
261
  * card is never invented. */
253
- tool(e) {
254
- if (e && typeof e === 'object') push({ k: 'tool', e });
262
+ tool(e, agentId) {
263
+ if (e && typeof e === 'object') push({ k: 'tool', e, ...(agentId ? { agentId: scoped(agentId) } : {}) });
264
+ },
265
+ agent(update) {
266
+ if (!update?.id) return;
267
+ const clean = (v, n) => scrubNativeText(v, n, scrub);
268
+ const fields = {};
269
+ for (const [key, cap] of Object.entries({ kind: 80, title: 200, now: 400, brief: 4_000 }))
270
+ if (typeof update[key] === 'string') fields[key] = clean(update[key], cap);
271
+ if (typeof fields.now === 'string') fields.now = fields.now.replace(/\s+/g, ' ').trim();
272
+ if (['running', 'waiting', 'asked', 'stopped', 'done'].includes(update.status)) fields.status = update.status;
273
+ const previous = agentReadings.get(update.id) ?? {};
274
+ if (agentReadings.has(update.id) && Object.entries(fields).every(([k, v]) => previous[k] === v)) return;
275
+ if (!agentReadings.has(update.id) && agentReadings.size >= 32) return;
276
+ const reading = { ...previous, ...fields };
277
+ agentReadings.set(update.id, reading);
278
+ // A later update repairs metadata whose first report was shed while
279
+ // offline; a delta alone cannot recover the child's prompt or title.
280
+ push({ k: 'agent', id: scoped(update.id), ...reading });
281
+ },
282
+ /** Routing is declared by the orchestrator; no server interpretation. */
283
+ handoff(agentId, from, text) {
284
+ push({ k: 'handoff', agentId: scoped(agentId), from: scrub(String(from)).slice(0, 160), t: scrub(String(text)).slice(0, TRACE_PROSE_CAP) });
285
+ },
286
+ doing(agentId, text) {
287
+ push({ k: 'doing', agentId: scoped(agentId), t: scrub(String(text)).slice(0, TRACE_PROSE_CAP) });
255
288
  },
256
289
  flush,
257
290
  /** No more entries, no more timers. The queue survives so a final flush can
258
291
  * still deliver it. */
259
292
  stop() {
293
+ if (!stopped && orchestrationVersion === 2) {
294
+ // Even a quiet child retains its identity after another child's noisy
295
+ // stream overflowed the relay. These snapshots remain sequence-safe.
296
+ for (const [id, reading] of agentReadings) push({ k: 'agent', id: scoped(id), ...reading });
297
+ }
260
298
  stopped = true;
261
299
  if (timer) {
262
300
  clearTimeout(timer);