flowviant 0.125.30 → 0.125.33

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 (43) hide show
  1. package/bin/lib/agentPlan.mjs +8 -0
  2. package/bin/lib/agentQueueContext.mjs +6 -0
  3. package/bin/lib/agentSplit.mjs +90 -0
  4. package/bin/lib/architectureContract.mjs +57 -0
  5. package/bin/lib/architectureReport.mjs +38 -0
  6. package/bin/lib/artifactContracts.mjs +21 -2
  7. package/bin/lib/claudeInput.mjs +1 -1
  8. package/bin/lib/claudeSubagents.mjs +46 -6
  9. package/bin/lib/claudeWorkflows.mjs +94 -0
  10. package/bin/lib/cliLimit.mjs +5 -0
  11. package/bin/lib/codexInput.mjs +191 -0
  12. package/bin/lib/codexSubagentEvents.mjs +3 -1
  13. package/bin/lib/codexSubagents.mjs +34 -9
  14. package/bin/lib/conversationOutput.mjs +14 -0
  15. package/bin/lib/crossCliChild.mjs +327 -0
  16. package/bin/lib/crossCliLaunch.mjs +92 -0
  17. package/bin/lib/crossCliPosture.mjs +55 -0
  18. package/bin/lib/crossCliResearch.mjs +19 -0
  19. package/bin/lib/fixtures/workflow-ultracode.jsonl +22 -0
  20. package/bin/lib/nativeAgentTools.mjs +12 -5
  21. package/bin/lib/orchestrator.mjs +24 -7
  22. package/bin/lib/orchestratorClient.mjs +8 -2
  23. package/bin/lib/orchestratorPrompt.mjs +13 -5
  24. package/bin/lib/orchestratorProtocol.mjs +18 -0
  25. package/bin/lib/outsideCli.mjs +124 -0
  26. package/bin/lib/prompts.mjs +7 -5
  27. package/bin/lib/runTurn.mjs +30 -8
  28. package/bin/lib/runtimeClaude.mjs +7 -3
  29. package/bin/lib/runtimeCodex.mjs +12 -6
  30. package/bin/lib/runtimeLimits.mjs +59 -1
  31. package/bin/lib/splitGate.mjs +115 -0
  32. package/bin/lib/subagentBounds.mjs +26 -0
  33. package/bin/lib/subagentLedger.mjs +110 -0
  34. package/bin/lib/trace.mjs +134 -6
  35. package/bin/lib/turnDeclaration.mjs +4 -4
  36. package/bin/lib/turnGaps.mjs +6 -2
  37. package/bin/lib/turnSubagents.mjs +23 -9
  38. package/bin/lib/wikiDraft.mjs +9 -0
  39. package/bin/lib/workAgentResume.mjs +11 -2
  40. package/bin/lib/workAgentTurnExecution.mjs +31 -7
  41. package/bin/lib/workAgentTurnOutcome.mjs +11 -0
  42. package/bin/lib/workAgentTurnReports.mjs +5 -2
  43. package/package.json +1 -1
@@ -11,9 +11,11 @@ import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs'
11
11
  import { OrchestratorInputTooLarge, readOrchestratorInput } from './orchestratorInput.mjs';
12
12
  import { SAFE_POINT_AFTER_EDIT as AFTER_EDIT } from './orchestratorProtocol.mjs';
