flowviant 0.125.1 → 0.125.3

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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * codex exec JSONL omits child steps (and several V2 collab tools). Read only
3
+ * rollouts named by this turn's native spawn receipts, never a global latest.
4
+ * rust-v0.160.0: exec_events.rs CollabToolCallItem; core/agent/child_config.rs
5
+ * reapplies the live turn's approval and permission profile after role config.
6
+ */
7
+ import { open } from 'node:fs/promises';
8
+ import { codexRolloutFile } from './runtimeLimits.mjs';
9
+ import { codexCollabUpdates, codexSpawnResult, statusOf } from './codexSubagentEvents.mjs';
10
+
11
+ export const SUBAGENT_LIMIT = 32;
12
+ const READ_BYTES = 256 * 1024;
13
+ const INITIAL_SCAN = 4 * 1024 * 1024;
14
+ const THREAD_ID = /^[a-zA-Z0-9-]{16,64}$/;
15
+
16
+ export function createCodexSubagentReader({ since = Date.now(), env = process.env, onSubagent, onActivity, onToolEvent, onSpawnResult,
17
+ findFile = codexRolloutFile } = {}) {
18
+ const readers = new Map();
19
+ const calls = new Map();
20
+ const targets = new Map();
21
+ const questions = new Map();
22
+ let root = null;
23
+ let timer = null;
24
+ let stopped = false;
25
+ let chain = Promise.resolve();
26
+ const report = update => {
27
+ if (!update?.id || !THREAD_ID.test(update.id)) return;
28
+ if (!readers.has(update.id) && readers.size >= SUBAGENT_LIMIT + 1) return;
29
+ if (!readers.has(update.id))
30
+ readers.set(update.id, { offset: null, rest: Buffer.alloc(0), seen: new Set(), live: false });
31
+ const { message, ...record } = update;
32
+ onSubagent?.(record);
33
+ };
34
+ const rememberTarget = (path, id) => {
35
+ targets.set(path, id);
36
+ if (targets.size > 128) targets.delete(targets.keys().next().value);
37
+ };
38
+ const targetId = value => targets.get(value) ?? (THREAD_ID.test(value ?? '') ? value : null);
39
+
40
+ function observe(ev, owner) {
41
+ const at = Date.parse(ev?.timestamp ?? '');
42
+ if (Number.isFinite(at) && at < since) return;
43
+ const p = ev?.payload;
44
+ if (!p || typeof p !== 'object') return;
45
+ const activity = (kind, text) => {
46
+ if (typeof text === 'string' && text.trim()) onActivity?.({ kind, label: text.replace(/\s+/g, ' ').slice(0, 160), full: text, agentId: owner });
47
+ };
48
+ const step = (id, command) => {
49
+ const seen = readers.get(owner)?.seen;
50
+ if (id && seen?.has(id)) return;
51
+ if (id) { seen?.add(id); if (seen?.size > 400) seen.delete(seen.values().next().value); }
52
+ onToolEvent?.('Bash', { command }, { agentId: owner, id });
53
+ };
54
+ if (ev.type === 'session_meta' && owner !== root) {
55
+ const src = p.source?.subagent?.thread_spawn ?? p.source?.sub_agent?.thread_spawn;
56
+ if (src) {
57
+ const title = src.agent_path ?? src.agent_nickname;
58
+ if (src.agent_path) rememberTarget(src.agent_path, owner);
59
+ report({ id: owner, ...(title ? { title } : {}), ...(src.agent_role ? { kind: src.agent_role } : {}) });
60
+ }
61
+ return;
62
+ }
63
+ if (ev.type === 'response_item' && p.type === 'function_call') {
64
+ const name = String(p.name ?? '').split('.').at(-1);
65
+ if (owner !== root && ['exec_command', 'shell_command', 'shell'].includes(name)) {
66
+ let args; try { args = JSON.parse(p.arguments); } catch { return; }
67
+ const command = args.cmd ?? args.command;
68
+ if (command) step(p.call_id, Array.isArray(command) ? command.join(' ') : command);
69
+ return;
70
+ }
71
+ if (owner !== root && name === 'request_user_input') {
72
+ questions.set(p.call_id, owner);
73
+ if (questions.size > 128) questions.delete(questions.keys().next().value);
74
+ report({ id: owner, status: 'asked' }); return;
75
+ }
76
+ if (!['spawn_agent', 'send_input', 'send_message', 'followup_task', 'resume_agent', 'close_agent', 'interrupt_agent'].includes(name)) return;
77
+ let args;
78
+ try { args = JSON.parse(p.arguments); } catch { return; }
79
+ calls.set(p.call_id, { name, args });
80
+ if (calls.size > 128) calls.delete(calls.keys().next().value);
81
+ const id = targetId(args.target ?? args.id);
82
+ if (id && name !== 'spawn_agent') {
83
+ const stopped = name === 'close_agent' || name === 'interrupt_agent';
84
+ report({ id, ...(stopped ? { status: 'stopped' } : name === 'followup_task' ? { status: 'running' } : {}) });
85
+ }
86
+ }
87
+ if (ev.type === 'response_item' && p.type === 'function_call_output') {
88
+ if (questions.has(p.call_id)) { report({ id: questions.get(p.call_id), status: 'running' }); questions.delete(p.call_id); }
89
+ const call = calls.get(p.call_id);
90
+ if (!['spawn_agent', 'followup_task', 'resume_agent', 'send_input'].includes(call?.name)) return;
91
+ let output;
92
+ try {
93
+ const body = Array.isArray(p.output) ? p.output.map(b => b.text ?? '').join('') : p.output;
94
+ output = typeof body === 'string' ? JSON.parse(body) : body;
95
+ } catch { onSpawnResult?.({ callId: p.call_id, success: false }); return; }
96
+ const id = output?.agent_id ?? output?.thread_id;
97
+ const receipt = id ?? output?.agent_path ?? (call.name !== 'spawn_agent' && (output?.submission_id || output?.status) ? call.args.target ?? call.args.id : null);
98
+ onSpawnResult?.({ callId: p.call_id, success: Boolean(receipt), ...(receipt ? { nativeId: receipt } : {}) });
99
+ if (call.name !== 'spawn_agent') return;
100
+ if (!id) return;
101
+ const args = call.args;
102
+ const title = args.task_name ?? args.description ?? output.agent_nickname ?? '';
103
+ if (args.task_name) rememberTarget(args.task_name, id);
104
+ if (output.agent_path) rememberTarget(output.agent_path, id);
105
+ report({ id, kind: args.agent_type ?? '', title, brief: args.message ?? '', status: 'running', now: '' });
106
+ return;
107
+ }
108
+ if (owner !== root && ev.type === 'response_item' && p.type === 'custom_tool_call') {
109
+ activity('tool', `${p.name ?? 'tool'}\n${String(p.input ?? '')}`);
110
+ return;
111
+ }
112
+ if (ev.type !== 'event_msg') return;
113
+ const activityState = item => {
114
+ const kind = String(item.kind ?? '').toLowerCase();
115
+ if (item.agent_thread_id) report({ id: item.agent_thread_id, title: item.agent_path,
116
+ ...(['started', 'completed', 'interrupted'].includes(kind) ? {
117
+ status: kind === 'completed' ? 'done' : kind === 'interrupted' ? 'stopped' : 'running',
118
+ } : {}),
119
+ });
120
+ };
121
+ if (p.type === 'sub_agent_activity') activityState(p);
122
+ if (p.type === 'item_completed') {
123
+ const item = p.item;
124
+ if (!item || p.thread_id && p.thread_id !== owner) return;
125
+ if (item.type === 'CollabAgentToolCall') {
126
+ const result = codexSpawnResult(item); if (result) onSpawnResult?.(result);
127
+ for (const update of codexCollabUpdates(item)) report(update);
128
+ }
129
+ if (item.type === 'SubAgentActivity') activityState(item);
130
+ if (owner === root) return;
131
+ if (item.type === 'AgentMessage') {
132
+ activity('say', item.content?.filter(b => b.type === 'Text' || b.type === 'text').map(b => b.text).join('\n'));
133
+ if (item.questions?.length) report({ id: owner, status: 'asked' });
134
+ }
135
+ if (item.type === 'Reasoning') activity('think', item.summary_text?.join('\n'));
136
+ if (item.type === 'CommandExecution') step(item.id, Array.isArray(item.command) ? item.command.join(' ') : item.command);
137
+ return;
138
+ }
139
+ if (p.type === 'collab_agent_spawn_end' && p.new_thread_id) {
140
+ report({ id: p.new_thread_id, brief: p.prompt ?? '', kind: p.new_agent_role ?? '', title: p.new_agent_nickname ?? '',
141
+ status: statusOf(typeof p.status === 'object' ? Object.keys(p.status)[0]?.toLowerCase() : p.status) ?? 'running' });
142
+ }
143
+ if (owner === root) return;
144
+ if (p.type === 'agent_message') activity('say', p.message);
145
+ if (p.type === 'agent_reasoning') activity('think', p.text);
146
+ if (p.type === 'exec_command_begin') {
147
+ step(p.call_id, Array.isArray(p.command) ? p.command.join(' ') : p.command);
148
+ }
149
+ if (p.type === 'patch_apply_begin') {
150
+ for (const [path, change] of Object.entries(p.changes ?? {}).slice(0, 40))
151
+ onToolEvent?.('Write', { file_path: path, content: change?.add?.content ?? '' }, { agentId: owner, id: p.call_id });
152
+ }
153
+ if (p.type === 'task_started' || p.type === 'turn_started') report({ id: owner, status: 'running' });
154
+ if (p.type === 'task_complete' || p.type === 'turn_complete') report({ id: owner, status: 'done', ...(p.last_agent_message ? { now: p.last_agent_message } : {}) });
155
+ if (p.type === 'turn_aborted') report({ id: owner, status: 'stopped' });
156
+ }
157
+
158
+ async function read(id, state) {
159
+ let file;
160
+ let fh;
161
+ try {
162
+ file = state.file ?? await findFile(id, { env });
163
+ if (!file) return;
164
+ state.file = file;
165
+ fh = await open(file, 'r');
166
+ const { size } = await fh.stat();
167
+ const initial = state.offset === null;
168
+ const initialEvents = [];
169
+ if (initial) {
170
+ state.offset = Math.max(0, size - INITIAL_SCAN);
171
+ state.skip = state.offset > 0;
172
+ }
173
+ if (size < state.offset) { state.offset = 0; state.rest = Buffer.alloc(0); }
174
+ // Bounded work per poll, including an immediately completed child.
175
+ const end = Math.min(size, state.offset + INITIAL_SCAN);
176
+ while (state.offset < end) {
177
+ const buffer = Buffer.alloc(Math.min(READ_BYTES, end - state.offset));
178
+ const { bytesRead } = await fh.read(buffer, 0, buffer.length, state.offset);
179
+ if (!bytesRead) break;
180
+ state.offset += bytesRead;
181
+ const data = Buffer.concat([state.rest, buffer.subarray(0, bytesRead)]);
182
+ let start = 0, nl;
183
+ while ((nl = data.indexOf(10, start)) >= 0) {
184
+ const line = data.subarray(start, nl).toString('utf8');
185
+ start = nl + 1;
186
+ if (state.skip) { state.skip = false; continue; }
187
+ try {
188
+ const ev = JSON.parse(line);
189
+ if (initial && id !== root) initialEvents.push(ev);
190
+ else if (id === root || state.live || ev.type === 'session_meta') observe(ev, id);
191
+ else if (ev.type === 'event_msg' && ['task_started', 'turn_started'].includes(ev.payload?.type)) {
192
+ state.live = true; observe(ev, id);
193
+ }
194
+ } catch { /* non-JSON/unknown native events */ }
195
+ }
196
+ // Keep bytes across UTF-8 and partial-line boundaries. Oversized lines
197
+ // are discarded through their newline rather than reparsed as a suffix.
198
+ state.rest = data.subarray(start);
199
+ if (state.rest.length > READ_BYTES) { state.rest = Buffer.alloc(0); state.skip = true; }
200
+ }
201
+ if (initial && id !== root) {
202
+ // A fork copies the parent's old EventMsgs. Only the child's newest
203
+ // native turn belongs to this card; inherited history is never replayed.
204
+ for (const ev of initialEvents) if (ev.type === 'session_meta') observe(ev, id);
205
+ const start = initialEvents.findLastIndex(ev => ev.type === 'event_msg' && ['task_started', 'turn_started'].includes(ev.payload?.type));
206
+ if (start >= 0) {
207
+ state.live = true;
208
+ for (const ev of initialEvents.slice(start)) observe(ev, id);
209
+ }
210
+ }
211
+ } catch { /* a native rollout may not have been created yet */ }
212
+ finally { await fh?.close().catch(() => {}); }
213
+ }
214
+ const flush = () => {
215
+ chain = chain.then(async () => {
216
+ for (const [id, state] of readers) await read(id, state);
217
+ }).catch(() => {});
218
+ return chain;
219
+ };
220
+ return {
221
+ thread(id) {
222
+ if (!THREAD_ID.test(id ?? '') || root) return;
223
+ root = id;
224
+ readers.set(id, { offset: null, rest: Buffer.alloc(0), seen: new Set(), live: false });
225
+ if (!stopped) { timer = setInterval(() => void flush(), 1_000); timer.unref?.(); }
226
+ },
227
+ update(update) { report(update); },
228
+ observe,
229
+ flush,
230
+ async stop() { stopped = true; clearInterval(timer); await flush(); },
231
+ };
232
+ }
@@ -0,0 +1,28 @@
1
+ /** Native gate; a full pool refuses only this spawn, preserving the turn. */
2
+ import { fileURLToPath } from 'node:url';
3
+ import { callOrchestrator } from '../orchestratorClient.mjs';
4
+ import { OrchestratorInputTooLarge, readOrchestratorInput } from '../orchestratorInput.mjs';
5
+ import { runningCompiledBinary } from '../launchCommand.mjs';
6
+
7
+ export async function main() {
8
+ try {
9
+ const event = await readOrchestratorInput(process.stdin);
10
+ const result = await callOrchestrator('hook', event);
11
+ const output = result.deny ? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: result.deny } }
12
+ : result.updatedInput || result.context ? { hookSpecificOutput: { hookEventName: event.hook_event_name,
13
+ ...(result.updatedInput ? { permissionDecision: 'allow', updatedInput: result.updatedInput } : {}),
14
+ ...(result.context ? { additionalContext: result.context } : {}) } } : {};
15
+ // __hook exits as soon as main returns. A large rewritten child prompt
16
+ // must reach the pipe before that exit, not stop partway through JSON.
17
+ await new Promise((resolve, reject) => process.stdout.write(`${JSON.stringify(output)}\n`, error => error ? reject(error) : resolve()));
18
+ } catch (error) {
19
+ // Both CLIs interpret exit 2 as a tool refusal. A failed capability must
20
+ // never let a native spawn escape the shared admission pool.
21
+ process.stderr.write(error instanceof OrchestratorInputTooLarge ? `${error.message}\n`
22
+ : 'The run’s orchestration gate is unavailable. Keep the work in the orchestrator until it reconnects.\n');
23
+ process.exitCode = 2;
24
+ }
25
+ }
26
+ // Bun bundles this module at the virtual entrypoint too. Only cli.mjs may
27
+ // dispatch it there; otherwise importing it consumes stdin before dispatch.
28
+ if (!runningCompiledBinary() && process.argv[1] === fileURLToPath(import.meta.url)) await main();
@@ -0,0 +1,298 @@
1
+ /** A run-scoped loopback capability. Only this daemon holds fleet credentials;
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';
7
+ import { fleetRequest } from './fleetPost.mjs';
8
+ import { fleetEndpoint } from './fleetWire.mjs';
9
+ import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
10
+ import { runningCompiledBinary } from './launchCommand.mjs';
11
+ import { scrubNativeText } from './runtimeEvents.mjs';
12
+ import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
13
+ import { OrchestratorInputTooLarge, readOrchestratorInput } from './orchestratorInput.mjs';
14
+
15
+ 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);
22
+ const text = (s, n) => scrubNativeText(s, n, scrub);
23
+
24
+
25
+
26
+ export async function createOrchestrator({ turnId, runtime, trace, admit, request = fleetRequest, notify = () => {}, taken = () => {},
27
+ interval = 500, scrubText = text } = {}) {
28
+ 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');
30
+ const secretKey = `orchestrator:${turnId}:${capability.slice(0, 8)}`;
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();
33
+ const pending = new Map(), steps = new Map();
34
+ let closed = false, polling = false, rootSession = null;
35
+ const identity = { scope: 'agent', turnId, instance: DAEMON_INSTANCE };
36
+ const resolve = id => aliases.get(id) ?? id;
37
+ const emit = update => {
38
+ const id = resolve(update.id), prior = records.get(id) ?? { id };
39
+ const reading = { ...prior, ...update, id };
40
+ if (reading.namedKind) reading.kind = reading.namedKind;
41
+ if (reading.namedTitle) reading.title = reading.namedTitle;
42
+ records.set(id, reading); trace.agent(reading); return reading;
43
+ };
44
+ 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
+ } };
49
+ const route = async args => {
50
+ const note = notes.get(args.messageId);
51
+ 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;
54
+ 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.');
57
+ // Flush the descriptor before the holder-bound route write validates it.
58
+ 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) } : {}) };
60
+ const result = await request(fleetEndpoint('agent-message-route', FLEET_URL), { body: { ...identity, messageId: args.messageId, route: next }, timeoutMs: 10_000 });
61
+ 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 });
63
+ taken(args.messageId); // the model naming the UUID proves receipt
64
+ // The API stores the handoff atomically with the route; a crash between
65
+ // this receipt and a trace flush must not lose or duplicate that item.
66
+ return { recorded: true };
67
+ };
68
+ function slot(id) {
69
+ const a = records.get(id);
70
+ if (a.release) return true;
71
+ if (admit('churn')) return false;
72
+ a.release = admit.reserve();
73
+ return true;
74
+ }
75
+ const match = input => [...records.values()].find(a => a.brief === (input.prompt ?? input.message ?? '') && a.status === 'waiting');
76
+ 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);
82
+ aliases.set(callId, id); calls.set(callId, id);
83
+ if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
84
+ 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';
93
+ 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 } };
96
+ }
97
+ const bind = (native, id) => {
98
+ if (typeof native !== 'string' || native.length > 160 || !id) return;
99
+ const target = resolve(id); aliases.set(native, target);
100
+ if (records.has(target)) records.get(target).nativeId = native;
101
+ 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);
105
+ }
106
+ for (const step of steps.get(native) ?? []) step(target);
107
+ steps.delete(native);
108
+ if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
109
+ };
110
+ function postSpawn(event) {
111
+ const id = calls.get(event.tool_use_id);
112
+ 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' }); }
123
+ }
124
+ 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);
129
+ 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 });
132
+ }
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' });
136
+ calls.set(event.tool_use_id, id);
137
+ return typeof input.message === 'string' ? { updatedInput: { ...input,
138
+ 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
+ }
140
+ function checkpoint(event) {
141
+ const id = resolve(event.agent_id ?? event.session_id);
142
+ if (!records.has(id)) return {};
143
+ const queued = [...notes.values()].filter(n => n.state === 'queued' && n.agentId === id);
144
+ if (!queued.length) return {};
145
+ // 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');
149
+ 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.`);
154
+ return { deny: 'Your orchestrator has a note for this checkpoint. Stop editing and wait for its follow-up; keep your work so far.' };
155
+ }
156
+ async function handle(action, args) {
157
+ if (closed) throw new Error('This run stopped.');
158
+ 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 {};
185
+ }
186
+ if (action === 'route_message') return route(args);
187
+ const id = resolve(args.agentId);
188
+ // A person’s answer starts a new CLI turn. The parent carries the earlier
189
+ // question and native identity in its own conversation; relay that known
190
+ // 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 &&
192
+ 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 });
194
+ questions.set(id, { question: args.question });
195
+ if (args.nativeAgentId) bind(args.nativeAgentId, id);
196
+ }
197
+ const a = records.get(id);
198
+ if (!a) throw new Error('Name an agent in this run.');
199
+ if (action === 'register_agent') return { registered: true, agentId: id };
200
+ 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.');
202
+ emit({ id, kind: args.kind, title: args.title, namedKind: args.kind, namedTitle: args.title });
203
+ return { recorded: true };
204
+ }
205
+ if (action === 'agent_question') {
206
+ let q = questions.get(id);
207
+ if (!q || q.question !== args.question) {
208
+ if (typeof args.question !== 'string' || args.question.length > 4_000) throw new Error('Keep the question within 4000 characters.');
209
+ q = { question: args.question }; questions.set(id, q); emit({ id, status: 'asked', now: args.question });
210
+ trace.prose('note', args.question, id);
211
+ 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
+ }
213
+ return q.answer ? { answer: q.answer } : { waiting: true, escalated: q.escalated === true };
214
+ }
215
+ if (action === 'answer_agent') {
216
+ const q = questions.get(id); if (!q) throw new Error('That agent has no unanswered question.');
217
+ 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.' }; }
219
+ if (typeof args.answer !== 'string' || !args.answer.trim() || args.answer.length > 4_000) throw new Error('Give the agent a bounded answer.');
220
+ q.answer = args.answer; trace.handoff(id, runtime, args.answer); emit({ id, status: a.release ? 'running' : 'waiting', now: 'Question answered by the orchestrator' });
221
+ return { answered: true, instruction: 'Give this answer to the child with its native follow-up/resume tool.' };
222
+ }
223
+ throw new Error('Unknown orchestration action.');
224
+ }
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; }
227
+ try {
228
+ const { action, args } = await readOrchestratorInput(req);
229
+ const result = await handle(action, args ?? {});
230
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify(result));
231
+ } catch (error) { res.writeHead(error instanceof OrchestratorInputTooLarge ? 413 : 409,
232
+ { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify({
233
+ error: text(error.message, 300), ...(error instanceof OrchestratorInputTooLarge ? { code: error.code } : {}),
234
+ })); }
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'];
240
+ const timer = setInterval(() => {
241
+ if (closed || polling) return;
242
+ polling = true;
243
+ 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.`);
247
+ }
248
+ } finally { polling = false; }
249
+ }, interval);
250
+ timer.unref?.();
251
+ return { env, config: { command: process.execPath, args: argv, env }, handle,
252
+ root(id) { if (typeof id === 'string') rootSession = id; },
253
+ resolve,
254
+ spawnResult(result) {
255
+ const id = calls.get(result.callId);
256
+ if (!id) return;
257
+ if (result.success) { if (result.nativeId) bind(result.nativeId, id); }
258
+ else if (records.get(id)?.started) { release(id); emit({ id, status: 'stopped', now: 'The native spawn did not start' }); }
259
+ },
260
+ attributed(native, step) {
261
+ const id = resolve(native);
262
+ if (records.has(id)) { step(id); return; }
263
+ if (!steps.has(native) && steps.size >= 32) return;
264
+ const queue = steps.get(native) ?? []; queue.push(step); if (queue.length > 40) queue.shift(); steps.set(native, queue);
265
+ },
266
+ 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 })); },
269
+ input(id, body) { if (closed || inbox.size >= 64 && !inbox.has(id)) return false; inbox.set(id, body); return true; },
270
+ observe(update) {
271
+ if (update.nativeId) bind(update.nativeId, update.id);
272
+ if (update.callId && calls.has(update.callId)) bind(update.id, calls.get(update.callId));
273
+ let id = resolve(update.id);
274
+ if (!records.has(id)) {
275
+ // Bind from a native receipt, never a guessed match on title/kind.
276
+ if (pending.size < 128 || pending.has(id)) pending.set(id, { ...pending.get(id), ...update });
277
+ return;
278
+ }
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.`);
290
+ },
291
+ async close(stopped = false) {
292
+ 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' }); }
294
+ server.close(); server.closeAllConnections();
295
+ forgetExtraSecrets(secretKey);
296
+ },
297
+ };
298
+ }
@@ -0,0 +1,34 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { ORCHESTRATOR_TOOLS } from './orchestratorProtocol.mjs';
3
+ import { ORCHESTRATOR_REQUEST_BYTES, ORCHESTRATOR_INPUT_TOO_LARGE, OrchestratorInputTooLarge } from './orchestratorInput.mjs';
4
+
5
+ export async function callOrchestrator(action, args) {
6
+ const url = new URL(process.env.FLOWVIANT_ORCHESTRATOR_URL);
7
+ if (url.hostname !== '127.0.0.1' || url.protocol !== 'http:') throw new Error('No held run is available.');
8
+ const body = JSON.stringify({ action, args });
9
+ if (Buffer.byteLength(body) > ORCHESTRATOR_REQUEST_BYTES) throw new OrchestratorInputTooLarge();
10
+ 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.');
15
+ return result;
16
+ }
17
+
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
+ }
34
+ }
@@ -0,0 +1,15 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { hookCommand } from './claudePosture.mjs';
3
+
4
+ const command = () => hookCommand('orchestrator', fileURLToPath(new URL('./hooks/orchestrator.mjs', import.meta.url)));
5
+ export function orchestratorHooks() {
6
+ const hooks = [{ type: 'command', command: command(), timeout: 20 }];
7
+ return { PreToolUse: [{ matcher: '.*', hooks }], PostToolUse: [{ matcher: '.*', hooks }], PostToolUseFailure: [{ matcher: '.*', hooks }], SubagentStop: [{ hooks }] };
8
+ }
9
+
10
+ export function codexOrchestratorArgs() {
11
+ const quote = s => JSON.stringify(String(s));
12
+ const row = `{matcher=".*",hooks=[{type="command",command=${quote(command())},timeout=20}]}`;
13
+ return ['--dangerously-bypass-hook-trust', '-c', 'features.hooks=true',
14
+ '-c', `hooks.PreToolUse=[${row}]`, '-c', `hooks.PostToolUse=[${row}]`, '-c', `hooks.SubagentStop=[${row}]`];
15
+ }