flowviant 0.125.29 → 0.125.31

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 (72) hide show
  1. package/README.md +364 -0
  2. package/bin/cli.mjs +23 -0
  3. package/bin/lib/agentPlan.mjs +8 -0
  4. package/bin/lib/agentQueueContext.mjs +6 -0
  5. package/bin/lib/agentSplit.mjs +90 -0
  6. package/bin/lib/architectureContract.mjs +57 -0
  7. package/bin/lib/architectureReport.mjs +38 -0
  8. package/bin/lib/artifactContracts.mjs +21 -2
  9. package/bin/lib/claudeInput.mjs +1 -1
  10. package/bin/lib/claudeSubagents.mjs +46 -6
  11. package/bin/lib/claudeWorkflows.mjs +94 -0
  12. package/bin/lib/cliLimit.mjs +5 -0
  13. package/bin/lib/codexInput.mjs +191 -0
  14. package/bin/lib/codexSubagentEvents.mjs +3 -1
  15. package/bin/lib/codexSubagents.mjs +34 -9
  16. package/bin/lib/conversationOutput.mjs +14 -0
  17. package/bin/lib/crossCliChild.mjs +327 -0
  18. package/bin/lib/crossCliLaunch.mjs +92 -0
  19. package/bin/lib/crossCliPosture.mjs +55 -0
  20. package/bin/lib/crossCliResearch.mjs +19 -0
  21. package/bin/lib/environmentProcess.mjs +44 -0
  22. package/bin/lib/fixtures/workflow-ultracode.jsonl +22 -0
  23. package/bin/lib/fleet.mjs +2 -0
  24. package/bin/lib/linuxDesktopBridge.mjs +62 -0
  25. package/bin/lib/managedBrowser.mjs +50 -10
  26. package/bin/lib/mergeBatch.mjs +1 -1
  27. package/bin/lib/nativeAgentTools.mjs +12 -5
  28. package/bin/lib/noWindowEnv.mjs +4 -2
  29. package/bin/lib/orchestrator.mjs +24 -7
  30. package/bin/lib/orchestratorClient.mjs +8 -2
  31. package/bin/lib/orchestratorPrompt.mjs +13 -5
  32. package/bin/lib/orchestratorProtocol.mjs +18 -0
  33. package/bin/lib/outsideCli.mjs +124 -0
  34. package/bin/lib/projectToolRuntimes.mjs +4 -2
  35. package/bin/lib/projectTools.mjs +2 -2
  36. package/bin/lib/prompts.mjs +7 -5
  37. package/bin/lib/repositoryPlaces.mjs +1 -1
  38. package/bin/lib/repositorySetup.mjs +15 -24
  39. package/bin/lib/reviewBridge.mjs +110 -0
  40. package/bin/lib/reviewDatabase.mjs +112 -0
  41. package/bin/lib/reviewDesktop.mjs +153 -0
  42. package/bin/lib/reviewEnvironment.mjs +286 -0
  43. package/bin/lib/reviewEnvironmentConfig.mjs +104 -0
  44. package/bin/lib/reviewEnvironmentMain.mjs +38 -0
  45. package/bin/lib/reviewImage.mjs +58 -0
  46. package/bin/lib/reviewMcpClient.mjs +69 -0
  47. package/bin/lib/reviewProcess.mjs +51 -0
  48. package/bin/lib/reviewTargets.mjs +50 -0
  49. package/bin/lib/robloxReview.mjs +234 -0
  50. package/bin/lib/robloxStudioBridge.mjs +68 -0
  51. package/bin/lib/runTurn.mjs +30 -8
  52. package/bin/lib/runtimeClaude.mjs +7 -3
  53. package/bin/lib/runtimeCodex.mjs +12 -6
  54. package/bin/lib/runtimeLimits.mjs +59 -1
  55. package/bin/lib/runtimeReview.mjs +185 -0
  56. package/bin/lib/splitGate.mjs +115 -0
  57. package/bin/lib/subagentBounds.mjs +26 -0
  58. package/bin/lib/subagentLedger.mjs +110 -0
  59. package/bin/lib/trace.mjs +134 -6
  60. package/bin/lib/turnDeclaration.mjs +4 -4
  61. package/bin/lib/turnGaps.mjs +6 -2
  62. package/bin/lib/turnSubagents.mjs +23 -9
  63. package/bin/lib/uiReview.mjs +5 -2
  64. package/bin/lib/wikiDraft.mjs +9 -0
  65. package/bin/lib/work.mjs +22 -1
  66. package/bin/lib/workAgentCheck.mjs +24 -1
  67. package/bin/lib/workAgentResume.mjs +11 -2
  68. package/bin/lib/workAgentTools.mjs +6 -4
  69. package/bin/lib/workAgentTurnExecution.mjs +77 -9
  70. package/bin/lib/workAgentTurnOutcome.mjs +11 -0
  71. package/bin/lib/workAgentTurnReports.mjs +5 -2
  72. package/package.json +1 -1
