flowviant 0.125.5 → 0.125.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +4 -5
  2. package/bin/lib/agentInternalWait.mjs +1 -5
  3. package/bin/lib/agentPlan.mjs +13 -163
  4. package/bin/lib/agentRepositories.mjs +3 -1
  5. package/bin/lib/claudeInput.mjs +89 -10
  6. package/bin/lib/claudeMachineMcp.mjs +85 -0
  7. package/bin/lib/claudePersonal.mjs +6 -57
  8. package/bin/lib/claudePosture.mjs +6 -15
  9. package/bin/lib/claudeSettingsArg.mjs +67 -0
  10. package/bin/lib/claudeStream.mjs +30 -1
  11. package/bin/lib/claudeSubagentProgress.mjs +12 -28
  12. package/bin/lib/claudeSubagents.mjs +4 -2
  13. package/bin/lib/codexContract.mjs +52 -0
  14. package/bin/lib/codexFencedMount.mjs +275 -0
  15. package/bin/lib/codexPersonal.mjs +1 -265
  16. package/bin/lib/codexProjectRead.mjs +5 -4
  17. package/bin/lib/codexResearch.mjs +9 -3
  18. package/bin/lib/codexSubagentEvents.mjs +11 -7
  19. package/bin/lib/codexSubagents.mjs +55 -52
  20. package/bin/lib/codexToml.mjs +328 -0
  21. package/bin/lib/config.mjs +22 -1
  22. package/bin/lib/conversationIds.mjs +10 -0
  23. package/bin/lib/fleet.mjs +2 -2
  24. package/bin/lib/git.mjs +32 -0
  25. package/bin/lib/heldTool.mjs +49 -0
  26. package/bin/lib/hooks/mcpAllow.mjs +11 -5
  27. package/bin/lib/hooks/orchestrator.mjs +20 -10
  28. package/bin/lib/intakeDraft.mjs +1 -1
  29. package/bin/lib/jsonlTail.mjs +52 -0
  30. package/bin/lib/mcpInventory.mjs +11 -6
  31. package/bin/lib/mcpReasons.mjs +26 -0
  32. package/bin/lib/mcpReportBounds.mjs +10 -0
  33. package/bin/lib/mcpStdio.mjs +35 -0
  34. package/bin/lib/mergeBatch.mjs +6 -24
  35. package/bin/lib/mergeOrder.mjs +18 -0
  36. package/bin/lib/modelJson.mjs +173 -0
  37. package/bin/lib/nativeAgentTools.mjs +72 -0
  38. package/bin/lib/orchestrator.mjs +221 -133
  39. package/bin/lib/orchestratorClient.mjs +17 -21
  40. package/bin/lib/orchestratorHooks.mjs +21 -4
  41. package/bin/lib/orchestratorNative.mjs +58 -0
  42. package/bin/lib/orchestratorPrompt.mjs +7 -4
  43. package/bin/lib/orchestratorProtocol.mjs +25 -3
  44. package/bin/lib/postureTools.mjs +58 -13
  45. package/bin/lib/prWorkflow.mjs +13 -2
  46. package/bin/lib/processGroup.mjs +41 -0
  47. package/bin/lib/projectRepositories.mjs +2 -2
  48. package/bin/lib/projectToolRuntimes.mjs +92 -245
  49. package/bin/lib/projectTools.mjs +60 -19
  50. package/bin/lib/prompts.mjs +47 -18
  51. package/bin/lib/reportJournal.mjs +61 -0
  52. package/bin/lib/repositoryDelivery.mjs +6 -4
  53. package/bin/lib/repositoryMerge.mjs +8 -12
  54. package/bin/lib/repositoryPlaces.mjs +2 -2
  55. package/bin/lib/repositoryPrompts.mjs +42 -0
  56. package/bin/lib/repositoryTool.mjs +9 -32
  57. package/bin/lib/researchUse.mjs +39 -0
  58. package/bin/lib/resumeLost.mjs +21 -1
  59. package/bin/lib/resumeMarker.mjs +43 -0
  60. package/bin/lib/runTurn.mjs +55 -52
  61. package/bin/lib/runtimeAntigravity.mjs +1 -1
  62. package/bin/lib/runtimeCapabilities.mjs +13 -18
  63. package/bin/lib/runtimeClaude.mjs +68 -24
  64. package/bin/lib/runtimeCodex.mjs +12 -6
  65. package/bin/lib/runtimeEvents.mjs +13 -2
  66. package/bin/lib/runtimeOfferings.mjs +12 -5
  67. package/bin/lib/runtimes.mjs +3 -1
  68. package/bin/lib/startProject.mjs +10 -0
  69. package/bin/lib/subagentBounds.mjs +20 -0
  70. package/bin/lib/trace.mjs +30 -5
  71. package/bin/lib/turnDeclaration.mjs +1 -1
  72. package/bin/lib/turnDiagnostics.mjs +9 -12
  73. package/bin/lib/turnReceipts.mjs +64 -0
  74. package/bin/lib/turnSubagents.mjs +91 -0
  75. package/bin/lib/turnUsage.mjs +9 -6
  76. package/bin/lib/update.mjs +18 -4
  77. package/bin/lib/versionOrder.mjs +11 -1
  78. package/bin/lib/work.mjs +9 -9
  79. package/bin/lib/workAgentMerges.mjs +5 -7
  80. package/bin/lib/workAgentPrecheck.mjs +5 -5
  81. package/bin/lib/workAgentRecovery.mjs +2 -1
  82. package/bin/lib/workAgentResume.mjs +121 -0
  83. package/bin/lib/workAgentTools.mjs +111 -0
  84. package/bin/lib/workAgentTurnExecution.mjs +82 -246
  85. package/bin/lib/workAgentTurnOutcome.mjs +30 -3
  86. package/bin/lib/workAgentTurnReports.mjs +16 -0
  87. package/bin/lib/workAgentTurnShape.mjs +90 -0
  88. package/bin/lib/workAgentTurns.mjs +28 -12
  89. package/bin/lib/workArtifactReconcile.mjs +3 -2
  90. package/bin/lib/workAttachments.mjs +27 -5
  91. package/bin/lib/workMergeBatch.mjs +7 -5
  92. package/bin/lib/workNarration.mjs +32 -0
  93. package/bin/lib/workPlaceReports.mjs +3 -3
  94. package/bin/lib/workPlaces.mjs +14 -0
  95. package/bin/lib/workReportQueue.mjs +18 -1
  96. package/bin/lib/workSessionRuntime.mjs +2 -5
  97. package/bin/lib/workSessionTurns.mjs +68 -80
  98. package/bin/lib/workShip.mjs +8 -18
  99. package/bin/lib/workTurnShape.mjs +6 -4
  100. package/bin/lib/worktreeDiff.mjs +29 -5
  101. package/package.json +1 -1