13
13
  import { MACHINE_MESSAGE_ROUTE_STATES, MESSAGE_ROUTE_FIELD_MAX, OPEN_MESSAGE_ROUTE_STATES, ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
14
- import { captureToolName, isChildWriteTool, isClaudeStopTool, isDiscoveryTool, isFollowupTool, isPersonQuestionTool, isShellTool, isSpawnTool } from './nativeAgentTools.mjs';
14
+ import { captureToolName, crossCliToolName, isChildWriteTool, isClaudeStopTool, isDiscoveryTool, isFollowupTool, isPersonQuestionTool, isShellTool, isSpawnTool } from './nativeAgentTools.mjs';
15
15
  import { childBrain, childChoice, followupTarget, readOnlyShell, readSpawn, spawnReceipt, withChildContract } from './orchestratorNative.mjs';
16
16
  import { SETTLED_SUBAGENT_STATUSES, SUBAGENT_CAP, SUBAGENT_FIELD_CAPS } from './subagentBounds.mjs';
17
+ import { readOnlyLookup, splitHold } from './splitGate.mjs';
18
+ import { createCrossCliChildren, CROSS_CLI_ACTIONS, CROSS_CLI_RESULT_WAIT_MAX_S } from './crossCliChild.mjs';
17
19
 
18
20
  const WAIT = 'Waiting for a free slot on the machine';
19
21
  // A research card's children read inside its fence; the orchestrator alone writes and delivers.
@@ -35,7 +37,7 @@ const text = (s, n) => scrubNativeText(s, n, scrub);
35
37
  * on Codex, only for a model in `catalog`, the measured spawnable models.
36
38
  */
37
39
  export async function createOrchestrator({ turnId, runtime, trace, admit, request = fleetRequest, notify = () => false, taken = () => {},
38
- interval = 500, hold = 60_000, clock = Date.now, scrubText = text, children = {}, readOnlyRun = null, signal, rootTools = null, assignmentContext = () => '' } = {}) {
40
+ interval = 500, hold = 60_000, clock = Date.now, scrubText = text, children = {}, readOnlyRun = null, signal, rootTools = null, assignmentContext = () => '', crossCli = null, listen = listenHeldTool } = {}) {
39
41
  // Consults belong to a held attempt, never to a card turn. This capability
40
42
  // is entirely local; it cannot report routes or borrow an agent principal.
41
43
  if (readOnlyRun && (!['plan', 'capture'].includes(readOnlyRun.scope) || !filled(readOnlyRun.id) ||
@@ -73,11 +75,16 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
73
75
  const emit = update => {
74
76
  const id = resolve(update.id), prior = records.get(id) ?? { id };
75
77
  const reading = { ...prior, ...update, id };
78
+ // A nested child's parent is named natively; the trace knows its record.
79
+ if (typeof update.parent === 'string' && update.parent !== 'lead') reading.parent = resolve(update.parent);
76
80
  if (reading.namedKind) reading.kind = reading.namedKind;
77
81
  if (reading.namedTitle) reading.title = reading.namedTitle;
78
82
  records.set(id, reading); trace.agent(reading); return reading;
79
83
  };
80
84
  const release = id => { const a = records.get(resolve(id)); a?.release?.(); if (a) a.release = null; };
85
+ // Subagents on another CLI (crossCliChild.mjs): a code card's lead only.
86
+ const others = crossCli && !readOnlyRun ? createCrossCliChildren({ lead: runtime, ...crossCli, access, card, split: children.split ?? null,
87
+ emit, count: () => records.size, admit, announce: m => announce(m), interval }) : null;
81
88
  // A wake the root CLI did not take (stdin closed; Codex always) waits for
82
89
  // the root's next tool hook, where both CLIs read additionalContext.
83
90
  const announce = message => {
@@ -152,6 +159,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
152
159
  const spawn = readSpawn(event);
153
160
  if (!spawn) return { deny: 'Give this native spawn a text prompt or message so the run can gate it.' };
154
161
  const { callId, brief } = spawn;
162
+ // A child that may edit files waits for the person's yes (splitGate.mjs).
163
+ const held = splitHold(children.split ?? null, event, spawn, { runtime, access });
164
+ if (held) return { deny: held };
155
165
  // Above the card's model or effort is refused before any record or slot.
156
166
  const chosen = childBrain(event, spawn, card);
157
167
  if (chosen.deny) return { deny: chosen.deny };
@@ -166,6 +176,8 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
166
176
  if (!slot(id)) { a.denied = resumed ? 'resume' : 'spawn'; return { deny: `${WAIT}. Agent id: ${id}. Keep working yourself; you will be told when this spawn can be retried. Do not wait for a child that has not started.` }; }
167
177
  // Claude's SendMessage addresses a child by its Agent `name`, Codex V2 by its task name.
168
178
  for (const name of spawn.names) if (filled(name) && name.length <= 160) aliases.set(name, id);
179
+ // Admitted unasked as a read-only lookup: its file writes are refused below.
180
+ if (readOnlyLookup(children.split ?? null, event, spawn, access)) a.readOnly = true;
169
181
  start(a, a.title);
170
182
  // A cheaper model or lower effort than the card's is relayed on the child's
171
183
  // stream, and the parent is asked to say why in its own update.
@@ -303,11 +315,13 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
303
315
  if (!records.has(id)) return { deny: sealedRun ? `That names no agent in this run.${childContract()}` : 'Use the agent record id in your prompt.' };
304
316
  bind(child, id);
305
317
  }
318
+ if (child && crossCliToolName(ev.tool_name)) return { deny: 'Only the card’s lead starts or continues a subagent on another CLI. Return your work to it.' };
306
319
  // A sealed child that missed (or never got) its start's words reads
307
320
  // them here, whole: an earlier run's follow-up, or a CLI without the hook.
308
321
  if (child && !records.has(resolve(child)) && !isDiscoveryTool(ev.tool_name))
309
322
  return { deny: sealedRun ? `Register with your orchestrator before working.${childContract()}` : 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
310
323
  if ((child && access === 'read' || readOnlyRun) && isChildWriteTool(ev.tool_name)) return { deny: readOnlyRun ? 'This attempt only reads: no file writes.' : READ_ONLY_REFUSAL };
324
+ if (child && records.get(resolve(child))?.readOnly && isChildWriteTool(ev.tool_name)) return { deny: 'You started as a read-only lookup: no file writes. Return your findings to the orchestrator.' };
311
325
  if (rootTools && captureToolName(ev.tool_name)) {
312
326
  if (child || !rootSession || ev.session_id !== rootSession) return { deny: 'Only the top-level capture coordinator may call capture tools. Return findings to it.' };
313
327
  return rootTools.authorize(ev);
@@ -340,6 +354,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
340
354
  return held.length ? { ...result, context: [result.context, ...held].filter(Boolean).join('\n\n') } : result;
341
355
  }
342
356
  if (action === 'route_message') return route(args);
357
+ if (CROSS_CLI_ACTIONS.includes(action)) { if (!others) throw new Error('This run cannot start a subagent on another CLI.'); return others.handle(action, args); }
343
358
  const id = named(args.agentId);
344
359
  // A person’s answer starts a new CLI turn. The parent carries the earlier
345
360
  // question and native identity in its own conversation; relay that known
@@ -396,13 +411,13 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
396
411
  };
397
412
  let listener;
398
413
  try {
399
- listener = await listenHeldTool({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: 5_000,
414
+ listener = await listen({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: others ? (CROSS_CLI_RESULT_WAIT_MAX_S + 30) * 1000 : 5_000,
400
415
  capability, isClosed: ended, respond });
401
416
  } catch (error) { forgetExtraSecrets(secretKey); throw error; }
402
417
  const { server, env: heldEnv, args: argv } = listener;
403
418
  // The hook reads it to refuse a research child's write while this is down.
404
419
  const env = { ...heldEnv, FLOWVIANT_ORCHESTRATOR_CHILDREN: access,
405
- ...(readOnlyRun ? { FLOWVIANT_ORCHESTRATOR_READ_ONLY: '1' } : {}) };
420
+ ...(readOnlyRun ? { FLOWVIANT_ORCHESTRATOR_READ_ONLY: '1' } : {}), ...(others?.offered().length ? { FLOWVIANT_ORCHESTRATOR_CROSS_CLI: others.offered().join(',') } : {}) };
406
421
  const timer = setInterval(() => {
407
422
  if (ended() || polling) return;
408
423
  polling = true;
@@ -426,7 +441,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
426
441
  } finally { polling = false; }
427
442
  }, interval);
428
443
  timer.unref?.();
429
- return { env, config: { command: process.execPath, args: argv, env }, handle,
444
+ // Codex's own MCP tool timeout sits above a result call's longest wait.
445
+ return { env, config: { command: process.execPath, args: argv, env, ...(others ? { toolTimeoutSec: CROSS_CLI_RESULT_WAIT_MAX_S + 30 } : {}) }, handle,
446
+ crossCli: () => others?.offered() ?? [],
430
447
  root(id) { if (typeof id === 'string') rootSession = id; },
431
448
  resolve,
432
449
  spawnResult(result) {
@@ -460,10 +477,10 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
460
477
  async close(stopped = false) {
461
478
  if (closed) return;
462
479
  closed = true; clearInterval(timer);
463
- rootTools?.close();
480
+ rootTools?.close(); others?.close();
464
481
  // Only an escalated question waits for the person; any other asked child ended with the run.
465
482
  for (const a of records.values()) { release(a.id); const q = questions.get(a.id);
466
- if (!terminal(a.status) && !(a.status === 'asked' && !readOnlyRun && !stopped && q?.escalated && !q.answer)) emit({ id: a.id, status: 'stopped', now: stopped ? 'Stopped with the run' : 'Run ended' }); }
483
+ if (!terminal(a.status) && !(!stopped && others?.asking(a.id)) && !(a.status === 'asked' && !readOnlyRun && !stopped && q?.escalated && !q.answer)) emit({ id: a.id, status: 'stopped', now: stopped ? 'Stopped with the run' : 'Run ended' }); }
467
484
  server.close(); server.closeAllConnections();
468
485
  forgetExtraSecrets(secretKey);
469
486
  },
@@ -1,4 +1,4 @@
1
- import { ORCHESTRATOR_TOOLS } from './orchestratorProtocol.mjs';
1
+ import { CROSS_CLI_TOOLS, ORCHESTRATOR_TOOLS } from './orchestratorProtocol.mjs';
2
2
  import { ORCHESTRATOR_REQUEST_BYTES, ORCHESTRATOR_INPUT_TOO_LARGE, OrchestratorInputTooLarge } from './orchestratorInput.mjs';
3
3
  import { serveMcpStdio } from './mcpStdio.mjs';
4
4
 
@@ -25,6 +25,12 @@ export async function callOrchestrator(action, args, timeoutMs = ORCHESTRATOR_CL
25
25
  return result;
26
26
  }
27
27
 
28
+ /** A result call waits up to its own waitSeconds (crossCliChild.mjs), past the usual bound. */
29
+ export const callTimeoutFor = (action, args) => action === 'cli_agent_result'
30
+ ? (Math.min(Math.max(Number(args?.waitSeconds) || 60, 0), 300) + 15) * 1000 : ORCHESTRATOR_CLIENT_TIMEOUT_MS;
31
+
28
32
  export function orchestratorMcpMain() {
29
- return serveMcpStdio({ serverInfo: { name: 'card-orchestrator', version: '2' }, tools: ORCHESTRATOR_TOOLS, call: callOrchestrator });
33
+ // Includes stop_cli_agent; all four child actions relay through this capability.
34
+ const tools = process.env.FLOWVIANT_ORCHESTRATOR_CROSS_CLI ? [...ORCHESTRATOR_TOOLS, ...CROSS_CLI_TOOLS] : ORCHESTRATOR_TOOLS;
35
+ return serveMcpStdio({ serverInfo: { name: 'card-orchestrator', version: '2' }, tools, call: (action, args) => callOrchestrator(action, args, callTimeoutFor(action, args)) });
30
36
  }
@@ -1,5 +1,6 @@
1
1
  import { TESTS_SCOPE } from './testFindingsContract.mjs';
2
2
  import { SAFE_POINT_AFTER_EDIT } from './orchestratorProtocol.mjs';
3
+ import { splitContract } from './splitGate.mjs';
3
4
 
4
5
  /**
5
6
  * THE POSTURES A MARKED CARD IS ORCHESTRATED IN, and what their children may
@@ -49,7 +50,10 @@ const CHILD_SPELLING = {
49
50
  const atCard = (model, effort) =>
50
51
  `This card runs on ${model ? model : 'its CLI’s default model'} at ${effort ? `${effort} effort` : 'its CLI’s default effort'}.`;
51
52
 
52
- const childBrain = ({ runtime, model, effort }) => `CHILD MODEL AND EFFORT. ${atCard(model, effort)} By default every child runs at the card's model and effort${model || effort ? '; the gate names them when your spawn names neither, so a child never falls back to the CLI\'s own global default' : ', which it inherits from this run when your spawn names neither'}. For a simple, well-bounded child — a search, a sweep of file reads, a mechanical edit — you may choose a cheaper model or a lower effort. Keep the card's model and effort for hard or judgment-heavy work. Never choose a model or effort above the card's, and only models of this card's own CLI; the gate refuses an effort above the card's. ${CHILD_SPELLING[runtime] ?? ''} When you choose anything other than the card's model or effort for a child, name the choice and why in your update.`.replace(' ', ' ');
53
+ /** Another CLI's children (E7, crossCliChild.mjs), where the controller offers them. */
54
+ const CROSS_CLI_SPELLING = 'start_cli_agent starts a child on another CLI, at a model and effort of that CLI no higher than the card\'s effort; use it only for a batch the person put on that CLI';
55
+
56
+ const childBrain = ({ runtime, model, effort, crossCli = [] }) => `CHILD MODEL AND EFFORT. ${atCard(model, effort)} By default every child runs at the card's model and effort${model || effort ? '; the gate names them when your spawn names neither, so a child never falls back to the CLI\'s own global default' : ', which it inherits from this run when your spawn names neither'}. For a simple, well-bounded child — a search, a sweep of file reads, a mechanical edit — you may choose a cheaper model or a lower effort. Keep the card's model and effort for hard or judgment-heavy work. Never choose a model or effort above the card's, and only models of this card's own CLI${crossCli.length ? ` for a native child (${CROSS_CLI_SPELLING})` : ''}; the gate refuses an effort above the card's. ${CHILD_SPELLING[runtime] ?? ''} When you choose anything other than the card's model or effort for a child, name the choice and why in your update.`.replace(' ', ' ');
53
57
 
54
58
  /** Investigators do not propose separate plans. Only this coordinator sees
55
59
  * and reconciles the complete selection before the existing JSON validator. */
@@ -126,15 +130,19 @@ ${childBrain(brain).replaceAll('card', 'capture turn')}`;
126
130
  * rules, the card's own model and effort, then the gate, routing and child
127
131
  * prompt rules every orchestrated card shares.
128
132
  */
129
- export function orchestratorContract({ posture = 'build', taskType = null, runtime = 'claude', model = null, effort = null } = {}) {
133
+ export function orchestratorContract({ posture = 'build', taskType = null, runtime = 'claude', model = null, effort = null, split = null, crossCli = [] } = {}) {
130
134
  const reads = ORCHESTRATED_POSTURES[posture] === 'read';
135
+ // The person's yes before children edit files (splitGate.mjs): a research
136
+ // card's children only read, so nothing there waits or is proposed.
137
+ const asks = reads ? '' : splitContract(split, { runtime, model, effort, crossCli });
131
138
  return `CARD ORCHESTRATOR — this applies to this card's one CLI and worktree.
132
139
  You are the model the person chose and remain responsible for the card and its final JSON.
133
140
  When an agent queue snapshot is supplied, use it across resumed and fresh turns. Execute only the active card. A person's instruction keeps its stated scope across later cards; its recorded card is provenance, not an implicit target. Give each child a focused assignment with relevant prior findings, decisions and exact prerequisite references. Upcoming work never expands a child's assignment or the active card's product/write contract.
134
- ${SPLIT}${reads ? '' : `\n${SHARED_FIRST}`}${taskType === 'tests' ? `\n${TESTS_SCOPE} Give every child this Tests scope explicitly.` : ''}${reads ? `\n${RESEARCH}` : ''}
135
- ${childBrain({ runtime, model, effort })}
141
+ ${SPLIT}${reads ? '' : `\n${SHARED_FIRST}`}${asks ? `\n${asks}` : ''}${taskType === 'tests' ? `\n${TESTS_SCOPE} Give every child this Tests scope explicitly.` : ''}${reads ? `\n${RESEARCH}` : ''}
142
+ ${childBrain({ runtime, model, effort, crossCli })}
143
+ ${crossCli.length ? 'SUBAGENTS ON ANOTHER CLI. Use start_cli_agent for another CLI, cli_agent_result to read it, followup_cli_agent to continue by its agentId or key even in a later turn, and stop_cli_agent to interrupt just that child. A lost native session refuses in words. An asking result carries a question and options: answer with followup_cli_agent from known facts, or end your own turn through the existing person-question path. Never route a child question directly to the person. Research children only read and return findings; they never edit files.' : ''}
136
144
 
137
- 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 (the same or a reworded prompt) or its native follow-up; the slot is held for about a minute, then offered again later. Never bypass this gate with another CLI process. The orchestrator itself also holds a slot. If no slot becomes available, finish the work yourself.
145
+ 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 (the same or a reworded prompt) or its native follow-up; the slot is held for about a minute, then offered again later. Never bypass this gate with another CLI process${crossCli.length ? '; start_cli_agent is not one: its child takes a slot from this same pool and waits while none is free' : ''}. The orchestrator itself also holds a slot. If no slot becomes available, finish the work yourself.
138
146
  On Claude you may end your turn while native children run: Claude keeps this process alive and wakes you on each child's completion and on the person's notes. Follow-ups and answers to a Claude child go through SendMessage to its agent id. Write the card's final JSON only after every child has finished or been stopped; your last answer is the card's answer.
139
147
 
140
148
  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:
@@ -34,4 +34,22 @@ export const ORCHESTRATOR_TOOLS = [
34
34
  { 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.',
35
35
  inputSchema: { type: 'object', properties: { agentId: { type: 'string' }, answer: { type: 'string' }, escalate: { type: 'boolean' },
36
36
  question: { type: 'string' }, nativeAgentId: { type: 'string' }, kind: { type: 'string' }, title: { type: 'string' } }, required: ['agentId'], additionalProperties: false } },
37
+
38
+ ];
39
+
40
+ /** SUBAGENTS ON ANOTHER CLI (2026-11-06, D21, E7; crossCliChild.mjs): listed
41
+ * only on a run that may start one (`FLOWVIANT_ORCHESTRATOR_CROSS_CLI`, the
42
+ * CLIs offered). The daemon starts and fences the child itself. */
43
+ export const CROSS_CLI_TOOLS = [
44
+ { name: 'start_cli_agent', description: 'Start a subagent on another CLI than yours (claude or codex) in this card\'s worktree, for a part of the card the person approved for that CLI. Returns its agentId at once; it works in the background. Read its outcome with cli_agent_result. The daemon fences it with the card\'s posture for that CLI; it cannot start subagents of its own.',
45
+ inputSchema: { type: 'object', properties: { cli: { enum: ['claude', 'codex'] }, title: { type: 'string' }, kind: { type: 'string', description: 'Backend, UI, Tests or the area of work.' },
46
+ brief: { type: 'string', description: 'The whole assignment: the part, the files it owns, the shared code it must not change, and what to return.' },
47
+ model: { type: 'string', description: 'A model of that CLI; leave it out for the person\'s batch choice or the default.' }, effort: { type: 'string' } },
48
+ required: ['cli', 'title', 'kind', 'brief'], additionalProperties: false } },
49
+ { name: 'followup_cli_agent', description: 'Send a finished subagent on another CLI a follow-up in its own conversation. Use the same agentId or stable key across turns. Refused while it is still working; a missing native session refuses in words. Returns at once; read it with cli_agent_result.',
50
+ inputSchema: { type: 'object', properties: { agentId: { type: 'string' }, message: { type: 'string' } }, required: ['agentId', 'message'], additionalProperties: false } },
51
+ { name: 'cli_agent_result', description: 'Wait up to waitSeconds (at most 300) for a subagent on another CLI, then return its status (waiting, running, done, asking, stopped or limited), the model and effort its own session names, its final message as result, the files it changed and the CLI\'s own words on a failure or plan limit.',
52
+ inputSchema: { type: 'object', properties: { agentId: { type: 'string' }, waitSeconds: { type: 'number', minimum: 0, maximum: 300 } }, required: ['agentId'], additionalProperties: false } },
53
+ { name: 'stop_cli_agent', description: 'Interrupt only this child, then kill its process group after the grace period if needed. Records stopped and releases its slot. Its files and conversation remain.',
54
+ inputSchema: { type: 'object', properties: { agentId: { type: 'string' } }, required: ['agentId'], additionalProperties: false } },
37
55
  ];
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A LEAD THAT SHELLS OUT TO ANOTHER CLI IS SHOWN, TAGGED "OUTSIDE" (D23,
3
+ * 2026-11-06, S4.6). `codex exec …` or `claude -p …` from a card's shell is
4
+ * a second model working on the card, and the person sitting at the keyboard
5
+ * would see it start. It is not one of the CLI's own subagents: it is never
6
+ * blocked, never counted toward the 32 and never given a subagent record.
7
+ * Each one is an `outside` row on the turn's trace (trace.mjs `outside`).
8
+ *
9
+ * THE MATCHER IS SMALL AND CONSERVATIVE ON PURPOSE. It reads only the first
10
+ * command of the line, after leading `cd … &&` steps, environment
11
+ * assignments and an `env`, inside one `bash -lc '…'` wrapper (Codex's exec
12
+ * spelling): its first word is `codex` with `exec`/`e`, `claude` with
13
+ * `-p`/`--print`, or `gemini`, directly or through `npx`/`bunx`/`pnpm dlx`/
14
+ * `npm exec` of that CLI's package. A pipeline, a loop or a script that
15
+ * starts one is not recognized: an unmatched line is an ordinary command,
16
+ * which the lead's stream relays as it always did. The asked model is what
17
+ * the command names (`--model`/`-m`); what ran is that CLI's business.
18
+ */
19
+
20
+ const PACKAGES = Object.freeze({
21
+ '@openai/codex': 'codex',
22
+ '@anthropic-ai/claude-code': 'claude',
23
+ '@google/gemini-cli': 'gemini',
24
+ });
25
+ const RUNNERS = new Set(['npx', 'bunx']);
26
+ const MODEL_RE = /^[A-Za-z0-9._:\-/]{1,80}$/;
27
+
28
+ /** Shell words, with quotes honored and no expansion. */
29
+ function words(line) {
30
+ const out = [];
31
+ let word = '', quote = null, any = false;
32
+ for (let i = 0; i < line.length; i++) {
33
+ const ch = line[i];
34
+ if (quote) {
35
+ if (ch === quote) quote = null;
36
+ else if (ch === '\\' && quote === '"' && i + 1 < line.length) word += line[++i];
37
+ else word += ch;
38
+ continue;
39
+ }
40
+ if (ch === "'" || ch === '"') { quote = ch; any = true; continue; }
41
+ if (ch === '\\' && i + 1 < line.length) { word += line[++i]; any = true; continue; }
42
+ if (/\s/.test(ch)) { if (word || any) out.push(word); word = ''; any = false; continue; }
43
+ word += ch;
44
+ }
45
+ if (word || any) out.push(word);
46
+ return out;
47
+ }
48
+
49
+ /** The first command of a line, past `cd … &&`/`;` steps, as words. */
50
+ function firstCommand(line) {
51
+ let text = String(line ?? '').trim();
52
+ const wrapped = text.match(/^(?:\S*\/)?(?:bash|sh|zsh)\s+-l?c\s+(['"])([\s\S]*)\1$/);
53
+ if (wrapped) text = wrapped[2].trim();
54
+ for (const step of text.split(/\s*(?:&&|;)\s*/)) {
55
+ const w = words(step);
56
+ if (!w.length || w[0] === 'cd' || w[0] === 'pushd') continue;
57
+ let i = 0;
58
+ if (w[i] === 'env') i++;
59
+ while (i < w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i])) i++;
60
+ return w.slice(i);
61
+ }
62
+ return [];
63
+ }
64
+
65
+ const base = word => String(word ?? '').split('/').at(-1);
66
+
67
+ /** `{ cli, model? }` when the command starts another CLI, else null. */
68
+ export function matchOutsideCli(command) {
69
+ let w = firstCommand(Array.isArray(command) ? command.join(' ') : command);
70
+ // A package runner names the CLI by its package.
71
+ const runner = base(w[0]);
72
+ const dlx = (runner === 'pnpm' && w[1] === 'dlx') || (runner === 'npm' && w[1] === 'exec');
73
+ let cli = null;
74
+ if (RUNNERS.has(runner) || dlx) {
75
+ let i = dlx ? 2 : 1;
76
+ while (i < w.length && w[i].startsWith('-')) i++;
77
+ cli = PACKAGES[String(w[i] ?? '').replace(/(?<=.)@[^/]*$/, '')] ?? null;
78
+ if (!cli) return null;
79
+ w = [cli, ...w.slice(i + 1).filter(x => x !== '--')];
80
+ } else cli = ['codex', 'claude', 'gemini'].includes(base(w[0])) ? base(w[0]) : null;
81
+ if (!cli) return null;
82
+ const args = w.slice(1);
83
+ if (cli === 'codex' && !['exec', 'e'].includes(args[0])) return null;
84
+ if (cli === 'claude' && !args.some(a => a === '-p' || a === '--print')) return null;
85
+ let model = null;
86
+ for (let i = 0; i < args.length; i++) {
87
+ const a = args[i];
88
+ if (a === '--model' || a === '-m') model = args[i + 1] ?? null;
89
+ else if (a.startsWith('--model=')) model = a.slice(8);
90
+ }
91
+ return { cli, ...(model && MODEL_RE.test(model) ? { model } : {}) };
92
+ }
93
+
94
+ /**
95
+ * The lead's tool events, watched for Outside rows. `observe` takes the
96
+ * same (name, input, meta) the lane's tool sink does: a shell call opens a
97
+ * row by its call id, its result settles it. A background shell's result is
98
+ * only its launch, so its row keeps no status it cannot know.
99
+ */
100
+ export function createOutsideRuns(trace) {
101
+ const open = new Map();
102
+ return {
103
+ observe(name, input, meta) {
104
+ if (!meta?.id) return;
105
+ if (meta.result) {
106
+ if (!open.has(meta.id)) return;
107
+ const background = open.get(meta.id);
108
+ open.delete(meta.id);
109
+ if (background) return;
110
+ const failed = meta.isError === true || (Number.isInteger(meta.exitCode) && meta.exitCode !== 0);
111
+ trace.outside(meta.id, { status: failed ? 'failed' : 'done' });
112
+ return;
113
+ }
114
+ if (name !== 'Bash' || typeof input?.command !== 'string' && !Array.isArray(input?.command)) return;
115
+ const match = matchOutsideCli(input.command);
116
+ if (!match) return;
117
+ const background = input.run_in_background === true;
118
+ open.set(meta.id, background);
119
+ if (open.size > 64) open.delete(open.keys().next().value);
120
+ trace.outside(meta.id, { ...match, command: Array.isArray(input.command) ? input.command.join(' ') : input.command,
121
+ ...(background ? {} : { status: 'running' }) });
122
+ },
123
+ };
124
+ }
@@ -1,3 +1,4 @@
1
+ import { CONVERSATION_OUTPUT } from './conversationOutput.mjs';
1
2
  import { TESTS_SCOPE, TESTS_REPORT } from './testFindingsContract.mjs';
2
3
  import { randomBytes } from 'node:crypto';
3
4
  import { REGRESSION_INVESTIGATION } from './discoveryContract.mjs';
@@ -28,13 +29,14 @@ import { ARTIFACT_CONTRACT_TAIL, HELP_THEM_DECIDE, LOOK_HEADLESS, SYSTEM_AGENT_D
28
29
  import { isLibraryReference } from './knowledgeLibrary.mjs';
29
30
  import { readCardThread } from './cardThread.mjs';
30
31
  import { AGENT_FILE_LINE_MAX, AGENT_FILES_PER_MESSAGE_MAX, CARD_FILES_PER_TURN_MAX } from './agentFiles.mjs';
32
+ import { SYSTEM_ARCHITECTURE_CONTRACT } from './architectureContract.mjs';
31
33
 
32
34
  // Wiki-gen turn: the local Claude READS the repo (cwd) and writes/maintains the
33
35
  // knowledge VAULT — a plain directory of markdown files with [[wikilinks]]
34
36
  // (Obsidian-style). No MCP tools involved: the vault is just files, and the
35
37
  // daemon hash-diff syncs them to Flowviant after the turn. The repo itself is
36
38
  // strictly read-only.
37
- const WIKI_MAINTENANCE = `
39
+ const WIKI_MAINTENANCE = `${SYSTEM_ARCHITECTURE_CONTRACT}
38
40
  OWNERSHIP AND PROVENANCE — this directory is a disposable draft of GENERATED
39
41
  output, separate from project Knowledge, uploaded/reference vault snapshots and
40
42
  human standing Instructions. Never write to any original source. In this draft,
@@ -2037,12 +2039,12 @@ XHR, sockets, forms and popups are all closed. Never commit these files.`;
2037
2039
  /**
2038
2040
  * The system prompt a turn actually runs under: the contract it picked, plus
2039
2041
  * whatever project context this box holds. `knowledgeDir` null or absent and
2040
- * `artifacts` false leave the contract byte-for-byte what it was, which is what
2041
- * every turn did before either existed — and what an older server still gets.
2042
- * Knowledge first, artifacts after: what to READ, then where to WRITE.
2042
+ * `artifacts` false omit their paragraphs. The shared conversation output guide
2043
+ * always follows the base contract. Knowledge then says what to READ, and the
2044
+ * artifact paragraph says where to WRITE when that surface accepts files.
2043
2045
  */
2044
2046
  export const withProjectContext = (system, { knowledgeDir, artifacts = false } = {}) => {
2045
- let out = system;
2047
+ let out = `${system}\n\n${CONVERSATION_OUTPUT}`;
2046
2048
  if (knowledgeDir) out = `${out}\n\n${KNOWLEDGE_PARAGRAPH(knowledgeDir)}`;
2047
2049
  if (artifacts) out = `${out}\n\n${ARTIFACTS_PARAGRAPH}`;
2048
2050
  return out;
@@ -27,6 +27,7 @@ import { createTurnOutput } from './turnOutput.mjs';
27
27
  import { createObservedLines } from './observedLines.mjs';
28
28
  import { startTurnDiagnostics } from './turnDiagnostics.mjs';
29
29
  import { createClaudeInput } from './claudeInput.mjs';
30
+ import { codexAppArgs, createCodexInput } from './codexInput.mjs';
30
31
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
31
32
  import { createTurnSubagents } from './turnSubagents.mjs';
32
33
  import { withPublicReference } from './publicReferenceRuntime.mjs';
@@ -79,7 +80,7 @@ const MAX_ARG_BYTES = 128 * 1024;
79
80
 
80
81
  export function runTurn(opts) {
81
82
  if (workLanesStopped()) return never();
82
- if ((opts.profile === 'research' || opts.researchIdentity) && ['claude', 'codex'].includes(opts.runtime ?? 'claude'))
83
+ if (!opts.crossCliChild && (opts.profile === 'research' || opts.researchIdentity) && ['claude', 'codex'].includes(opts.runtime ?? 'claude'))
83
84
  return withPublicReference(opts, runProfileTurn);
84
85
  return runProfileTurn(opts);
85
86
  }
@@ -88,7 +89,7 @@ function runProfileTurn(opts) {
88
89
  // A Codex write-up runs over an isolated copy of its worktree
89
90
  // (codexResearch.mjs). After the teardown it copies nothing either: the
90
91
  // stand-down gate below is asked before the copy, not only before the spawn.
91
- if (opts.runtime === 'codex' && opts.profile === 'research')
92
+ if (!opts.crossCliChild && opts.runtime === 'codex' && opts.profile === 'research')
92
93
  return workLanesStopped() ? never() : runIsolatedResearch(opts, prepared => runResearchWithReceipt(prepared, runPlainTurn));
93
94
  if (opts.profile === 'research' && opts.researchHandoff)
94
95
  return prepareResearchLaunch(opts).then(prepared => runResearchWithReceipt(prepared, runPlainTurn));
@@ -208,6 +209,8 @@ function runPlainTurn(opts) {
208
209
  verifiedWorktree: opts.verifiedWorktree === true,
209
210
  orchestration: opts.orchestration === true,
210
211
  readOnlyCoordination: opts.readOnlyCoordination === true,
212
+ // A subagent on another CLI's card (crossCliChild.mjs, crossCliPosture.mjs).
213
+ crossCliChild: opts.crossCliChild === true,
211
214
  // The lane's own measurement when it decided to orchestrate, so the
212
215
  // hooks and the controller cannot disagree (workAgentTurnExecution.mjs).
213
216
  codexVersion: opts.codexVersion ?? measuredRuntimeVersion('codex')?.version,
@@ -303,8 +306,13 @@ function runPlainTurn(opts) {
303
306
  * so no throw on the way leaves that file behind: the sweep takes only a
304
307
  * dead process's leftovers, and this one names the daemon's own pid.
305
308
  */
309
+ const nativeVersion = opts.codexVersion ?? measuredRuntimeVersion('codex')?.version;
310
+ // Only a measured native protocol switches transport. Unknown/older CLIs
311
+ // still receive the original prompt and durable next-turn queue via exec.
312
+ const appArgs = rt.id === 'codex' && opts.liveMessages && nativeVersion ? codexAppArgs(args, nativeVersion) : null;
313
+ const codexApp = Boolean(appArgs);
306
314
  let transport;
307
- try { transport = promptTransport(rt.id, args, stdinText); }
315
+ try { transport = appArgs ? { args: appArgs, text: null, cleanup() {} } : promptTransport(rt.id, args, stdinText); }
308
316
  catch (error) { rt.releaseArgs?.(args); throw error; }
309
317
  args.splice(0, args.length, ...transport.args);
310
318
  let argsReleased = false;
@@ -403,7 +411,13 @@ function runPlainTurn(opts) {
403
411
  const spawnedAt = Date.now();
404
412
  let out = '';
405
413
  let stdinError = null;
406
- const input = rt.liveInput === true && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken, onContext: opts.onContext ?? null }) : null;
414
+ const input = codexApp ? createCodexInput(child, { cwd, threadId: resumeThreadId,
415
+ onTaken: id => { if (!id.startsWith('recovery-')) opts.liveMessages.onTaken(id); },
416
+ onThread: id => opts.liveMessages.onInit(id),
417
+ onEvent: event => onLine(JSON.stringify(event), Date.now(), true),
418
+ onFailure: () => { cliFailed = true; opts.onCliFailure?.(); },
419
+ onContext: opts.onContext ?? null,
420
+ }) : !rt.parse && rt.liveInput === true && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken, onContext: opts.onContext ?? null }) : null;
407
421
  // EVERY TURN IS LINE-PARSED (2026-10-10, the audit). Claude's adapter
408
422
  // always asks for `--output-format stream-json` and every other runtime
409
423
  // brings its own `parse` — for Codex the JSONL stream is the only output
@@ -436,7 +450,7 @@ function runPlainTurn(opts) {
436
450
  };
437
451
  // The native children this CLI starts, observed (turnSubagents.mjs).
438
452
  const natives = createTurnSubagents({ runtime: rt.id, cwd, since: spawnedAt, env: { ...process.env, ...(mcpEnv ?? {}) },
439
- owned: opts.orchestration === true, onSubagent: opts.onSubagent, onSpawnResult: opts.onSpawnResult, resumeThreadId,
453
+ owned: opts.orchestration === true, onSubagent: opts.onSubagent, onWorkflow: opts.onWorkflow, onSpawnResult: opts.onSpawnResult, resumeThreadId, knownSubagents: opts.knownSubagents,
440
454
  sinks: { emit, onActivity, onToolEvent, appendText, onDetail: output.detail } });
441
455
  let codexThread = rt.id === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
442
456
  /**
@@ -471,14 +485,18 @@ function runPlainTurn(opts) {
471
485
  const taskPlan = new Map();
472
486
  /** One line of the child's stdout, in whichever dialect it speaks. */
473
487
  let cliFailed = false;
474
- const onLine = (line, observedAt) => {
488
+ const onLine = (line, observedAt, nativeEvent = false) => {
489
+ if (codexApp && !nativeEvent) {
490
+ try { input.event(JSON.parse(line)); } catch { /* non-protocol stdout */ }
491
+ return;
492
+ }
475
493
  const emitObserved = (text, meta) => emit(text, { ...meta, observedAt });
476
494
  const toolObserved = (name, input, meta) => onToolEvent(name, input, meta, observedAt);
477
495
  try {
478
496
  const event = JSON.parse(line);
479
497
  const parentEvent = event.parent_tool_use_id == null && !event.isSidechain;
480
498
  opts.researchReceipt?.event(event);
481
- if (input && parentEvent) {
499
+ if (input && !codexApp && parentEvent) {
482
500
  input.event(event);
483
501
  if (event.type === 'system' && event.subtype === 'init' && event.session_id) opts.liveMessages.onInit(event.session_id);
484
502
  // A message written just before result may become a second native
@@ -486,6 +504,7 @@ function runPlainTurn(opts) {
486
504
  // structured objects joined together; every result still reports spend.
487
505
  if (event.type === 'result') out = '';
488
506
  }
507
+ if (codexApp && event.type === 'turn.started') out = '';
489
508
  if (parentEvent && ((event.type === 'result' && event.is_error === true) ||
490
509
  event.type === 'error' || event.type === 'turn.failed')) {
491
510
  cliFailed = true;
@@ -500,7 +519,9 @@ function runPlainTurn(opts) {
500
519
  // thread.started). Purely additive: callers that pass no onThreadId —
501
520
  // every dispatch path — see zero behavior change.
502
521
  if (ev.threadId) {
503
- if (rt.id === 'codex') { codexThread = ev.threadId; natives.thread(ev.threadId); }
522
+ if (rt.id === 'codex') { codexThread = ev.threadId; natives.thread(ev.threadId);
523
+ if (!input && opts.liveMessages) opts.liveMessages.onTaken(opts.liveMessages.turnId);
524
+ }
504
525
  onThreadId?.(ev.threadId);
505
526
  }
506
527
  // The runtime's own token count (codex's `turn.completed`) — the same
@@ -657,6 +678,7 @@ function runPlainTurn(opts) {
657
678
  if (input) {
658
679
  const initial = opts.liveMessages.initialPrompt(prompt, resumeThreadId, resume, opts.liveContinuation === true);
659
680
  input.send(opts.liveMessages.initialId || opts.liveMessages.turnId, initial);
681
+ if (codexApp) input.start();
660
682
  opts.liveMessages.connect(input.send);
661
683
  }
662
684
  });
@@ -21,6 +21,7 @@ import { withSettings } from './claudeSettingsArg.mjs';
21
21
  import { orchestratorHooks, claudeOrchestrationSafe } from './orchestratorHooks.mjs';
22
22
  import { orchestratesPosture } from './orchestratorPrompt.mjs';
23
23
  import { versionAtLeast } from './versionOrder.mjs';
24
+ import { crossCliClaudePerm } from './crossCliPosture.mjs';
24
25
 
25
26
  /** The release the agent lane first measured `--system-prompt-snapshot` on
26
27
  * (decisions.md, 2026-09-24). */
@@ -123,7 +124,7 @@ export const CLAUDE_RUNTIME = {
123
124
  * strongest form of it available anywhere: `--append-system-prompt` sits
124
125
  * above the conversation rather than inside it.
125
126
  */
126
- args({ managedBrowser = false, 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, readOnlyCoordination = false, claudeVersion = null }) {
127
+ args({ managedBrowser = false, 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, readOnlyCoordination = false, claudeVersion = null, crossCliChild = false, cwd }) {
127
128
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
128
129
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
129
130
  // with no vault, or one no rule can name, has no list — refused here in
@@ -195,7 +196,10 @@ export const CLAUDE_RUNTIME = {
195
196
  // a research card's list then admits the children its fence denied.
196
197
  const readCoordinator = orchestration && readOnlyCoordination && ['consult', 'plan'].includes(profile) && claudeOrchestrationSafe(claudeVersion);
197
198
  const gated = readCoordinator || orchestration && orchestratesPosture(profile);
198
- const perm = readCoordinator ? withReadOnlyCoordination(postureList) : gated && fenced ? withNativeChildren(postureList) : postureList;
199
+ // A subagent on another CLI's card runs the build fenced to its worktree,
200
+ // with no children of its own (crossCliPosture.mjs).
201
+ const perm = readCoordinator ? withReadOnlyCoordination(postureList) : gated && fenced ? withNativeChildren(postureList)
202
+ : crossCliChild ? crossCliClaudePerm(postureList, cwd, profile) : postureList;
199
203
  if (fenced) a.push(...NO_SETTING_SOURCES);
200
204
  // A FENCED POSTURE NAMES ITS OWN MODE (2026-10-10). Its lists fence only
201
205
  // under the CLI's `default` mode, where a tool the list does not name is
@@ -205,7 +209,7 @@ export const CLAUDE_RUNTIME = {
205
209
  // classifier that may approve what the list never named. So the posture
206
210
  // states `default` itself, unless its own list names a mode (plan-mode).
207
211
  if (fenced && !perm.includes('--permission-mode')) a.push('--permission-mode', 'default');
208
- else if (agentTools) a.push('--setting-sources', 'user');
212
+ else if (agentTools || crossCliChild) a.push('--setting-sources', 'user');
209
213
  // EVERY TURN RENDERS ITS OWN SYSTEM PROMPT (2026-10-19). By default the
210
214
  // CLI records a conversation's first system prompt and resends that record
211
215
  // on every resume "even when a later launch passes different text" —
@@ -19,6 +19,7 @@ import { projectReadBoundary, projectReadFence } from './codexProjectRead.mjs';
19
19
  import { versionAtLeast } from './versionOrder.mjs';
20
20
  import { codexOrchestratorArgs } from './orchestratorHooks.mjs';
21
21
  import { orchestratesPosture } from './orchestratorPrompt.mjs';
22
+ import { CROSS_CLI_TMP_FENCE } from './crossCliPosture.mjs';
22
23
 
23
24
  /**
24
25
  * FLOWVIANT'S OWN TOOLS NEED NO APPROVAL FROM A CHAT NOBODY CAN ANSWER
@@ -215,7 +216,7 @@ export const CODEX_RUNTIME = {
215
216
  bin: 'codex',
216
217
  install: 'npm i -g @openai/codex',
217
218
  login: 'codex login',
218
- liveInput: false,
219
+ liveInput: true,
219
220
  /**
220
221
  * BUILD AND CONSULT. See `args()` below for how consult is expressed — the
221
222
  * short version is that Codex keeps the promise at the kernel rather than in
@@ -281,7 +282,7 @@ export const CODEX_RUNTIME = {
281
282
  * limit even when its argv plus environment is far below ARG_MAX. Both exec
282
283
  * and exec resume support `-` (verified on Codex 0.160.0, 2026-10-16).
283
284
  */
284
- args({ managedBrowser = false, streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, verifiedWorktree = false, codexVersion, orchestration = false, readOnlyCoordination = false }) {
285
+ args({ managedBrowser = false, streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, verifiedWorktree = false, codexVersion, orchestration = false, readOnlyCoordination = false, crossCliChild = false }) {
285
286
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
286
287
  // agy moves) — codex has no adoptable store wired yet. Reaching here
287
288
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -319,7 +320,7 @@ export const CODEX_RUNTIME = {
319
320
  // the parent's sandbox (2026-10-19, orchestratorPrompt.mjs).
320
321
  const gated = orchestration && (orchestratesPosture(fenced ? profile : 'build') || readOnlyCoordination && ['consult', 'plan'].includes(profile)) && codexSubagentsSafe(codexVersion);
321
322
  if (fenced) {
322
- a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads, subagents: gated, codexVersion }));
323
+ a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads, subagents: gated, codexVersion, crossCliChild }));
323
324
  } else {
324
325
  // The daemon's build posture, mapped: SAFE keeps writes inside the
325
326
  // workspace, the default lets the agent run its own tests and git
@@ -334,7 +335,9 @@ export const CODEX_RUNTIME = {
334
335
  if (writableDirs.length) a.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`);
335
336
  a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
336
337
  if (cardTurn) a.push('-c', 'approval_policy="never"', ...(agentTools?.codexApprovals ?? []));
337
- a.push(...(cardTurn && profile === 'build' && codexSubagentsSafe(codexVersion) ? CARD_SUBAGENTS : noSubagents(codexVersion)));
338
+ // A subagent on another CLI's card has none of its own (crossCliPosture.mjs).
339
+ a.push(...(crossCliChild ? [...noSubagents(codexVersion), ...(SAFE ? CROSS_CLI_TMP_FENCE : [])]
340
+ : cardTurn && profile === 'build' && codexSubagentsSafe(codexVersion) ? CARD_SUBAGENTS : noSubagents(codexVersion)));
338
341
  }
339
342
 
340
343
  /**
@@ -521,9 +524,12 @@ const CODEX_FENCES = {
521
524
  // An orchestrated research card's children (2026-10-19) inherit this same
522
525
  // permission profile, approval policy and cwd (0.160.0 child_config.rs);
523
526
  // the gate refuses their writes.
524
- research: ({ researchIsolation, subagents = false, codexVersion }) => {
527
+ research: ({ researchIsolation, subagents = false, codexVersion, crossCliChild = false }) => {
525
528
  if (!researchIsolation) throw new Error('Codex research requires its isolated input copy.');
526
- return [...researchFence(researchIsolation), ...fencedTail({ web: true, imageGeneration: true, subagents, codexVersion })];
529
+ const fence = researchFence(researchIsolation);
530
+ // A child returns findings to its lead: even the artifact/scratch roots read only.
531
+ return [...(crossCliChild ? fence.map(a => a.startsWith('permissions.flowviantresearch.filesystem=') ? a.replaceAll('="write"', '="read"') : a) : fence),
532
+ ...fencedTail({ web: true, imageGeneration: !crossCliChild, subagents: !crossCliChild && subagents, codexVersion })];
527
533
  },
528
534
  };
529
535
  /** The fenced profile names — for the test that holds both tables to one set. */