package/bin/lib/fleet.mjs CHANGED
@@ -276,6 +276,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
276
276
  retirePreviews,
277
277
  shutdownPreviews,
278
278
  retireWorkSessions,
279
+ reconcileEnvironments,
279
280
  reportWorktrees,
280
281
  reconcileIdleArtifacts,
281
282
  reconcileLocalCleanup,
@@ -597,6 +598,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
597
598
  // winner is working in — absence would mean "somebody else won" instead of
598
599
  // "the tab closed".
599
600
  retireWorkSessions(roster.activeWorkSessions, roster.sessionsHeldElsewhere);
601
+ reconcileEnvironments();
600
602
  // Diffs somebody has open and is waiting on. Project-scoped rather than
601
603
  // per-session: `git show` runs from the repo ROOT, which can see a closed
602
604
  // tab's branch and a shipped commit on main alike.
@@ -0,0 +1,62 @@
1
+ /** Linux host helper: parent EOF owns the lifetime of its whole process group. */
2
+ import { createInterface } from 'node:readline';
3
+ import { openBridgePage } from './reviewBridge.mjs';
4
+ import { openDesktopPage } from './reviewDesktop.mjs';
5
+ import { heldToolArgv } from './heldTool.mjs';
6
+ import { killGroup } from './processGroup.mjs';
7
+ import { procStat } from './procStat.mjs';
8
+ import { reviewDiagnostic } from './uiReviewBrowser.mjs';
9
+
10
+ export function openLinuxDesktop({ command, cwd, env, timeoutMs, signal, onChild, onFrame, onChange }) {
11
+ return openBridgePage({ command: [process.execPath, ...heldToolArgv('__linux-desktop-bridge')], cwd, env, worktree: cwd,
12
+ configuration: { command, cwd, timeoutMs }, timeoutMs, signal, onChild, onFrame, onChange });
13
+ }
14
+
15
+ export async function linuxDesktopBridgeMain() {
16
+ // Never group-kill a caller's terminal if somebody manually invokes this
17
+ // private verb. The parent starts the helper detached as its group leader.
18
+ if (process.platform !== 'linux' || procStat(process.pid)?.pgrp !== process.pid) throw new Error('The desktop helper needs its own Linux process group.');
19
+ const controller = new AbortController();
20
+ let page, configuration, epoch = 0, closing = false;
21
+ const send = value => { if (!closing) process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n'); };
22
+ const shutdown = () => {
23
+ if (closing) return;
24
+ closing = true; controller.abort(); page?.close();
25
+ // All native descendants inherit this group; this also reaps descendants
26
+ // of an app launcher after a killed daemon closes the private stdin pipe.
27
+ setTimeout(() => killGroup(process.pid), 50);
28
+ };
29
+ process.stdin.once('end', shutdown); process.stdin.once('error', shutdown);
30
+ process.stdout.once('error', shutdown);
31
+ process.once('SIGTERM', shutdown); process.once('SIGINT', shutdown);
32
+ let bytes = 0;
33
+ try {
34
+ for await (const line of createInterface({ input: process.stdin })) {
35
+ if (closing) break;
36
+ let request;
37
+ try {
38
+ bytes = Buffer.byteLength(line); if (bytes > 128 * 1024) throw new Error('Desktop helper request exceeds its byte bound.');
39
+ request = JSON.parse(line);
40
+ const { id, method, params } = request;
41
+ let result = {};
42
+ if (method === 'initialize') {
43
+ configuration = params.configuration;
44
+ if (params.protocol !== 1 || !configuration || !Array.isArray(configuration.command) || configuration.cwd !== params.worktree) throw new Error('Invalid scoped desktop initialization.');
45
+ result = { protocol: 1, isolation: 'owned-session', privateHandoff: true };
46
+ } else if (method === 'open' && configuration && !page) {
47
+ page = await openDesktopPage({ ...configuration, env: process.env, signal: controller.signal, detachedChildren: false,
48
+ onFrame: frame => send({ method: 'frame', params: { ...frame, epoch } }),
49
+ onChange: patch => send({ method: patch.ended ? 'ended' : 'failure', params: { epoch, message: patch.error } }) });
50
+ } else if (method === 'snapshot' && page) result = { ...await page.snapshot(), width: 1440, height: 900, epoch };
51
+ else if (method === 'act' && page) await page.act(params.input);
52
+ else if (method === 'privacy' && page) { await page.setPrivate(params.private); epoch = params.epoch; result = { epoch, drained: true }; }
53
+ else throw new Error('Unsupported desktop helper operation.');
54
+ send({ id, result });
55
+ } catch (error) { send({ id: request?.id, error: { message: reviewDiagnostic(error.message) } }); }
56
+ }
57
+ } finally {
58
+ shutdown();
59
+ // Do not return to cli.mjs's process.exit before the group cleanup runs.
60
+ await new Promise(resolve => setTimeout(resolve, 100));
61
+ }
62
+ }
@@ -70,7 +70,7 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
70
70
  let lastRxAt = 0;
71
71
  let captures = 0;
72
72
  let observationEpoch = 0, pageEpoch = 0;
73
- let pageOrigin = null, pageMeasured = null, statusPending = false, statusRetry = null;
73
+ let pageOrigin = null, pageMeasured = null, pageScope = null, statusPending = false, statusRetry = null;
74
74
  let handoff = null, handoffResult = null;
75
75
  let transportStarted = false;
76
76
  const controller = new AbortController();
@@ -88,7 +88,7 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
88
88
  };