@@ -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 { createServer } from 'node:http';
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 { runningCompiledBinary } from './launchCommand.mjs';
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
- // Codex 0.160 V2 serializes the namespace without punctuation in hook
17
- // tool_name (measured: collaborationspawn_agent). V1 spawn has an alias.
18
- const nativeName = name => String(name ?? '').split(/[./]/).at(-1).replace(/^(collaboration|multi_agent_v1)(?=spawn_agent|followup_task|resume_agent|send_input|interrupt_agent|close_agent)/, '');
19
- const spawnTool = name => ['Agent', 'Task', 'spawn_agent'].includes(nativeName(name));
20
- const resumeTool = name => ['followup_task', 'resume_agent', 'send_input'].includes(nativeName(name));
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
- const capability = randomBytes(32).toString('hex');
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(), inbox = new Map(), questions = 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
- const announce = message => { if (!closed) {
46
- inbox.set(randomUUID(), message); if (inbox.size > 64) inbox.delete(inbox.keys().next().value);
47
- notify(message);
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 (!['queued', 'handed', 'taken'].includes(args.state)) throw new Error('Choose queued, handed or taken.');
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
- if (args.state === 'handed' && a?.status === 'waiting') throw new Error('The agent has not started yet. Keep this note queued.');
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
- const next = { state: args.state, runtime, ...(id ? { agentId: trace.agentId(id), nativeAgentId: scrubText(a.nativeId ?? id, 160), kind: scrubText(args.kind, 80), safePoint: scrubText(args.safePoint, 200) } : {}) };
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,190 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
72
89
  a.release = admit.reserve();
73
90
  return true;
74
91
  }
75
- const match = input => [...records.values()].find(a => a.brief === (input.prompt ?? input.message ?? '') && a.status === 'waiting');
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 record that ran in this CLI run runs again: a resume or a follow-up.
104
+ if (a.started) a.rerun = true;
105
+ a.started = true; a.denied = null; a.heldAt = null; a.stops = 0;
106
+ for (const n of notes.values()) if (n.paused && n.state === 'queued' && target(n) === a.id) n.delivering = true;
107
+ if (questions.get(a.id)?.answer) questions.get(a.id).read = true;
108
+ emit({ id: a.id, status: 'running', now });
109
+ };
76
110
  function preSpawn(event) {
77
- const input = event.tool_input ?? {}, callId = event.tool_use_id;
78
- if (!callId || typeof (input.prompt ?? input.message) !== 'string') throw new Error('A native spawn needs its call id and prompt.');
79
- const waiting = match(input);
80
- const resumed = input.resume && records.has(resolve(input.resume)) ? resolve(input.resume) : null;
81
- const id = resumed ?? waiting?.id ?? resolve(callId);
111
+ const spawn = readSpawn(event);
112
+ if (!spawn) return { deny: 'Give this native spawn a text prompt or message so the run can gate it.' };
113
+ const { callId, brief } = spawn;
114
+ const resumed = spawn.resume !== null && records.has(resolve(spawn.resume)) ? resolve(spawn.resume) : null;
115
+ const id = resumed ?? claim(spawn)?.id ?? resolve(callId);
82
116
  aliases.set(callId, id); calls.set(callId, id);
83
117
  if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
84
118
  if (calls.size > 128) calls.delete(calls.keys().next().value);
85
- if (!records.has(id) && records.size >= 32) return { deny: 'This run already has 32 agent records. Continue the work yourself.' };
86
- const a = emit({ id, brief: input.prompt ?? input.message, title: input.description ?? input.task_name ?? '',
87
- kind: input.subagent_type ?? input.agent_type ?? '', status: 'waiting', now: WAIT });
88
- if (!slot(id)) 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.` };
89
- a.started = true;
90
- a.checkpointSaid = false;
91
- emit({ id, status: 'running', now: a.title });
92
- const promptKey = typeof input.prompt === 'string' ? 'prompt' : 'message';
119
+ if (!records.has(id) && records.size >= SUBAGENT_CAP) return { deny: `This run already has ${SUBAGENT_CAP} agent records. Continue the work yourself.` };
120
+ const a = emit({ id, brief, title: spawn.title ?? '', kind: spawn.kind ?? '', status: 'waiting', now: WAIT });
121
+ 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.` }; }
122
+ // Claude's SendMessage addresses a child by its Agent `name`, Codex V2 by its task name.
123
+ for (const name of spawn.names) if (filled(name) && name.length <= 160) aliases.set(name, id);
124
+ start(a, a.title);
93
125
  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: { ...input, ...(['Agent', 'Task'].includes(nativeName(event.tool_name)) ? { run_in_background: true } : {}), [promptKey]: input[promptKey] + childContract } };
