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.
- package/README.md +364 -0
- package/bin/cli.mjs +23 -0
- package/bin/lib/agentPlan.mjs +8 -0
- package/bin/lib/agentQueueContext.mjs +6 -0
- package/bin/lib/agentSplit.mjs +90 -0
- package/bin/lib/architectureContract.mjs +57 -0
- package/bin/lib/architectureReport.mjs +38 -0
- package/bin/lib/artifactContracts.mjs +21 -2
- package/bin/lib/claudeInput.mjs +1 -1
- package/bin/lib/claudeSubagents.mjs +46 -6
- package/bin/lib/claudeWorkflows.mjs +94 -0
- package/bin/lib/cliLimit.mjs +5 -0
- package/bin/lib/codexInput.mjs +191 -0
- package/bin/lib/codexSubagentEvents.mjs +3 -1
- package/bin/lib/codexSubagents.mjs +34 -9
- package/bin/lib/conversationOutput.mjs +14 -0
- package/bin/lib/crossCliChild.mjs +327 -0
- package/bin/lib/crossCliLaunch.mjs +92 -0
- package/bin/lib/crossCliPosture.mjs +55 -0
- package/bin/lib/crossCliResearch.mjs +19 -0
- package/bin/lib/environmentProcess.mjs +44 -0
- package/bin/lib/fixtures/workflow-ultracode.jsonl +22 -0
- package/bin/lib/fleet.mjs +2 -0
- package/bin/lib/linuxDesktopBridge.mjs +62 -0
- package/bin/lib/managedBrowser.mjs +50 -10
- package/bin/lib/mergeBatch.mjs +1 -1
- package/bin/lib/nativeAgentTools.mjs +12 -5
- package/bin/lib/noWindowEnv.mjs +4 -2
- package/bin/lib/orchestrator.mjs +24 -7
- package/bin/lib/orchestratorClient.mjs +8 -2
- package/bin/lib/orchestratorPrompt.mjs +13 -5
- package/bin/lib/orchestratorProtocol.mjs +18 -0
- package/bin/lib/outsideCli.mjs +124 -0
- package/bin/lib/projectToolRuntimes.mjs +4 -2
- package/bin/lib/projectTools.mjs +2 -2
- package/bin/lib/prompts.mjs +7 -5
- package/bin/lib/repositoryPlaces.mjs +1 -1
- package/bin/lib/repositorySetup.mjs +15 -24
- package/bin/lib/reviewBridge.mjs +110 -0
- package/bin/lib/reviewDatabase.mjs +112 -0
- package/bin/lib/reviewDesktop.mjs +153 -0
- package/bin/lib/reviewEnvironment.mjs +286 -0
- package/bin/lib/reviewEnvironmentConfig.mjs +104 -0
- package/bin/lib/reviewEnvironmentMain.mjs +38 -0
- package/bin/lib/reviewImage.mjs +58 -0
- package/bin/lib/reviewMcpClient.mjs +69 -0
- package/bin/lib/reviewProcess.mjs +51 -0
- package/bin/lib/reviewTargets.mjs +50 -0
- package/bin/lib/robloxReview.mjs +234 -0
- package/bin/lib/robloxStudioBridge.mjs +68 -0
- package/bin/lib/runTurn.mjs +30 -8
- package/bin/lib/runtimeClaude.mjs +7 -3
- package/bin/lib/runtimeCodex.mjs +12 -6
- package/bin/lib/runtimeLimits.mjs +59 -1
- package/bin/lib/runtimeReview.mjs +185 -0
- package/bin/lib/splitGate.mjs +115 -0
- package/bin/lib/subagentBounds.mjs +26 -0
- package/bin/lib/subagentLedger.mjs +110 -0
- package/bin/lib/trace.mjs +134 -6
- package/bin/lib/turnDeclaration.mjs +4 -4
- package/bin/lib/turnGaps.mjs +6 -2
- package/bin/lib/turnSubagents.mjs +23 -9
- package/bin/lib/uiReview.mjs +5 -2
- package/bin/lib/wikiDraft.mjs +9 -0
- package/bin/lib/work.mjs +22 -1
- package/bin/lib/workAgentCheck.mjs +24 -1
- package/bin/lib/workAgentResume.mjs +11 -2
- package/bin/lib/workAgentTools.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +77 -9
- package/bin/lib/workAgentTurnOutcome.mjs +11 -0
- package/bin/lib/workAgentTurnReports.mjs +5 -2
- 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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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;
|
package/bin/lib/mergeBatch.mjs
CHANGED
|
@@ -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,
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
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
|
|
package/bin/lib/noWindowEnv.mjs
CHANGED
|
@@ -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 (
|
|
71
|
-
*
|
|
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
|
package/bin/lib/orchestrator.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|