89
89
  const change = patch => { status = { ...status, ...patch }; statusPending = true; flushStatus(); };
90
90
  const frame = message => { if (closed || stopped) return; lastFrame = message; flushStatus(); if (!statusPending) send(message); };
91
- const release = () => { pageEpoch++; observationEpoch++; page?.close(); page = null; pageOrigin = null; pageMeasured = null; lastFrame = null; };
91
+ const release = () => { pageEpoch++; observationEpoch++; page?.close(); page = null; pageOrigin = null; pageMeasured = null; pageScope = null; lastFrame = null; };
92
92
  const endHandoff = reason => {
93
93
  if (!handoff) return;
94
94
  const active = handoff; handoff = null; clearTimeout(active.timer);
@@ -122,12 +122,15 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
122
122
  }
123
123
  return { content: [text({ page: shot.observation, diagnostics: shot.diagnostics, path: status.path, pendingOrigins: status.pendingOrigins, networkError: status.networkError, ...(artifact ? { artifact } : {}) }), { type: 'image', data: shot.data, mimeType: 'image/jpeg' }] };
124
124
  };
125
- const call = async (name, input) => {
125
+ const requireModelControl = () => {
126
126
  if (closed || signal?.aborted) throw new Error('This browser turn has ended.');
127
127
  if (stopped) throw new Error('A person stopped the browser for this turn.');
128
128
  if (status.handoff && name !== 'handoff') throw new Error('The browser is waiting for a person. Continue other work or finish this invocation; the daemon will resume after the handoff.');
129
129
  if (status.mode === 'user') throw new Error('A person has control of the shared browser. Continue other work and retry after they return control.');
130
130
  if (busy) throw new Error('A browser action is already running.');
131
+ };
132
+ const call = async (name, input, scope = wt) => {
133
+ requireModelControl();
131
134
  busy = true;
132
135
  let openingNew = false;
133
136
  try {
@@ -141,22 +144,22 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
141
144
  const path = browserPath(input.path ?? '/');
142
145
  if (typeof input.fixture !== 'string' || input.fixture.trim().length < 12 || input.fixture.length > 2000) throw new Error('Describe the disposable test fixtures before opening the browser.');
143
146
  if (input.hostname != null && input.hostname !== 'localhost') throw new Error('The optional preview hostname must be localhost.');
144
- const measured = measuredOrigin(wt, input.port, undefined, input.scheme);
147
+ const measured = measuredOrigin(scope, input.port, undefined, input.scheme);
145
148
  const upstreamUrl = new URL(measured);
146
149
  const connectHost = upstreamUrl.hostname === '[::1]' ? '::1' : upstreamUrl.hostname;
147
150
  if (input.hostname === 'localhost') upstreamUrl.hostname = 'localhost';
148
151
  const upstream = upstreamUrl.origin;
149
152
  if (!transportStarted) { transportStarted = true; openSocket(); }
150
- if (!page || status.state !== 'live' || pageOrigin !== upstream || pageMeasured !== measured) {
153
+ if (!page || status.state !== 'live' || pageOrigin !== upstream || pageMeasured !== measured || pageScope !== scope) {
151
154
  openingNew = true;
152
- release(); change({ state: 'starting', error: undefined, networkError: undefined, pendingOrigins: [], path });
155
+ release(); change({ state: 'starting', surface: 'browser', target: undefined, error: undefined, networkError: undefined, pendingOrigins: [], path });
153
156
  const opening = pageEpoch;
154
157
  page = await openPage({ origin: upstream, connectHost: input.hostname === 'localhost' ? connectHost : undefined, signal: controller.signal, stillOwned: () => {
155
- try { return measuredOrigin(wt, input.port, undefined, input.scheme) === measured; } catch { return false; }
158
+ try { return measuredOrigin(scope, input.port, undefined, input.scheme) === measured; } catch { return false; }
156
159
  }, onFrame: message => { if (opening === pageEpoch) frame(message); },
157
160
  onChange: patch => { if (opening === pageEpoch && !closed && !stopped) change(patch.error ? { ...patch, state: 'error' } : patch); }, onChild });
158
161
  if (opening !== pageEpoch || closed || stopped) { page.close(); page = null; throw new Error('The browser turn has ended.'); }
159
- pageOrigin = upstream; pageMeasured = measured;
162
+ pageOrigin = upstream; pageMeasured = measured; pageScope = scope;
160
163
  }
161
164
  await page.act({ type: 'navigate', path });
162
165
  change({ state: 'live' });
@@ -172,6 +175,39 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
172
175
  throw error;
173
176
  } finally { busy = false; }
174
177
  };
178
+ /** Runtime adapters share this transport, ownership and private handoff.
179
+ * The factory is daemon-owned; no raw desktop/debugging API crosses the relay. */
180
+ const openSurface = async ({ key, target, surface, open }) => {
181
+ requireModelControl();
182
+ if (!['desktop', 'engine'].includes(surface) || typeof key !== 'string' || key.length > 300 || !/^[A-Za-z0-9_-]{1,60}$/.test(target)) throw new Error('Invalid runtime surface.');
183
+ busy = true;
184
+ try {
185
+ if (!transportStarted) { transportStarted = true; openSocket(); }
186
+ if (!page || status.state !== 'live' || pageOrigin !== key) {
187
+ release(); change({ state: 'starting', surface, target, path: '/', error: undefined, networkError: undefined, pendingOrigins: [] });
188
+ const opening = pageEpoch;
189
+ const opened = await open({ signal: controller.signal, onChild,
190
+ onFrame: message => { if (opening === pageEpoch) frame(message); },
191
+ onChange: patch => {
192
+ if (opening !== pageEpoch || closed || stopped) return;
193
+ if (patch.ended) {
194
+ release(); change({ state: 'ended', mode: 'agent', private: false, handoff: undefined, error: undefined }); endHandoff('returned');
195
+ } else if (patch.error) change({ state: 'error', error: reviewDiagnostic(patch.error) });
196
+ } });
197
+ if (opening !== pageEpoch || closed || stopped) { opened.close(); throw new Error('The interactive turn has ended.'); }
198
+ page = opened; pageOrigin = key;
199
+ if (status.mode === 'user') beginHandoff();
200
+ if (status.private) await page.setPrivate(true);
201
+ }
202
+ change({ state: 'live' });
203
+ return await observe();
204
+ } catch (error) {
205
+ // Taking control while startup is finishing fences this model result;
206
+ // the newly opened app still belongs to the person holding the frame.
207
+ if (status.mode !== 'user') { release(); if (!closed && !stopped) change({ state: 'error', error: reviewDiagnostic(error.message) }); }
208
+ throw error;
209
+ } finally { busy = false; }
210
+ };
175
211
  const control = async msg => {
176
212
  if (msg.type !== 'control' || closed) return;
177
213
  if (msg.action === 'refresh') { if (msg.protocol === 2) status.protocol = 2; change({}); if (lastFrame) send(lastFrame); return; }
@@ -254,19 +290,23 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
254
290
  res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result));
255
291
  } catch (error) { res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ isError: true, content: [text(reviewDiagnostic(error.message))] })); }