126
+ 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.' : ''}`,
127
+ updatedInput: withChildContract(event, spawn, childContract) };
128
+ }
129
+ // Native readings never revive a settled record or start a waiting spawn
130
+ // or follow-up (Codex records a refused call's start anyway); only an
131
+ // admitted spawn or follow-up does.
132
+ // …except the CLI's own run of a follow-up admitted while the child ran,
133
+ // counted again if its held slot already lapsed.
134
+ const resumeQueued = a => { a.queued = false; slot(a.id); emit({ id: a.id, status: 'running', now: 'Continuing with the orchestrator’s note' }); };
135
+ const apply = (id, update) => {
136
+ const a = records.get(id), { status: _status, now: _now, ...metadata } = update;
137
+ if (terminal(a.status) && a.queued && update.status === 'running') { resumeQueued(a); return; }
138
+ if ((a.status === 'waiting' && (!a.started || a.denied)) || terminal(a.status)) { emit({ ...metadata, id }); return; }
139
+ // A rerun settles only on its own stop (SubagentStop, once per run). The
140
+ // stream's reading can be the earlier run's, late, and once closed the
141
+ // resumed one (2026-10-19). Measured on Claude Code 2.1.303: a SendMessage
142
+ // resume's task events keep the child's task_id (only tool_use_id moves to
143
+ // the follow-up's), and SubagentStop fires for each run, the resumed one
144
+ // included.
145
+ if (terminal(update.status) && a.rerun) { emit({ ...metadata, id }); return; }
146
+ if (terminal(update.status)) { settle(id, update); return; }
147
+ emit({ ...update, id, ...(questions.has(id) && !questions.get(id).answer ? { status: 'asked' } : {}) });
148
+ };
149
+ function settle(id, update) {
150
+ const a = records.get(id), live = Boolean(a.release) && !a.denied;
151
+ // Claude 2.1.302 and Codex 0.163 run a child again when it finished before
152
+ // a follow-up queued while it ran reached a tool round: hold its slot until
153
+ // that run's own stop.
154
+ if (a.stops > 1) a.queued = false;
155
+ if (!a.queued) release(id); else a.heldAt ??= clock();
156
+ if (terminal(a.status)) return;
157
+ emit({ ...update, id, ...(questions.has(id) && !questions.get(id).answer ? { status: 'asked' } : {}) });
158
+ if (live) for (const note of notes.values()) if (note.state === 'queued' && target(note) === id)
159
+ announce(`Agent ${id} finished. Deliver note ${note.messageId} in its next task or a native follow-up and report handed at that checkpoint.`);
96
160
  }
