flowviant 0.125.4 → 0.125.6
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/bin/lib/agentInternalWait.mjs +1 -5
- package/bin/lib/agentPlan.mjs +13 -163
- package/bin/lib/agentRepositories.mjs +3 -1
- package/bin/lib/claudeInput.mjs +28 -9
- package/bin/lib/claudeMachineMcp.mjs +85 -0
- package/bin/lib/claudePersonal.mjs +6 -6
- package/bin/lib/claudePosture.mjs +6 -15
- package/bin/lib/claudeSettingsArg.mjs +67 -0
- package/bin/lib/claudeSubagentProgress.mjs +12 -28
- package/bin/lib/claudeSubagents.mjs +4 -2
- package/bin/lib/codexFencedMount.mjs +275 -0
- package/bin/lib/codexPersonal.mjs +1 -265
- package/bin/lib/codexProjectRead.mjs +5 -4
- package/bin/lib/codexResearch.mjs +9 -3
- package/bin/lib/codexSubagentEvents.mjs +11 -7
- package/bin/lib/codexSubagents.mjs +55 -52
- package/bin/lib/codexToml.mjs +328 -0
- package/bin/lib/conversationIds.mjs +10 -0
- package/bin/lib/fleet.mjs +2 -2
- package/bin/lib/git.mjs +32 -0
- package/bin/lib/heldTool.mjs +49 -0
- package/bin/lib/hooks/mcpAllow.mjs +11 -5
- package/bin/lib/hooks/orchestrator.mjs +20 -10
- package/bin/lib/intakeDraft.mjs +1 -1
- package/bin/lib/jsonlTail.mjs +52 -0
- package/bin/lib/mcpInventory.mjs +9 -5
- package/bin/lib/mcpReasons.mjs +20 -0
- package/bin/lib/mcpReportBounds.mjs +10 -0
- package/bin/lib/mcpStdio.mjs +35 -0
- package/bin/lib/mergeBatch.mjs +6 -24
- package/bin/lib/mergeOrder.mjs +18 -0
- package/bin/lib/modelJson.mjs +173 -0
- package/bin/lib/nativeAgentTools.mjs +72 -0
- package/bin/lib/orchestrator.mjs +212 -133
- package/bin/lib/orchestratorClient.mjs +17 -21
- package/bin/lib/orchestratorHooks.mjs +21 -4
- package/bin/lib/orchestratorNative.mjs +58 -0
- package/bin/lib/orchestratorPrompt.mjs +7 -4
- package/bin/lib/orchestratorProtocol.mjs +25 -3
- package/bin/lib/postureTools.mjs +58 -13
- package/bin/lib/prWorkflow.mjs +13 -2
- package/bin/lib/projectRepositories.mjs +2 -2
- package/bin/lib/projectToolRuntimes.mjs +92 -245
- package/bin/lib/projectTools.mjs +59 -19
- package/bin/lib/repositoryDelivery.mjs +6 -4
- package/bin/lib/repositoryMerge.mjs +8 -12
- package/bin/lib/repositoryPlaces.mjs +2 -2
- package/bin/lib/repositoryPrompts.mjs +42 -0
- package/bin/lib/repositoryTool.mjs +9 -32
- package/bin/lib/researchUse.mjs +39 -0
- package/bin/lib/resumeMarker.mjs +43 -0
- package/bin/lib/runTurn.mjs +38 -52
- package/bin/lib/runtimeAntigravity.mjs +1 -1
- package/bin/lib/runtimeCapabilities.mjs +6 -16
- package/bin/lib/runtimeClaude.mjs +62 -19
- package/bin/lib/runtimeCodex.mjs +9 -6
- package/bin/lib/runtimeEvents.mjs +13 -2
- package/bin/lib/runtimeOfferings.mjs +9 -4
- package/bin/lib/runtimes.mjs +3 -1
- package/bin/lib/subagentBounds.mjs +20 -0
- package/bin/lib/trace.mjs +30 -5
- package/bin/lib/turnDeclaration.mjs +1 -1
- package/bin/lib/turnDiagnostics.mjs +9 -12
- package/bin/lib/turnReceipts.mjs +64 -0
- package/bin/lib/turnSubagents.mjs +91 -0
- package/bin/lib/turnUsage.mjs +9 -6
- package/bin/lib/versionOrder.mjs +11 -1
- package/bin/lib/work.mjs +6 -6
- package/bin/lib/workAgentMerges.mjs +5 -7
- package/bin/lib/workAgentPrecheck.mjs +5 -5
- package/bin/lib/workAgentRecovery.mjs +2 -1
- package/bin/lib/workAgentResume.mjs +121 -0
- package/bin/lib/workAgentTools.mjs +111 -0
- package/bin/lib/workAgentTurnExecution.mjs +62 -246
- package/bin/lib/workAgentTurnOutcome.mjs +30 -3
- package/bin/lib/workAgentTurnShape.mjs +90 -0
- package/bin/lib/workAgentTurns.mjs +24 -10
- package/bin/lib/workArtifactReconcile.mjs +3 -2
- package/bin/lib/workMergeBatch.mjs +7 -5
- package/bin/lib/workPlaceReports.mjs +3 -3
- package/bin/lib/workPlaces.mjs +14 -0
- package/bin/lib/workSessionRuntime.mjs +2 -5
- package/bin/lib/workSessionTurns.mjs +12 -63
- package/bin/lib/workShip.mjs +8 -18
- package/bin/lib/workTurnShape.mjs +6 -4
- package/package.json +1 -1
package/bin/lib/orchestrator.mjs
CHANGED
|
@@ -1,39 +1,48 @@
|
|
|
1
1
|
/** A run-scoped loopback capability. Only this daemon holds fleet credentials;
|
|
2
2
|
* native tools and MCP clients receive a random capability for this one run. */
|
|
3
|
-
import {
|
|
4
|
-
import { randomBytes, randomUUID } from 'node:crypto';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { sameSecret } from './grant.mjs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
7
4
|
import { fleetRequest } from './fleetPost.mjs';
|
|
8
5
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
9
6
|
import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
|
|
10
|
-
import {
|
|
7
|
+
import { listenHeldTool, mintHeldCapability } from './heldTool.mjs';
|
|
11
8
|
import { scrubNativeText } from './runtimeEvents.mjs';
|
|
12
9
|
import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
|
|
13
10
|
import { OrchestratorInputTooLarge, readOrchestratorInput } from './orchestratorInput.mjs';
|
|
11
|
+
import { SAFE_POINT_AFTER_EDIT as AFTER_EDIT } from './orchestratorProtocol.mjs';
|
|
12
|
+
import { MACHINE_MESSAGE_ROUTE_STATES, MESSAGE_ROUTE_FIELD_MAX, OPEN_MESSAGE_ROUTE_STATES, ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
|
|
13
|
+
import { isClaudeStopTool, isDiscoveryTool, isFollowupTool, isPersonQuestionTool, isSpawnTool } from './nativeAgentTools.mjs';
|
|
14
|
+
import { followupTarget, readSpawn, spawnReceipt, withChildContract } from './orchestratorNative.mjs';
|
|
15
|
+
import { SETTLED_SUBAGENT_STATUSES, SUBAGENT_CAP, SUBAGENT_FIELD_CAPS } from './subagentBounds.mjs';
|
|
14
16
|
|
|
15
17
|
const WAIT = 'Waiting for a free slot on the machine';
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const terminal = status => ['done', 'stopped'].includes(status);
|
|
18
|
+
const HELD = 'At the checkpoint for a message';
|
|
19
|
+
const terminal = status => SETTLED_SUBAGENT_STATUSES.includes(status);
|
|
20
|
+
const filled = s => typeof s === 'string' && s.trim().length > 0;
|
|
21
|
+
// A safe point is the model's prose: compare its words, not case or punctuation.
|
|
22
|
+
const pointOf = s => String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ').replace(/^["'“‘`]+|["'”’`.!?;:,…]+$/g, '');
|
|
22
23
|
const text = (s, n) => scrubNativeText(s, n, scrub);
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export async function createOrchestrator({ turnId, runtime, trace, admit, request = fleetRequest, notify = () => {}, taken = () => {},
|
|
27
|
-
interval = 500, scrubText = text } = {}) {
|
|
25
|
+
export async function createOrchestrator({ turnId, runtime, trace, admit, request = fleetRequest, notify = () => false, taken = () => {},
|
|
26
|
+
interval = 500, hold = 60_000, clock = Date.now, scrubText = text } = {}) {
|
|
28
27
|
if (typeof admit !== 'function' || typeof admit.reserve !== 'function') throw new Error('This run has no project admission pool.');
|
|
29
|
-
|
|
28
|
+
// Minted before the server exists, so the scrubber knows it from the start.
|
|
29
|
+
const capability = mintHeldCapability();
|
|
30
30
|
const secretKey = `orchestrator:${turnId}:${capability.slice(0, 8)}`;
|
|
31
31
|
learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_ORCHESTRATOR_CAPABILITY', value: capability }]);
|
|
32
|
-
const records = new Map(), aliases = new Map(), calls = new Map(), notes = new Map(),
|
|
32
|
+
const records = new Map(), aliases = new Map(), calls = new Map(), notes = new Map(), questions = new Map();
|
|
33
|
+
// A person's notes (Codex) and the daemon's wakes queue apart: a burst of
|
|
34
|
+
// wakes never evicts a note input() accepted.
|
|
35
|
+
const inbox = new Map(), wakes = new Map();
|
|
33
36
|
const pending = new Map(), steps = new Map();
|
|
34
|
-
let closed = false, polling = false, rootSession = null;
|
|
37
|
+
let closed = false, polling = false, rootSession = null, offerAt = 0;
|
|
35
38
|
const identity = { scope: 'agent', turnId, instance: DAEMON_INSTANCE };
|
|
36
39
|
const resolve = id => aliases.get(id) ?? id;
|
|
40
|
+
// A re-held note keeps its native id; resolve it when matched, not when held.
|
|
41
|
+
const target = note => note.agentId ? resolve(note.agentId) : undefined;
|
|
42
|
+
const spawnable = a => !a.started && !a.resumable;
|
|
43
|
+
// Both CLIs name a child by agent_id on its hooks; session_id is the root's
|
|
44
|
+
// (measured on Claude 2.1.302 and Codex 0.163).
|
|
45
|
+
const childOf = ev => ev.agent_id ?? (rootSession && ev.session_id && ev.session_id !== rootSession ? ev.session_id : null);
|
|
37
46
|
const emit = update => {
|
|
38
47
|
const id = resolve(update.id), prior = records.get(id) ?? { id };
|
|
39
48
|
const reading = { ...prior, ...update, id };
|
|
@@ -42,24 +51,32 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
42
51
|
records.set(id, reading); trace.agent(reading); return reading;
|
|
43
52
|
};
|
|
44
53
|
const release = id => { const a = records.get(resolve(id)); a?.release?.(); if (a) a.release = null; };
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
// A wake the root CLI did not take (stdin closed; Codex always) waits for
|
|
55
|
+
// the root's next tool hook, where both CLIs read additionalContext.
|
|
56
|
+
const announce = message => {
|
|
57
|
+
if (closed || notify(message) === true) return;
|
|
58
|
+
wakes.set(randomUUID(), message); if (wakes.size > 64) wakes.delete(wakes.keys().next().value);
|
|
59
|
+
};
|
|
60
|
+
const field = (key, value, cap) => { const v = filled(value) ? scrubText(value, cap) : ''; return v.trim() ? { [key]: v } : {}; };
|
|
49
61
|
const route = async args => {
|
|
50
62
|
const note = notes.get(args.messageId);
|
|
51
63
|
if (!note) throw new Error('That note is not held by this run.');
|
|
52
|
-
if (!
|
|
53
|
-
const id = args.agentId ? resolve(args.agentId) : undefined;
|
|
64
|
+
if (!MACHINE_MESSAGE_ROUTE_STATES.includes(args.state)) throw new Error('Choose queued, handed or taken.');
|
|
65
|
+
const id = filled(args.agentId) ? resolve(args.agentId) : undefined;
|
|
54
66
|
const a = id ? records.get(id) : null;
|
|
55
|
-
if (args.state !== 'taken' && (!a || !args.safePoint || !args.kind)) throw new Error('Name the agent, its kind and this note’s safe point.');
|
|
56
|
-
|
|
67
|
+
if (args.state !== 'taken' && (!a || !filled(args.safePoint) || !filled(args.kind))) throw new Error('Name the agent, its kind and this note’s safe point.');
|
|
68
|
+
// A child held at a checkpoint has started; only a spawn still waiting for its slot cannot take a note.
|
|
69
|
+
if (args.state === 'handed' && a.status === 'waiting' && spawnable(a)) throw new Error('The agent has not started yet. Keep this note queued.');
|
|
57
70
|
// Flush the descriptor before the holder-bound route write validates it.
|
|
58
71
|
await trace.flush(1_000);
|
|
59
|
-
|
|
72
|
+
// Only what exists is reported: the API refuses a whole route over an unknown agent or an empty field.
|
|
73
|
+
const next = { state: args.state, runtime, ...(a ? { agentId: trace.agentId(id), nativeAgentId: scrubText(a.nativeId ?? id, MESSAGE_ROUTE_FIELD_MAX.id),
|
|
74
|
+
...field('kind', args.kind, MESSAGE_ROUTE_FIELD_MAX.kind), ...field('safePoint', args.safePoint, MESSAGE_ROUTE_FIELD_MAX.safePoint) } : {}) };
|
|
60
75
|
const result = await request(fleetEndpoint('agent-message-route', FLEET_URL), { body: { ...identity, messageId: args.messageId, route: next }, timeoutMs: 10_000 });
|
|
61
76
|
if (!result.ok || !result.json?.data?.reported) throw new Error('The route was not recorded. Re-read the note and retry.');
|
|
62
|
-
Object.assign(note, args, { agentId: id });
|
|
77
|
+
Object.assign(note, args, { agentId: a ? id : undefined, paused: false, delivering: false });
|
|
78
|
+
if (a && args.state !== 'queued' && a.status === 'waiting' && a.now === HELD && ![...notes.values()].some(n => n.paused && n.state === 'queued' && target(n) === id))
|
|
79
|
+
emit({ id, status: 'running', now: 'Continuing with the orchestrator’s note' });
|
|
63
80
|
taken(args.messageId); // the model naming the UUID proves receipt
|
|
64
81
|
// The API stores the handoff atomically with the route; a crash between
|
|
65
82
|
// this receipt and a trace flush must not lose or duplicate that item.
|
|
@@ -72,125 +89,181 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
72
89
|
a.release = admit.reserve();
|
|
73
90
|
return true;
|
|
74
91
|
}
|
|
75
|
-
|
|
92
|
+
// A slot the timer holds belongs to the run's waiting spawns, not to one
|
|
93
|
+
// exact prompt: a reworded retry takes it, by title or kind, else the oldest.
|
|
94
|
+
// Without one, a retry of the same brief or title is still that one record.
|
|
95
|
+
const claim = ({ brief, title, kind }) => {
|
|
96
|
+
const open = [...records.values()].filter(a => a.status === 'waiting' && spawnable(a)), held = open.filter(a => a.release);
|
|
97
|
+
return held.find(a => a.brief === brief) ?? held.find(a => title && a.title === title) ?? held.find(a => kind && a.kind === kind)
|
|
98
|
+
?? held[0] ?? open.find(a => a.brief === brief) ?? open.find(a => title && a.title === title);
|
|
99
|
+
};
|
|
100
|
+
// Admitted: the offer is spent, and notes that paused this child are being
|
|
101
|
+
// delivered, so its checkpoint lets it work until their route lands.
|
|
102
|
+
const start = (a, now) => {
|
|
103
|
+
a.started = true; a.denied = null; a.heldAt = null; a.stops = 0;
|
|
104
|
+
for (const n of notes.values()) if (n.paused && n.state === 'queued' && target(n) === a.id) n.delivering = true;
|
|
105
|
+
if (questions.get(a.id)?.answer) questions.get(a.id).read = true;
|
|
106
|
+
emit({ id: a.id, status: 'running', now });
|
|
107
|
+
};
|
|
76
108
|
function preSpawn(event) {
|
|
77
|
-
const
|
|
78
|
-
if (!
|
|
79
|
-
const
|
|
80
|
-
const resumed =
|
|
81
|
-
const id = resumed ??
|
|
109
|
+
const spawn = readSpawn(event);
|
|
110
|
+
if (!spawn) return { deny: 'Give this native spawn a text prompt or message so the run can gate it.' };
|
|
111
|
+
const { callId, brief } = spawn;
|
|
112
|
+
const resumed = spawn.resume !== null && records.has(resolve(spawn.resume)) ? resolve(spawn.resume) : null;
|
|
113
|
+
const id = resumed ?? claim(spawn)?.id ?? resolve(callId);
|
|
82
114
|
aliases.set(callId, id); calls.set(callId, id);
|
|
83
115
|
if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
|
|
84
116
|
if (calls.size > 128) calls.delete(calls.keys().next().value);
|
|
85
|
-
if (!records.has(id) && records.size >=
|
|
86
|
-
const a = emit({ id, brief:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
a.
|
|
91
|
-
emit({ id, status: 'running', now: a.title });
|
|
92
|
-
const promptKey = typeof input.prompt === 'string' ? 'prompt' : 'message';
|
|
117
|
+
if (!records.has(id) && records.size >= SUBAGENT_CAP) return { deny: `This run already has ${SUBAGENT_CAP} agent records. Continue the work yourself.` };
|
|
118
|
+
const a = emit({ id, brief, title: spawn.title ?? '', kind: spawn.kind ?? '', status: 'waiting', now: WAIT });
|
|
119
|
+
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.` }; }
|
|
120
|
+
// Claude's SendMessage addresses a child by its Agent `name`, Codex V2 by its task name.
|
|
121
|
+
for (const name of spawn.names) if (filled(name) && name.length <= 160) aliases.set(name, id);
|
|
122
|
+
start(a, a.title);
|
|
93
123
|
const childContract = `\n\nCard orchestrator agent record id: ${id}. FIRST call card_orchestrator.register_agent with agentId=${id} before doing work (tool discovery is allowed). Share this worktree and preserve others’ edits. Ask questions through card_orchestrator.agent_question with agentId=${id}; read its answer before proceeding. Only your orchestrator escalates person-only decisions. Return your work to it; it owns the card’s final commit and JSON.`;
|
|
94
|
-
return { context: `Agent record id: ${id}. Use it with card_orchestrator for routing and questions
|
|
95
|
-
updatedInput:
|
|
124
|
+
return { context: `Agent record id: ${id}. Use it with card_orchestrator for routing and questions.${spawn.relocated ? ' Its isolation and cwd were removed: children share this card’s worktree on this machine.' : ''}`,
|
|
125
|
+
updatedInput: withChildContract(event, spawn, childContract) };
|
|
126
|
+
}
|
|
127
|
+
// Native readings never revive a settled record or start a waiting spawn
|
|
128
|
+
// or follow-up (Codex records a refused call's start anyway); only an
|
|
129
|
+
// admitted spawn or follow-up does.
|
|
130
|
+
// …except the CLI's own run of a follow-up admitted while the child ran,
|
|
131
|
+
// counted again if its held slot already lapsed.
|
|
132
|
+
const resumeQueued = a => { a.queued = false; slot(a.id); emit({ id: a.id, status: 'running', now: 'Continuing with the orchestrator’s note' }); };
|
|
133
|
+
const apply = (id, update) => {
|
|
134
|
+
const a = records.get(id), { status: _status, now: _now, ...metadata } = update;
|
|
135
|
+
if (terminal(a.status) && a.queued && update.status === 'running') { resumeQueued(a); return; }
|
|
136
|
+
if ((a.status === 'waiting' && (!a.started || a.denied)) || terminal(a.status)) { emit({ ...metadata, id }); return; }
|
|
137
|
+
if (terminal(update.status)) { settle(id, update); return; }
|
|
138
|
+
emit({ ...update, id, ...(questions.has(id) && !questions.get(id).answer ? { status: 'asked' } : {}) });
|
|
139
|
+
};
|
|
140
|
+
function settle(id, update) {
|
|
141
|
+
const a = records.get(id), live = Boolean(a.release) && !a.denied;
|
|
142
|
+
// Claude 2.1.302 and Codex 0.163 run a child again when it finished before
|
|
143
|
+
// a follow-up queued while it ran reached a tool round: hold its slot until
|
|
144
|
+
// that run's own stop.
|
|
145
|
+
if (a.stops > 1) a.queued = false;
|
|
146
|
+
if (!a.queued) release(id); else a.heldAt ??= clock();
|
|
147
|
+
if (terminal(a.status)) return;
|
|
148
|
+
emit({ ...update, id, ...(questions.has(id) && !questions.get(id).answer ? { status: 'asked' } : {}) });
|
|
149
|
+
if (live) for (const note of notes.values()) if (note.state === 'queued' && target(note) === id)
|
|
150
|
+
announce(`Agent ${id} finished. Deliver note ${note.messageId} in its next task or a native follow-up and report handed at that checkpoint.`);
|
|
96
151
|
}
|
|
97
152
|
const bind = (native, id) => {
|
|
98
153
|
if (typeof native !== 'string' || native.length > 160 || !id) return;
|
|
99
154
|
const target = resolve(id); aliases.set(native, target);
|
|
100
155
|
if (records.has(target)) records.get(target).nativeId = native;
|
|
101
156
|
if (pending.has(native)) {
|
|
102
|
-
const update = pending.get(native);
|
|
103
|
-
if (
|
|
104
|
-
emit({ ...update, id: target }); pending.delete(native);
|
|
157
|
+
const update = pending.get(native); pending.delete(native);
|
|
158
|
+
if (records.has(target)) apply(target, update);
|
|
105
159
|
}
|
|
106
160
|
for (const step of steps.get(native) ?? []) step(target);
|
|
107
161
|
steps.delete(native);
|
|
108
162
|
if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
|
|
109
163
|
};
|
|
164
|
+
// Codex V2 names a child `backend` or `/root/backend` interchangeably.
|
|
165
|
+
const named = native => {
|
|
166
|
+
if (aliases.has(native) || records.has(native)) return resolve(native);
|
|
167
|
+
const hits = new Set([...aliases].filter(([k]) => k.endsWith(`/${native}`) || native.endsWith(`/${k}`)).map(([, v]) => v));
|
|
168
|
+
return hits.size === 1 ? [...hits][0] : native;
|
|
169
|
+
};
|
|
110
170
|
function postSpawn(event) {
|
|
111
171
|
const id = calls.get(event.tool_use_id);
|
|
112
172
|
if (!id) return;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
try { if (typeof output === 'string') output = JSON.parse(output); } catch { /* native prose */ }
|
|
117
|
-
bind(output?.agent_id ?? output?.agentId ?? output?.thread_id ?? output?.agent_path, id);
|
|
118
|
-
for (const block of output?.content ?? []) {
|
|
119
|
-
if (typeof block.text !== 'string') continue;
|
|
120
|
-
try { const parsed = JSON.parse(block.text); bind(parsed.agent_id ?? parsed.thread_id ?? parsed.agent_path, id); } catch { /* prose */ }
|
|
121
|
-
}
|
|
122
|
-
if (output?.is_error || output?.isError || event.error) { release(id); emit({ id, status: 'stopped' }); }
|
|
173
|
+
const { ids, failed } = spawnReceipt(event);
|
|
174
|
+
for (const native of ids) bind(native, id);
|
|
175
|
+
if (failed) { release(id); emit({ id, status: 'stopped' }); }
|
|
123
176
|
}
|
|
124
177
|
function preResume(event) {
|
|
125
|
-
const input = event.tool_input ?? {};
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const id = resolve(native);
|
|
178
|
+
const input = event.tool_input ?? {}, { claude, native } = followupTarget(event);
|
|
179
|
+
if (native === null) return claude ? null : { deny: 'Name the native agent to resume.' };
|
|
180
|
+
const id = named(native);
|
|
129
181
|
if (!records.has(id)) {
|
|
130
|
-
|
|
131
|
-
|
|
182
|
+
// SendMessage also reaches "main" and teammates: only a known child is a follow-up.
|
|
183
|
+
if (claude) return null;
|
|
184
|
+
if (records.size >= SUBAGENT_CAP) return { deny: 'Continue the work in this run’s orchestrator.' };
|
|
185
|
+
emit({ id, kind: '', title: native, brief: typeof input.message === 'string' ? input.message : '', status: 'waiting', now: WAIT, resumable: true });
|
|
132
186
|
}
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
187
|
+
if (spawnable(records.get(id))) return { deny: `Agent ${id} has not started. Retry its native spawn instead.` };
|
|
188
|
+
// A refused follow-up retried on its offered slot reaches a finished child.
|
|
189
|
+
const prior = records.get(id), running = Boolean(prior.release) && !prior.denied && !terminal(prior.status);
|
|
190
|
+
if (!slot(id)) { records.get(id).denied = 'resume'; emit({ id, status: 'waiting', now: WAIT }); return { deny: `${WAIT}. Retry the native follow-up when notified. Agent id: ${id}` }; }
|
|
191
|
+
start(records.get(id), 'Continuing with the orchestrator’s note');
|
|
192
|
+
records.get(id).queued = running;
|
|
136
193
|
calls.set(event.tool_use_id, id);
|
|
137
194
|
return typeof input.message === 'string' ? { updatedInput: { ...input,
|
|
138
195
|
message: input.message + `\n\nCurrent card orchestrator agent record id: ${id}. Before continuing work, call card_orchestrator.register_agent with this id. Return questions to the orchestrator.` } } : {};
|
|
139
196
|
}
|
|
140
197
|
function checkpoint(event) {
|
|
141
|
-
const
|
|
198
|
+
const child = childOf(event);
|
|
199
|
+
if (!child || isDiscoveryTool(event.tool_name) || String(event.tool_name).includes(ORCHESTRATOR_SERVER)) return {};
|
|
200
|
+
const id = resolve(child);
|
|
142
201
|
if (!records.has(id)) return {};
|
|
143
|
-
const queued = [...notes.values()].filter(n => n.state === 'queued' && n.agentId === id);
|
|
144
|
-
if (!queued.length) return {};
|
|
145
202
|
// The orchestrator, not this hook, chose each checkpoint. Pause tool
|
|
146
|
-
// execution at its next boundary and ask the parent
|
|
147
|
-
// follow-up. The child keeps its worktree and transcript intact.
|
|
148
|
-
const edits =
|
|
203
|
+
// execution at its next boundary, once per note, and ask the parent for a
|
|
204
|
+
// native follow-up. The child keeps its worktree and transcript intact.
|
|
205
|
+
const edits = [...notes.values()].filter(n => n.state === 'queued' && target(n) === id && !n.delivering && pointOf(n.safePoint) === AFTER_EDIT);
|
|
149
206
|
if (!edits.length) return {};
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
207
|
+
const fresh = edits.filter(n => !n.paused);
|
|
208
|
+
if (!fresh.length) return { deny: 'Remain at the checkpoint until your orchestrator sends the follow-up. Your card_orchestrator tools still work.' };
|
|
209
|
+
for (const n of fresh) n.paused = true;
|
|
210
|
+
const native = records.get(id).nativeId;
|
|
211
|
+
emit({ id, status: 'waiting', now: HELD });
|
|
212
|
+
announce(`Agent ${id} is between tools. ${runtime === 'claude' ? `Send it note ${fresh.map(n => n.messageId).join(' ')} with SendMessage to ${native ?? 'its agent id'}; it arrives at its next tool round` :
|
|
213
|
+
`Stop it with the native tool and resume/follow up with note ${fresh.map(n => n.messageId).join(' ')} plus its work so far`}. Then report handed.`);
|
|
154
214
|
return { deny: 'Your orchestrator has a note for this checkpoint. Stop editing and wait for its follow-up; keep your work so far.' };
|
|
155
215
|
}
|
|
216
|
+
function hook(ev) {
|
|
217
|
+
// A child's next tool round carries a follow-up queued to it; after its
|
|
218
|
+
// stop, its own tool use is the CLI running it again with that follow-up.
|
|
219
|
+
const kid = records.get(resolve(childOf(ev)));
|
|
220
|
+
if (kid && ['PreToolUse', 'PostToolUse'].includes(ev.hook_event_name)) { if (kid.queued && terminal(kid.status)) resumeQueued(kid); kid.queued = false; }
|
|
221
|
+
// A native stop (TaskStop takes the agent id or name as task_id) cancels that resume.
|
|
222
|
+
const stopped = ev.hook_event_name === 'PreToolUse' && isClaudeStopTool(ev.tool_name) && records.get(named(String(ev.tool_input?.task_id ?? '')));
|
|
223
|
+
if (stopped) { stopped.queued = false; if (terminal(stopped.status)) release(stopped.id); }
|
|
224
|
+
if (ev.hook_event_name === 'SubagentStop') { const id = resolve(childOf(ev)); if (records.has(id)) { records.get(id).stops = (records.get(id).stops ?? 0) + 1; settle(id, { status: 'done' }); } }
|
|
225
|
+
if (ev.hook_event_name === 'PostToolUseFailure' && (isSpawnTool(ev.tool_name) || isFollowupTool(ev.tool_name))) {
|
|
226
|
+
const id = calls.get(ev.tool_use_id); if (id && records.get(id)?.started) { release(id); emit({ id, status: 'stopped', now: 'The native spawn did not start' }); }
|
|
227
|
+
}
|
|
228
|
+
if (ev.hook_event_name === 'PreToolUse') {
|
|
229
|
+
const child = childOf(ev);
|
|
230
|
+
if (String(ev.tool_name).includes(ORCHESTRATOR_SERVER) && String(ev.tool_name).endsWith('register_agent')) {
|
|
231
|
+
// Binding the root would pause the orchestrator at its children's checkpoints.
|
|
232
|
+
if (!child) return { deny: 'Only a native child registers an agent record. As the orchestrator, use the record id with describe_agent, route_message or answer_agent.' };
|
|
233
|
+
const id = resolve(ev.tool_input?.agentId);
|
|
234
|
+
if (!records.has(id)) return { deny: 'Use the agent record id in your prompt.' };
|
|
235
|
+
bind(child, id);
|
|
236
|
+
}
|
|
237
|
+
if (child && !records.has(resolve(child)) && !isDiscoveryTool(ev.tool_name))
|
|
238
|
+
return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
|
|
239
|
+
if (isSpawnTool(ev.tool_name)) return preSpawn(ev);
|
|
240
|
+
if (isFollowupTool(ev.tool_name)) { const resumed = preResume(ev); if (resumed) return resumed; }
|
|
241
|
+
const point = checkpoint(ev); if (point.deny) return point;
|
|
242
|
+
if (isPersonQuestionTool(ev.tool_name))
|
|
243
|
+
return { deny: 'A child asks its orchestrator with agent_question first. The orchestrator escalates only person-only decisions through the existing blocked JSON question card.' };
|
|
244
|
+
}
|
|
245
|
+
if (ev.hook_event_name === 'PostToolUse' && (isSpawnTool(ev.tool_name) || isFollowupTool(ev.tool_name))) postSpawn(ev);
|
|
246
|
+
return {};
|
|
247
|
+
}
|
|
156
248
|
async function handle(action, args) {
|
|
157
249
|
if (closed) throw new Error('This run stopped.');
|
|
158
250
|
if (action === 'hook') {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (String(ev.tool_name).includes('card_orchestrator') && String(ev.tool_name).endsWith('register_agent')) {
|
|
166
|
-
const id = resolve(ev.tool_input?.agentId);
|
|
167
|
-
if (!records.has(id)) return { deny: 'Use the agent record id in your prompt.' };
|
|
168
|
-
bind(ev.agent_id ?? ev.session_id, id);
|
|
169
|
-
}
|
|
170
|
-
const child = ev.agent_id ?? (rootSession && ev.session_id !== rootSession ? ev.session_id : null);
|
|
171
|
-
if (child && !records.has(resolve(child)) && !['tool_search', 'search_tool'].includes(nativeName(ev.tool_name)))
|
|
172
|
-
return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
|
|
173
|
-
if (spawnTool(ev.tool_name)) return preSpawn(ev);
|
|
174
|
-
if (resumeTool(ev.tool_name)) return preResume(ev);
|
|
175
|
-
const point = checkpoint(ev); if (point.deny) return point;
|
|
176
|
-
if (['AskUserQuestion', 'request_user_input'].includes(nativeName(ev.tool_name)))
|
|
177
|
-
return { deny: 'A child asks its orchestrator with agent_question first. The orchestrator escalates only person-only decisions through the existing blocked JSON question card.' };
|
|
178
|
-
}
|
|
179
|
-
if (ev.hook_event_name === 'PostToolUse' && (spawnTool(ev.tool_name) || resumeTool(ev.tool_name))) postSpawn(ev);
|
|
180
|
-
if (runtime === 'codex' && !ev.agent_id && rootSession && ev.session_id === rootSession) {
|
|
181
|
-
const context = [...inbox.entries()].slice(0, 4); for (const [id] of context) inbox.delete(id);
|
|
182
|
-
return { context: context.map(([, body]) => body).join('\n\n') };
|
|
183
|
-
}
|
|
184
|
-
return {};
|
|
251
|
+
const result = hook(args);
|
|
252
|
+
// Root PreToolUse/PostToolUse additionalContext is measured on both CLIs.
|
|
253
|
+
if (result.deny || !['PreToolUse', 'PostToolUse'].includes(args.hook_event_name) || args.agent_id || !rootSession || args.session_id !== rootSession) return result;
|
|
254
|
+
const held = [];
|
|
255
|
+
for (const box of [inbox, wakes]) for (const [key, body] of box) if (held.length < 4) { box.delete(key); held.push(body); }
|
|
256
|
+
return held.length ? { ...result, context: [result.context, ...held].filter(Boolean).join('\n\n') } : result;
|
|
185
257
|
}
|
|
186
258
|
if (action === 'route_message') return route(args);
|
|
187
259
|
const id = resolve(args.agentId);
|
|
188
260
|
// A person’s answer starts a new CLI turn. The parent carries the earlier
|
|
189
261
|
// question and native identity in its own conversation; relay that known
|
|
190
262
|
// context instead of inventing a new child or requiring another question.
|
|
191
|
-
if (action === 'answer_agent' && !records.has(id) && typeof id === 'string' && id.length > 0 && id.length <= 160 && records.size <
|
|
263
|
+
if (action === 'answer_agent' && !records.has(id) && typeof id === 'string' && id.length > 0 && id.length <= 160 && records.size < SUBAGENT_CAP &&
|
|
192
264
|
typeof args.question === 'string' && args.question.trim() && args.question.length <= 4_000) {
|
|
193
|
-
|
|
265
|
+
// Resumable, never spawnable: no slot is offered until a gated follow-up targets it.
|
|
266
|
+
emit({ id, kind: scrubText(args.kind ?? '', SUBAGENT_FIELD_CAPS.kind), title: scrubText(args.title ?? '', SUBAGENT_FIELD_CAPS.title), brief: '', status: 'asked', now: args.question, resumable: true });
|
|
194
267
|
questions.set(id, { question: args.question });
|
|
195
268
|
if (args.nativeAgentId) bind(args.nativeAgentId, id);
|
|
196
269
|
}
|
|
@@ -198,32 +271,36 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
198
271
|
if (!a) throw new Error('Name an agent in this run.');
|
|
199
272
|
if (action === 'register_agent') return { registered: true, agentId: id };
|
|
200
273
|
if (action === 'describe_agent') {
|
|
201
|
-
if (typeof args.kind !== 'string' || !args.kind.trim() || args.kind.length >
|
|
274
|
+
if (typeof args.kind !== 'string' || !args.kind.trim() || args.kind.length > SUBAGENT_FIELD_CAPS.kind || typeof args.title !== 'string' || !args.title.trim() || args.title.length > SUBAGENT_FIELD_CAPS.title) throw new Error('Give this agent a bounded kind and title.');
|
|
202
275
|
emit({ id, kind: args.kind, title: args.title, namedKind: args.kind, namedTitle: args.title });
|
|
203
276
|
return { recorded: true };
|
|
204
277
|
}
|
|
205
278
|
if (action === 'agent_question') {
|
|
206
279
|
let q = questions.get(id);
|
|
280
|
+
// The person answers in a later run; the child ends now so this run settles on the question card.
|
|
281
|
+
if (q?.escalated && !q.answer) return { waiting: true, escalated: true,
|
|
282
|
+
instruction: 'Your orchestrator escalated this to the person. Stop now: end your work with a short summary of where you are. You will be continued with the answer.' };
|
|
283
|
+
// A given answer stands for a reworded poll until the child has it.
|
|
284
|
+
if (q?.answer && (!q.read || q.question === args.question)) { q.read = true; return { answer: q.answer }; }
|
|
207
285
|
if (!q || q.question !== args.question) {
|
|
208
286
|
if (typeof args.question !== 'string' || args.question.length > 4_000) throw new Error('Keep the question within 4000 characters.');
|
|
209
287
|
q = { question: args.question }; questions.set(id, q); emit({ id, status: 'asked', now: args.question });
|
|
210
288
|
trace.prose('note', args.question, id);
|
|
211
289
|
announce(`${a.kind || a.title || id} asks: ${args.question}\nAnswer with answer_agent from the card, code and earlier notes if possible. Escalate only a real person-only decision.`);
|
|
212
290
|
}
|
|
213
|
-
return
|
|
291
|
+
return { waiting: true, escalated: false };
|
|
214
292
|
}
|
|
215
293
|
if (action === 'answer_agent') {
|
|
216
294
|
const q = questions.get(id); if (!q) throw new Error('That agent has no unanswered question.');
|
|
217
295
|
if (args.escalate === true) { q.escalated = true; return { askingAgent: { id, nativeAgentId: a.nativeId, kind: a.kind, title: a.title },
|
|
218
|
-
question: `${a.kind || a.title || id} asks: ${q.question}`, instruction: '
|
|
296
|
+
question: `${a.kind || a.title || id} asks: ${q.question}`, instruction: 'The child is told to stop. Once every child has finished or been stopped, return the existing blocked JSON question card with this attribution. Leave the agent asked until the person answers.' }; }
|
|
219
297
|
if (typeof args.answer !== 'string' || !args.answer.trim() || args.answer.length > 4_000) throw new Error('Give the agent a bounded answer.');
|
|
220
298
|
q.answer = args.answer; trace.handoff(id, runtime, args.answer); emit({ id, status: a.release ? 'running' : 'waiting', now: 'Question answered by the orchestrator' });
|
|
221
299
|
return { answered: true, instruction: 'Give this answer to the child with its native follow-up/resume tool.' };
|
|
222
300
|
}
|
|
223
301
|
throw new Error('Unknown orchestration action.');
|
|
224
302
|
}
|
|
225
|
-
const
|
|
226
|
-
if (closed || req.method !== 'POST' || req.url !== '/' || !sameSecret(req.headers.authorization, `Bearer ${capability}`)) { res.writeHead(403).end(); return; }
|
|
303
|
+
const respond = async (req, res) => {
|
|
227
304
|
try {
|
|
228
305
|
const { action, args } = await readOrchestratorInput(req);
|
|
229
306
|
const result = await handle(action, args ?? {});
|
|
@@ -232,18 +309,28 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
232
309
|
{ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify({
|
|
233
310
|
error: text(error.message, 300), ...(error instanceof OrchestratorInputTooLarge ? { code: error.code } : {}),
|
|
234
311
|
})); }
|
|
235
|
-
}
|
|
236
|
-
server
|
|
237
|
-
|
|
238
|
-
const env = { FLOWVIANT_ORCHESTRATOR_URL: `http://127.0.0.1:${server.address().port}/`, FLOWVIANT_ORCHESTRATOR_CAPABILITY: capability };
|
|
239
|
-
const argv = runningCompiledBinary() ? ['__orchestrator-mcp'] : [fileURLToPath(new URL('../cli.mjs', import.meta.url)), '__orchestrator-mcp'];
|
|
312
|
+
};
|
|
313
|
+
const { server, env, args: argv } = await listenHeldTool({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: 5_000,
|
|
314
|
+
capability, isClosed: () => closed, respond });
|
|
240
315
|
const timer = setInterval(() => {
|
|
241
316
|
if (closed || polling) return;
|
|
242
317
|
polling = true;
|
|
243
318
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
319
|
+
const now = clock();
|
|
320
|
+
for (const a of records.values()) {
|
|
321
|
+
// A follow-up sent during the child's last tool round reaches it there
|
|
322
|
+
// and resumes nothing; on Claude no post hook says so (2.1.302).
|
|
323
|
+
if (a.queued && a.release && terminal(a.status)) { if (now - (a.heldAt ??= now) >= hold) release(a.id); continue; }
|
|
324
|
+
if (!a.denied) continue;
|
|
325
|
+
// An offer the parent leaves unused lapses: the slot returns to the
|
|
326
|
+
// project's other turns, and this run is offered one again later.
|
|
327
|
+
if (a.release) { if (now - (a.heldAt ?? now) >= hold) { release(a.id); offerAt = now + hold; emit({ id: a.id, status: 'waiting', now: WAIT }); } continue; }
|
|
328
|
+
if (now < offerAt || !slot(a.id)) continue;
|
|
329
|
+
a.heldAt = now;
|
|
330
|
+
const resume = a.denied === 'resume';
|
|
331
|
+
emit({ id: a.id, status: 'waiting', now: resume ? 'Ready to continue' : 'Ready to start' });
|
|
332
|
+
announce(resume ? `A slot is available for agent ${a.id}. Retry its native follow-up${runtime === 'claude' && a.nativeId ? ` (SendMessage to ${a.nativeId})` : ''} now; the slot is held for about a minute. If it is no longer needed, finish the run yourself.`
|
|
333
|
+
: `A slot is available for agent ${a.id}. Retry its native spawn now, with the same or a reworded prompt; the slot is held for about a minute. If it is no longer needed, finish the run yourself.`);
|
|
247
334
|
}
|
|
248
335
|
} finally { polling = false; }
|
|
249
336
|
}, interval);
|
|
@@ -260,37 +347,29 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
260
347
|
attributed(native, step) {
|
|
261
348
|
const id = resolve(native);
|
|
262
349
|
if (records.has(id)) { step(id); return; }
|
|
263
|
-
if (!steps.has(native) && steps.size >=
|
|
350
|
+
if (!steps.has(native) && steps.size >= SUBAGENT_CAP) return;
|
|
264
351
|
const queue = steps.get(native) ?? []; queue.push(step); if (queue.length > 40) queue.shift(); steps.set(native, queue);
|
|
265
352
|
},
|
|
266
353
|
note(message) { if (!notes.has(message.id) && notes.size < 64) notes.set(message.id, { ...message, messageId: message.id,
|
|
267
|
-
state: message.route?.state ?? 'deciding', ...(message.route?.nativeAgentId ? { agentId:
|
|
268
|
-
outstanding() { return [...notes.values()].filter(n =>
|
|
354
|
+
state: message.route?.state ?? 'deciding', ...(message.route?.nativeAgentId ? { agentId: message.route.nativeAgentId, safePoint: message.route.safePoint } : {}) }); },
|
|
355
|
+
outstanding() { return [...notes.values()].filter(n => OPEN_MESSAGE_ROUTE_STATES.includes(n.state)).map(n => ({ id: n.messageId, state: n.state, body: n.body, agentId: target(n), safePoint: n.safePoint })); },
|
|
269
356
|
input(id, body) { if (closed || inbox.size >= 64 && !inbox.has(id)) return false; inbox.set(id, body); return true; },
|
|
270
357
|
observe(update) {
|
|
271
358
|
if (update.nativeId) bind(update.nativeId, update.id);
|
|
272
359
|
if (update.callId && calls.has(update.callId)) bind(update.id, calls.get(update.callId));
|
|
273
|
-
|
|
360
|
+
const id = resolve(update.id);
|
|
274
361
|
if (!records.has(id)) {
|
|
275
362
|
// Bind from a native receipt, never a guessed match on title/kind.
|
|
276
363
|
if (pending.size < 128 || pending.has(id)) pending.set(id, { ...pending.get(id), ...update });
|
|
277
364
|
return;
|
|
278
365
|
}
|
|
279
|
-
|
|
280
|
-
if (a?.status === 'waiting' && !a.started) {
|
|
281
|
-
const { status: _status, now: _now, ...metadata } = update;
|
|
282
|
-
emit({ ...metadata, id }); return;
|
|
283
|
-
}
|
|
284
|
-
const finished = terminal(update.status);
|
|
285
|
-
if (finished) release(id);
|
|
286
|
-
if (questions.has(id) && !questions.get(id).answer) update = { ...update, status: 'asked' };
|
|
287
|
-
emit({ ...update, id });
|
|
288
|
-
if (finished) for (const note of notes.values()) if (note.state === 'queued' && note.agentId === id)
|
|
289
|
-
announce(`Agent ${id} finished. Deliver note ${note.messageId} in its next task or a native follow-up and report handed at that checkpoint.`);
|
|
366
|
+
apply(id, update);
|
|
290
367
|
},
|
|
291
368
|
async close(stopped = false) {
|
|
292
369
|
closed = true; clearInterval(timer);
|
|
293
|
-
|
|
370
|
+
// Only an escalated question waits for the person; any other asked child ended with the run.
|
|
371
|
+
for (const a of records.values()) { release(a.id); const q = questions.get(a.id);
|
|
372
|
+
if (!terminal(a.status) && !(a.status === 'asked' && !stopped && q?.escalated && !q.answer)) emit({ id: a.id, status: 'stopped', now: stopped ? 'Stopped with the run' : 'Run ended' }); }
|
|
294
373
|
server.close(); server.closeAllConnections();
|
|
295
374
|
forgetExtraSecrets(secretKey);
|
|
296
375
|
},
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import { createInterface } from 'node:readline';
|
|
2
1
|
import { ORCHESTRATOR_TOOLS } from './orchestratorProtocol.mjs';
|
|
3
2
|
import { ORCHESTRATOR_REQUEST_BYTES, ORCHESTRATOR_INPUT_TOO_LARGE, OrchestratorInputTooLarge } from './orchestratorInput.mjs';
|
|
3
|
+
import { serveMcpStdio } from './mcpStdio.mjs';
|
|
4
|
+
|
|
5
|
+
/** Below the CLIs' own hook timeout (orchestratorHooks.mjs): a CLI that times
|
|
6
|
+
* a hook out does not block the tool, so the gate must answer first. */
|
|
7
|
+
export const ORCHESTRATOR_CLIENT_TIMEOUT_MS = 15_000;
|
|
8
|
+
|
|
9
|
+
/** The controller's own refusal (a 409 body's words), relayed as said. */
|
|
10
|
+
export class OrchestratorRefusal extends Error {}
|
|
4
11
|
|
|
5
12
|
export async function callOrchestrator(action, args) {
|
|
6
13
|
const url = new URL(process.env.FLOWVIANT_ORCHESTRATOR_URL);
|
|
@@ -8,27 +15,16 @@ export async function callOrchestrator(action, args) {
|
|
|
8
15
|
const body = JSON.stringify({ action, args });
|
|
9
16
|
if (Buffer.byteLength(body) > ORCHESTRATOR_REQUEST_BYTES) throw new OrchestratorInputTooLarge();
|
|
10
17
|
const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_ORCHESTRATOR_CAPABILITY}`, 'Content-Type': 'application/json' },
|
|
11
|
-
body, signal: AbortSignal.timeout(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
body, signal: AbortSignal.timeout(ORCHESTRATOR_CLIENT_TIMEOUT_MS) });
|
|
19
|
+
// A closed or foreign run answers a bare 403: no body to parse.
|
|
20
|
+
let result = null;
|
|
21
|
+
try { result = JSON.parse(await response.text()); } catch { result = null; }
|
|
22
|
+
if (result?.code === ORCHESTRATOR_INPUT_TOO_LARGE) throw new OrchestratorInputTooLarge();
|
|
23
|
+
if (!response.ok) throw typeof result?.error === 'string' && result.error ? new OrchestratorRefusal(result.error) : new Error('This run is no longer held.');
|
|
24
|
+
if (!result || typeof result !== 'object') throw new Error('This run is no longer held.');
|
|
15
25
|
return result;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
let req;
|
|
21
|
-
try {
|
|
22
|
-
req = JSON.parse(line); if (req.id === undefined) continue;
|
|
23
|
-
let result;
|
|
24
|
-
if (req.method === 'initialize') result = { protocolVersion: req.params?.protocolVersion ?? '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'card-orchestrator', version: '2' } };
|
|
25
|
-
else if (req.method === 'ping') result = {};
|
|
26
|
-
else if (req.method === 'tools/list') result = { tools: ORCHESTRATOR_TOOLS };
|
|
27
|
-
else if (req.method === 'tools/call' && ORCHESTRATOR_TOOLS.some(t => t.name === req.params?.name)) {
|
|
28
|
-
try { result = { content: [{ type: 'text', text: JSON.stringify(await callOrchestrator(req.params.name, req.params.arguments ?? {})) }] }; }
|
|
29
|
-
catch (error) { result = { isError: true, content: [{ type: 'text', text: error.message }] }; }
|
|
30
|
-
} else { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Unknown method' } })}\n`); continue; }
|
|
31
|
-
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: req.id, result })}\n`);
|
|
32
|
-
} catch { if (req?.id !== undefined) process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: req.id, error: { code: -32602, message: 'Invalid request' } })}\n`); }
|
|
33
|
-
}
|
|
28
|
+
export function orchestratorMcpMain() {
|
|
29
|
+
return serveMcpStdio({ serverInfo: { name: 'card-orchestrator', version: '2' }, tools: ORCHESTRATOR_TOOLS, call: callOrchestrator });
|
|
34
30
|
}
|
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
2
|
import { hookCommand } from './claudePosture.mjs';
|
|
3
|
+
import { CLAUDE_POST_HOOK_MATCHER } from './nativeAgentTools.mjs';
|
|
4
|
+
import { toml } from './codexToml.mjs';
|
|
5
|
+
import { versionAtLeast } from './versionOrder.mjs';
|
|
6
|
+
|
|
7
|
+
/** Seconds. Above the client's own timeout (orchestratorClient.mjs). */
|
|
8
|
+
export const ORCHESTRATOR_HOOK_TIMEOUT_S = 20;
|
|
9
|
+
|
|
10
|
+
/** The Claude Code the gate was verified on (2026-10-17): hook `agent_id`,
|
|
11
|
+
* `updatedInput`, PostToolUseFailure, SubagentStop and background Agent.
|
|
12
|
+
* An older or unmeasured CLI runs the card solo. */
|
|
13
|
+
export const CLAUDE_ORCHESTRATION_MIN = '2.1.302';
|
|
14
|
+
export function claudeOrchestrationSafe(version) {
|
|
15
|
+
return versionAtLeast(String(version ?? '').trim().split(/\s+/)[0], CLAUDE_ORCHESTRATION_MIN);
|
|
16
|
+
}
|
|
3
17
|
|
|
4
18
|
const command = () => hookCommand('orchestrator', fileURLToPath(new URL('./hooks/orchestrator.mjs', import.meta.url)));
|
|
5
19
|
export function orchestratorHooks() {
|
|
6
|
-
const hooks = [{ type: 'command', command: command(), timeout:
|
|
7
|
-
|
|
20
|
+
const hooks = [{ type: 'command', command: command(), timeout: ORCHESTRATOR_HOOK_TIMEOUT_S }];
|
|
21
|
+
// Claude's post hooks matter only for native spawns; each run starts the
|
|
22
|
+
// daemon's binary. Bare names are an exact-name list (2.1.302).
|
|
23
|
+
const spawns = CLAUDE_POST_HOOK_MATCHER;
|
|
24
|
+
return { PreToolUse: [{ matcher: '.*', hooks }], PostToolUse: [{ matcher: spawns, hooks }], PostToolUseFailure: [{ matcher: spawns, hooks }], SubagentStop: [{ hooks }] };
|
|
8
25
|
}
|
|
9
26
|
|
|
10
27
|
export function codexOrchestratorArgs() {
|
|
11
|
-
|
|
12
|
-
const row = `{matcher=".*",hooks=[{type="command",command=${
|
|
28
|
+
// A Codex `-c` value is TOML, quoted by the one quoter (codexToml.mjs).
|
|
29
|
+
const row = `{matcher=".*",hooks=[{type="command",command=${toml(command())},timeout=${ORCHESTRATOR_HOOK_TIMEOUT_S}}]}`;
|
|
13
30
|
return ['--dangerously-bypass-hook-trust', '-c', 'features.hooks=true',
|
|
14
31
|
'-c', `hooks.PreToolUse=[${row}]`, '-c', `hooks.PostToolUse=[${row}]`, '-c', `hooks.SubagentStop=[${row}]`];
|
|
15
32
|
}
|