256
292
  } });
257
- learnExtraSecrets([held.capability]);
293
+ const secretKey = `browser:${held.capability.slice(0, 12)}`;
294
+ learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_BROWSER_CAPABILITY', value: held.capability }]);
258
295
  const close = async () => {
259
296
  if (closed) return;
260
297
  closed = true; signal?.removeEventListener('abort', abort);
261
298
  clearTimeout(reconnect); clearInterval(ping); clearTimeout(statusRetry); statusRetry = null; endHandoff('closed');
262
299
  controller.abort(); release(); change({ state: 'ended', mode: 'agent' });
263
300
  ws?.close(1000); held.server.close(); held.server.closeAllConnections();
264
- forgetExtraSecrets([held.capability]);
301
+ forgetExtraSecrets(secretKey);
265
302
  };
266
303
  const abort = () => { void close(); };
267
304
  signal?.addEventListener('abort', abort, { once: true });
268
305
  if (signal?.aborted) await close();
269
306
  return { env: held.env, config: { command: process.execPath, args: held.args, env: held.env }, close,
307
+ open: (input, scope = wt) => call('open', input, scope), act: input => call('act', input), handoff: input => call('handoff', input), openSurface,
308
+ assertControl: requireModelControl,
309
+ stopSurface() { requireModelControl(); release(); change({ state: 'ended', mode: 'agent', private: false, handoff: undefined }); },
270
310
  async waitForHandoff() {
271
311
  for (;;) {
272
312
  const result = handoff ? await handoff.promise : handoffResult;
@@ -65,7 +65,7 @@ export async function integrateMergeBatch({ spec, progress = {}, previousError,
65
65
  await report(states);
66
66
  for (const r of rows) {
67
67
  const stamp = resolve(r.wt, git(['rev-parse', '--git-path', 'flowviant-batch-setup'], r.wt));
68
- if (!existsSync(stamp)) { await setup(r.repoRoot, r.wt); writeFileSync(stamp, 'ready'); }
68
+ if (!existsSync(stamp)) { await setup(r.repoRoot, r.wt, { baseRef: r.baseRef }); writeFileSync(stamp, 'ready'); }
69
69
  }
70
70
  const worktrees = rows.map((r) => ({ wt: r.wt, branch: r.branch, repoRoot: r.repoRoot }));
71
71
  if (spec.prMode && pr.ghReady()) throw new Error('This project merges through pull requests. GitHub CLI is not installed or signed in on this machine.');
@@ -77,13 +77,20 @@ export const isClaudeStopTool = name => NATIVE_AGENT_TOOLS.claude.stop.includes(
77
77
  /** Both runtime hook spellings for the capture server. */
78
78
  export const captureToolName = name => String(name ?? '').match(/^(?:mcp__flowviant__|mcp[./]flowviant[./]|flowviant[./])([a-z_]+)$/)?.[1] ?? null;
79
79
 
80
+ /** The card_orchestrator tools that start or continue a subagent on another
81
+ * CLI (2026-11-06, E7; orchestratorProtocol.mjs `CROSS_CLI_TOOLS`), and their
82
+ * bare name off either CLI's spelling of an MCP tool, or null. */
83
+ export const CROSS_CLI_START_TOOLS = Object.freeze(['start_cli_agent', 'followup_cli_agent', 'stop_cli_agent']);
84
+ export const crossCliToolName = name => String(name ?? '').match(/^(?:mcp__card_orchestrator__|mcp[./]card_orchestrator[./]|card_orchestrator[./])(start_cli_agent|followup_cli_agent|cli_agent_result|stop_cli_agent)$/)?.[1] ?? null;
85
+
80
86
  /** What the hook refuses while the controller is unreachable: every spawn and
81
- * follow-up the controller gates, and on a run whose children only read
82
- * (`children` 'read', a research card) a child's write or shell, which only
83
- * the controller can fence read-only. */
84
- export const GATED_TOOLS = Object.freeze([...SPAWN, ...FOLLOWUP]);
87
+ * follow-up the controller gates, a start or follow-up of a subagent on
88
+ * another CLI, and on a run whose children only read (`children` 'read', a
89
+ * research card) a child's write or shell, which only the controller can
90
+ * fence read-only. */
91
+ export const GATED_TOOLS = Object.freeze([...SPAWN, ...FOLLOWUP, ...CROSS_CLI_START_TOOLS]);
85
92
  export const gatedSpawnEvent = (event, children = 'edit', readOnlyRun = false) => event?.hook_event_name === 'PreToolUse' &&
86
- (GATED_TOOLS.includes(nativeName(event.tool_name)) ||
93
+ (GATED_TOOLS.includes(nativeName(event.tool_name)) || CROSS_CLI_START_TOOLS.includes(crossCliToolName(event.tool_name)) ||
87
94
  (readOnlyRun && (isChildWriteTool(event.tool_name) || Boolean(captureToolName(event.tool_name)) || /^(?:mcp__|mcp[./])/.test(String(event.tool_name)))) ||
88
95
  (children === 'read' && Boolean(event.agent_id) && (isChildWriteTool(event.tool_name) || isShellTool(event.tool_name))));
89
96
 
@@ -67,8 +67,10 @@ export function withoutWindows(env, browserHome = null) {
67
67
  /**
68
68
  * EVERY CLI CHILD THE DAEMON SPAWNS UNATTENDED, ONE RULE (2026-10-10, the
69
69
  * audit): no machine credential (machineEnv.mjs), and no way to a window
70
- * unless a person at the keyboard asked for one (`display: true`, a Terminal
71
- * tab's alone). It was composed by hand twice — a turn's `cliEnv`
70
+ * unless a person at the keyboard asked for one (a Terminal tab), or the
71
+ * runtime-review daemon leaf supplied its own private X display/authority.
72
+ * `display: true` is never used to inherit a personal display on a code-agent
73
+ * CLI. It was composed by hand twice — a turn's `cliEnv`
72
74
  * (runTurn.mjs) and the project check's `checkEnv` (workAgentCheck.mjs) — and
73
75
  * skipped outright by the two spawns the daemon makes for its own purposes:
74
76
  * the startup skills probe (runtimeCapabilities.mjs), whose `claude` started
@@ -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
+ }