97
161
  const bind = (native, id) => {
98
162
  if (typeof native !== 'string' || native.length > 160 || !id) return;
99
163
  const target = resolve(id); aliases.set(native, target);
100
164
  if (records.has(target)) records.get(target).nativeId = native;
101
165
  if (pending.has(native)) {
102
- const update = pending.get(native);
103
- if (terminal(update.status)) release(target);
104
- emit({ ...update, id: target }); pending.delete(native);
166
+ const update = pending.get(native); pending.delete(native);
167
+ if (records.has(target)) apply(target, update);
105
168
  }
106
169
  for (const step of steps.get(native) ?? []) step(target);
107
170
  steps.delete(native);
108
171
  if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
109
172
  };
173
+ // Codex V2 names a child `backend` or `/root/backend` interchangeably.
174
+ const named = native => {
175
+ if (aliases.has(native) || records.has(native)) return resolve(native);
176
+ const hits = new Set([...aliases].filter(([k]) => k.endsWith(`/${native}`) || native.endsWith(`/${k}`)).map(([, v]) => v));
177
+ return hits.size === 1 ? [...hits][0] : native;
178
+ };
110
179
  function postSpawn(event) {
111
180
  const id = calls.get(event.tool_use_id);
112
181
  if (!id) return;
113
- let output = event.tool_response;
114
- try { if (typeof output === 'string') output = JSON.parse(output); } catch { /* native plain output */ }
115
- if (Array.isArray(output)) output = output.map(b => b.text ?? '').join('');
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' }); }
182
+ const { ids, failed } = spawnReceipt(event);
183
+ for (const native of ids) bind(native, id);
184
+ if (failed) { release(id); emit({ id, status: 'stopped' }); }
123
185
  }
124
186
  function preResume(event) {
125
- const input = event.tool_input ?? {};
126
- const native = input.target ?? input.id;
127
- if (typeof native !== 'string' || native.length > 160) return { deny: 'Name the native agent to resume.' };
128
- const id = resolve(native);
187
+ const input = event.tool_input ?? {}, { claude, native } = followupTarget(event);
188
+ if (native === null) return claude ? null : { deny: 'Name the native agent to resume.' };
189
+ const id = named(native);
129
190
  if (!records.has(id)) {
130
- if (records.size >= 32) return { deny: 'Continue the work in this run’s orchestrator.' };
131
- emit({ id, kind: '', title: native, brief: input.message ?? '', status: 'waiting', now: WAIT });
191
+ // SendMessage also reaches "main" and teammates: only a known child is a follow-up.
192
+ if (claude) return null;
193
+ if (records.size >= SUBAGENT_CAP) return { deny: 'Continue the work in this run’s orchestrator.' };
194
+ emit({ id, kind: '', title: native, brief: typeof input.message === 'string' ? input.message : '', status: 'waiting', now: WAIT, resumable: true });
132
195
  }
133
- if (!slot(id)) { emit({ id, status: 'waiting', now: WAIT }); return { deny: `${WAIT}. Retry the native follow-up when notified. Agent id: ${id}` }; }
134
- records.get(id).started = true; records.get(id).checkpointSaid = false;
135
- emit({ id, status: 'running', now: 'Continuing with the orchestrator’s note' });
196
+ if (spawnable(records.get(id))) return { deny: `Agent ${id} has not started. Retry its native spawn instead.` };
197
+ // A refused follow-up retried on its offered slot reaches a finished child.
198
+ const prior = records.get(id), running = Boolean(prior.release) && !prior.denied && !terminal(prior.status);
199
+ 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}` }; }
200
+ start(records.get(id), 'Continuing with the orchestrator’s note');
201
+ records.get(id).queued = running;
136
202
  calls.set(event.tool_use_id, id);
137
203
  return typeof input.message === 'string' ? { updatedInput: { ...input,
138
204
  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
205
  }
140
206
  function checkpoint(event) {
141
- const id = resolve(event.agent_id ?? event.session_id);
207
+ const child = childOf(event);
208
+ if (!child || isDiscoveryTool(event.tool_name) || String(event.tool_name).includes(ORCHESTRATOR_SERVER)) return {};
209
+ const id = resolve(child);
142
210
  if (!records.has(id)) return {};
143
- const queued = [...notes.values()].filter(n => n.state === 'queued' && n.agentId === id);
144
- if (!queued.length) return {};
145
211
  // The orchestrator, not this hook, chose each checkpoint. Pause tool
146
- // execution at its next boundary and ask the parent to use native stop /
147
- // follow-up. The child keeps its worktree and transcript intact.
148
- const edits = queued.filter(n => n.safePoint === 'after its current edit');
212
+ // execution at its next boundary, once per note, and ask the parent for a
213
+ // native follow-up. The child keeps its worktree and transcript intact.
214
+ const edits = [...notes.values()].filter(n => n.state === 'queued' && target(n) === id && !n.delivering && pointOf(n.safePoint) === AFTER_EDIT);
149
215
  if (!edits.length) return {};
150
- if (records.get(id).checkpointSaid) return { deny: 'Remain at the checkpoint until your orchestrator sends the follow-up.' };
151
- records.get(id).checkpointSaid = true;
152
- emit({ id, status: 'waiting', now: 'At the checkpoint for a message' });
153
- announce(`Agent ${id} is between tools. Stop it with the native tool and resume/follow up with note ${edits.map(n => n.messageId).join(' ')} plus its work so far. Then report handed.`);
216
+ const fresh = edits.filter(n => !n.paused);
217
+ if (!fresh.length) return { deny: 'Remain at the checkpoint until your orchestrator sends the follow-up. Your card_orchestrator tools still work.' };
218
+ for (const n of fresh) n.paused = true;
219
+ const native = records.get(id).nativeId;
220
+ emit({ id, status: 'waiting', now: HELD });
221
+ 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` :
222
+ `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
223
  return { deny: 'Your orchestrator has a note for this checkpoint. Stop editing and wait for its follow-up; keep your work so far.' };
155
224
  }
225
+ function hook(ev) {
226
+ // A child's next tool round carries a follow-up queued to it; after its
227
+ // stop, its own tool use is the CLI running it again with that follow-up.
228
+ const kid = records.get(resolve(childOf(ev)));
229
+ if (kid && ['PreToolUse', 'PostToolUse'].includes(ev.hook_event_name)) { if (kid.queued && terminal(kid.status)) resumeQueued(kid); kid.queued = false; }
230
+ // A native stop (TaskStop takes the agent id or name as task_id) cancels that resume.
231
+ const stopped = ev.hook_event_name === 'PreToolUse' && isClaudeStopTool(ev.tool_name) && records.get(named(String(ev.tool_input?.task_id ?? '')));
232
+ if (stopped) { stopped.queued = false; if (terminal(stopped.status)) release(stopped.id); }
233
+ 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' }); } }
234
+ if (ev.hook_event_name === 'PostToolUseFailure' && (isSpawnTool(ev.tool_name) || isFollowupTool(ev.tool_name))) {
235
+ 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' }); }
236
+ }
237
+ if (ev.hook_event_name === 'PreToolUse') {
238
+ const child = childOf(ev);
239
+ if (String(ev.tool_name).includes(ORCHESTRATOR_SERVER) && String(ev.tool_name).endsWith('register_agent')) {
240
+ // Binding the root would pause the orchestrator at its children's checkpoints.
241
+ 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.' };
242
+ const id = resolve(ev.tool_input?.agentId);
243
+ if (!records.has(id)) return { deny: 'Use the agent record id in your prompt.' };
244
+ bind(child, id);
245
+ }
246
+ if (child && !records.has(resolve(child)) && !isDiscoveryTool(ev.tool_name))
247
+ return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
248
+ if (isSpawnTool(ev.tool_name)) return preSpawn(ev);
249
+ if (isFollowupTool(ev.tool_name)) { const resumed = preResume(ev); if (resumed) return resumed; }
250
+ const point = checkpoint(ev); if (point.deny) return point;
251
+ if (isPersonQuestionTool(ev.tool_name))
252
+ 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.' };
253
+ }
254
+ if (ev.hook_event_name === 'PostToolUse' && (isSpawnTool(ev.tool_name) || isFollowupTool(ev.tool_name))) postSpawn(ev);
255
+ return {};
256
+ }
156
257
  async function handle(action, args) {
157
258
  if (closed) throw new Error('This run stopped.');
158
259
  if (action === 'hook') {
159
- const ev = args;
160
- if (ev.hook_event_name === 'SubagentStop') { const id = resolve(ev.agent_id ?? ev.session_id); release(id); if (records.has(id)) emit({ id, status: questions.has(id) && !questions.get(id).answer ? 'asked' : 'done' }); }
161
- if (ev.hook_event_name === 'PostToolUseFailure' && (spawnTool(ev.tool_name) || resumeTool(ev.tool_name))) {
162
- 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' }); }
163
- }
164
- if (ev.hook_event_name === 'PreToolUse') {
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 {};
260
+ const result = hook(args);
261
+ // Root PreToolUse/PostToolUse additionalContext is measured on both CLIs.
262
+ if (result.deny || !['PreToolUse', 'PostToolUse'].includes(args.hook_event_name) || args.agent_id || !rootSession || args.session_id !== rootSession) return result;
263
+ const held = [];
264
+ for (const box of [inbox, wakes]) for (const [key, body] of box) if (held.length < 4) { box.delete(key); held.push(body); }
265
+ return held.length ? { ...result, context: [result.context, ...held].filter(Boolean).join('\n\n') } : result;
185
266
  }
186
267
  if (action === 'route_message') return route(args);
187
268
  const id = resolve(args.agentId);
188
269
  // A person’s answer starts a new CLI turn. The parent carries the earlier
189
270
  // question and native identity in its own conversation; relay that known
190
271
  // 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 < 32 &&
272
+ if (action === 'answer_agent' && !records.has(id) && typeof id === 'string' && id.length > 0 && id.length <= 160 && records.size < SUBAGENT_CAP &&
192
273
  typeof args.question === 'string' && args.question.trim() && args.question.length <= 4_000) {
193
- emit({ id, kind: scrubText(args.kind ?? '', 80), title: scrubText(args.title ?? '', 200), brief: '', status: 'asked', now: args.question });
274
+ // Resumable, never spawnable: no slot is offered until a gated follow-up targets it.
275
+ 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
276
  questions.set(id, { question: args.question });
195
277
  if (args.nativeAgentId) bind(args.nativeAgentId, id);
196
278
  }
@@ -198,32 +280,36 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
198
280
  if (!a) throw new Error('Name an agent in this run.');
199
281
  if (action === 'register_agent') return { registered: true, agentId: id };
200
282
  if (action === 'describe_agent') {
201
- if (typeof args.kind !== 'string' || !args.kind.trim() || args.kind.length > 80 || typeof args.title !== 'string' || !args.title.trim() || args.title.length > 200) throw new Error('Give this agent a bounded kind and title.');
283
+ 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
284
  emit({ id, kind: args.kind, title: args.title, namedKind: args.kind, namedTitle: args.title });
203
285
  return { recorded: true };
204
286
  }
205
287
  if (action === 'agent_question') {
206
288
  let q = questions.get(id);
289
+ // The person answers in a later run; the child ends now so this run settles on the question card.
290
+ if (q?.escalated && !q.answer) return { waiting: true, escalated: true,
291
+ 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.' };
292
+ // A given answer stands for a reworded poll until the child has it.
293
+ if (q?.answer && (!q.read || q.question === args.question)) { q.read = true; return { answer: q.answer }; }
207
294
  if (!q || q.question !== args.question) {
208
295
  if (typeof args.question !== 'string' || args.question.length > 4_000) throw new Error('Keep the question within 4000 characters.');
209
296
  q = { question: args.question }; questions.set(id, q); emit({ id, status: 'asked', now: args.question });
210
297
  trace.prose('note', args.question, id);
211
298
  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
299
  }
213
- return q.answer ? { answer: q.answer } : { waiting: true, escalated: q.escalated === true };
300
+ return { waiting: true, escalated: false };
214
301
  }
215
302
  if (action === 'answer_agent') {
216
303
  const q = questions.get(id); if (!q) throw new Error('That agent has no unanswered question.');
217
304
  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: 'Return the existing blocked JSON question card with this attribution. Leave the agent asked until the person answers.' }; }
305
+ 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
306
  if (typeof args.answer !== 'string' || !args.answer.trim() || args.answer.length > 4_000) throw new Error('Give the agent a bounded answer.');
220
307
  q.answer = args.answer; trace.handoff(id, runtime, args.answer); emit({ id, status: a.release ? 'running' : 'waiting', now: 'Question answered by the orchestrator' });
221
308
  return { answered: true, instruction: 'Give this answer to the child with its native follow-up/resume tool.' };
222
309
  }
223
310
  throw new Error('Unknown orchestration action.');
224
311
  }
225
- const server = createServer(async (req, res) => {
226
- if (closed || req.method !== 'POST' || req.url !== '/' || !sameSecret(req.headers.authorization, `Bearer ${capability}`)) { res.writeHead(403).end(); return; }
312
+ const respond = async (req, res) => {
227
313
  try {
228
314
  const { action, args } = await readOrchestratorInput(req);
229
315
  const result = await handle(action, args ?? {});
@@ -232,18 +318,28 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
232
318
  { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify({
233
319
  error: text(error.message, 300), ...(error instanceof OrchestratorInputTooLarge ? { code: error.code } : {}),
234
320
  })); }
235
- });
236
- server.requestTimeout = 5_000;
237
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
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'];
321
+ };
322
+ const { server, env, args: argv } = await listenHeldTool({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: 5_000,
323
+ capability, isClosed: () => closed, respond });
240
324
  const timer = setInterval(() => {
241
325
  if (closed || polling) return;
242
326
  polling = true;
243
327
  try {
244
- for (const a of records.values()) if (a.status === 'waiting' && !a.started && !a.release && slot(a.id)) {
245
- emit({ id: a.id, status: 'waiting', now: 'Ready to start' });
246
- announce(`A slot is available for agent ${a.id}. Retry its native spawn with the same prompt now; its slot is reserved. If it is no longer needed, finish the run yourself.`);
328
+ const now = clock();
329
+ for (const a of records.values()) {
330
+ // A follow-up sent during the child's last tool round reaches it there
331
+ // and resumes nothing; on Claude no post hook says so (2.1.302).
332
+ if (a.queued && a.release && terminal(a.status)) { if (now - (a.heldAt ??= now) >= hold) release(a.id); continue; }
333
+ if (!a.denied) continue;
334
+ // An offer the parent leaves unused lapses: the slot returns to the
335
+ // project's other turns, and this run is offered one again later.
336
+ if (a.release) { if (now - (a.heldAt ?? now) >= hold) { release(a.id); offerAt = now + hold; emit({ id: a.id, status: 'waiting', now: WAIT }); } continue; }
337
+ if (now < offerAt || !slot(a.id)) continue;
338
+ a.heldAt = now;
339
+ const resume = a.denied === 'resume';
340
+ emit({ id: a.id, status: 'waiting', now: resume ? 'Ready to continue' : 'Ready to start' });
341
+ 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.`
342
+ : `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
343
  }
248
344
  } finally { polling = false; }
249
345
  }, interval);
@@ -260,37 +356,29 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
260
356
  attributed(native, step) {
261
357
  const id = resolve(native);
262
358
  if (records.has(id)) { step(id); return; }
263
- if (!steps.has(native) && steps.size >= 32) return;
359
+ if (!steps.has(native) && steps.size >= SUBAGENT_CAP) return;
264
360
  const queue = steps.get(native) ?? []; queue.push(step); if (queue.length > 40) queue.shift(); steps.set(native, queue);
265
361
  },
266
362
  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: resolve(message.route.nativeAgentId), safePoint: message.route.safePoint } : {}) }); },
268
- outstanding() { return [...notes.values()].filter(n => ['deciding', 'queued'].includes(n.state)).map(n => ({ id: n.messageId, state: n.state, body: n.body, agentId: n.agentId, safePoint: n.safePoint })); },
363
+ state: message.route?.state ?? 'deciding', ...(message.route?.nativeAgentId ? { agentId: message.route.nativeAgentId, safePoint: message.route.safePoint } : {}) }); },
364
+ 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
365
  input(id, body) { if (closed || inbox.size >= 64 && !inbox.has(id)) return false; inbox.set(id, body); return true; },
270
366
  observe(update) {
271
367
  if (update.nativeId) bind(update.nativeId, update.id);
272
368
  if (update.callId && calls.has(update.callId)) bind(update.id, calls.get(update.callId));
273
- let id = resolve(update.id);
369
+ const id = resolve(update.id);
274
370
  if (!records.has(id)) {
275
371
  // Bind from a native receipt, never a guessed match on title/kind.
276
372
  if (pending.size < 128 || pending.has(id)) pending.set(id, { ...pending.get(id), ...update });
277
373
  return;
278
374
  }
279
- const a = records.get(id);
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.`);
375
+ apply(id, update);
290
376
  },
291
377
  async close(stopped = false) {
292
378
  closed = true; clearInterval(timer);
293
- for (const a of records.values()) { release(a.id); if (!terminal(a.status) && !(a.status === 'asked' && !stopped)) emit({ id: a.id, status: 'stopped', now: stopped ? 'Stopped with the run' : 'Run ended' }); }
379
+ // Only an escalated question waits for the person; any other asked child ended with the run.
380
+ for (const a of records.values()) { release(a.id); const q = questions.get(a.id);
381
+ 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
382
  server.close(); server.closeAllConnections();
295
383
  forgetExtraSecrets(secretKey);
296
384
  },
@@ -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(15_000) });
12
- const result = await response.json();
13
- if (result.code === ORCHESTRATOR_INPUT_TOO_LARGE) throw new OrchestratorInputTooLarge();
14
- if (!response.ok) throw new Error(result.error ?? 'This run is no longer held.');
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 async function orchestratorMcpMain() {
19
- for await (const line of createInterface({ input: process.stdin })) {
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
  }