flowviant 0.125.0 → 0.125.2
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/cli.mjs +10 -0
- package/bin/lib/agentAsk.mjs +6 -12
- package/bin/lib/agentCards.mjs +5 -30
- package/bin/lib/agentFiles.mjs +1 -1
- package/bin/lib/agentFootprint.mjs +5 -5
- package/bin/lib/agentPlan.mjs +16 -27
- package/bin/lib/cardThread.mjs +1 -1
- package/bin/lib/claudeStream.mjs +18 -7
- package/bin/lib/claudeSubagentProgress.mjs +62 -0
- package/bin/lib/claudeSubagents.mjs +77 -0
- package/bin/lib/codexRelay.mjs +6 -22
- package/bin/lib/codexSubagentEvents.mjs +34 -0
- package/bin/lib/codexSubagents.mjs +232 -0
- package/bin/lib/hooks/orchestrator.mjs +22 -0
- package/bin/lib/orchestrator.mjs +295 -0
- package/bin/lib/orchestratorClient.mjs +30 -0
- package/bin/lib/orchestratorHooks.mjs +15 -0
- package/bin/lib/orchestratorPrompt.mjs +19 -0
- package/bin/lib/orchestratorProtocol.mjs +15 -0
- package/bin/lib/promptTransport.mjs +34 -0
- package/bin/lib/runTurn.mjs +87 -14
- package/bin/lib/runtimeClaude.mjs +3 -1
- package/bin/lib/runtimeCodex.mjs +32 -11
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/trace.mjs +48 -10
- package/bin/lib/turnDiagnostics.mjs +13 -5
- package/bin/lib/work.mjs +2 -1
- package/bin/lib/workAgentTurnExecution.mjs +84 -7
- package/bin/lib/workAgentTurnOutcome.mjs +8 -8
- package/bin/lib/workAgentTurns.mjs +2 -0
- package/bin/lib/workChildren.mjs +4 -0
- package/bin/lib/workLiveMessages.mjs +7 -1
- package/bin/lib/workSessionTurns.mjs +3 -3
- package/package.json +2 -2
|
@@ -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,22 @@
|
|
|
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
|
+
|
|
5
|
+
export async function main() {
|
|
6
|
+
try {
|
|
7
|
+
let raw = ''; for await (const chunk of process.stdin) { raw += chunk; if (Buffer.byteLength(raw) > 64 * 1024) throw new Error('Hook input is too large.'); }
|
|
8
|
+
const event = JSON.parse(raw);
|
|
9
|
+
const result = await callOrchestrator('hook', event);
|
|
10
|
+
const output = result.deny ? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: result.deny } }
|
|
11
|
+
: result.updatedInput || result.context ? { hookSpecificOutput: { hookEventName: event.hook_event_name,
|
|
12
|
+
...(result.updatedInput ? { permissionDecision: 'allow', updatedInput: result.updatedInput } : {}),
|
|
13
|
+
...(result.context ? { additionalContext: result.context } : {}) } } : {};
|
|
14
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
15
|
+
} catch {
|
|
16
|
+
// Both CLIs interpret exit 2 as a tool refusal. A failed capability must
|
|
17
|
+
// never let a native spawn escape the shared admission pool.
|
|
18
|
+
process.stderr.write('The run’s orchestration gate is unavailable. Keep the work in the orchestrator until it reconnects.\n');
|
|
19
|
+
process.exitCode = 2;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
|
@@ -0,0 +1,295 @@
|
|
|
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
|
+
|
|
14
|
+
const WAIT = 'Waiting for a free slot on the machine';
|
|
15
|
+
// Codex 0.160 V2 serializes the namespace without punctuation in hook
|
|
16
|
+
// tool_name (measured: collaborationspawn_agent). V1 spawn has an alias.
|
|
17
|
+
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)/, '');
|
|
18
|
+
const spawnTool = name => ['Agent', 'Task', 'spawn_agent'].includes(nativeName(name));
|
|
19
|
+
const resumeTool = name => ['followup_task', 'resume_agent', 'send_input'].includes(nativeName(name));
|
|
20
|
+
const terminal = status => ['done', 'stopped'].includes(status);
|
|
21
|
+
const text = (s, n) => scrubNativeText(s, n, scrub);
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
export async function createOrchestrator({ turnId, runtime, trace, admit, request = fleetRequest, notify = () => {}, taken = () => {},
|
|
26
|
+
interval = 500, scrubText = text } = {}) {
|
|
27
|
+
if (typeof admit !== 'function' || typeof admit.reserve !== 'function') throw new Error('This run has no project admission pool.');
|
|
28
|
+
const capability = randomBytes(32).toString('hex');
|
|
29
|
+
const secretKey = `orchestrator:${turnId}:${capability.slice(0, 8)}`;
|
|
30
|
+
learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_ORCHESTRATOR_CAPABILITY', value: capability }]);
|
|
31
|
+
const records = new Map(), aliases = new Map(), calls = new Map(), notes = new Map(), inbox = new Map(), questions = new Map();
|
|
32
|
+
const pending = new Map(), steps = new Map();
|
|
33
|
+
let closed = false, polling = false, rootSession = null;
|
|
34
|
+
const identity = { scope: 'agent', turnId, instance: DAEMON_INSTANCE };
|
|
35
|
+
const resolve = id => aliases.get(id) ?? id;
|
|
36
|
+
const emit = update => {
|
|
37
|
+
const id = resolve(update.id), prior = records.get(id) ?? { id };
|
|
38
|
+
const reading = { ...prior, ...update, id };
|
|
39
|
+
if (reading.namedKind) reading.kind = reading.namedKind;
|
|
40
|
+
if (reading.namedTitle) reading.title = reading.namedTitle;
|
|
41
|
+
records.set(id, reading); trace.agent(reading); return reading;
|
|
42
|
+
};
|
|
43
|
+
const release = id => { const a = records.get(resolve(id)); a?.release?.(); if (a) a.release = null; };
|
|
44
|
+
const announce = message => { if (!closed) {
|
|
45
|
+
inbox.set(randomUUID(), message); if (inbox.size > 64) inbox.delete(inbox.keys().next().value);
|
|
46
|
+
notify(message);
|
|
47
|
+
} };
|
|
48
|
+
const route = async args => {
|
|
49
|
+
const note = notes.get(args.messageId);
|
|
50
|
+
if (!note) throw new Error('That note is not held by this run.');
|
|
51
|
+
if (!['queued', 'handed', 'taken'].includes(args.state)) throw new Error('Choose queued, handed or taken.');
|
|
52
|
+
const id = args.agentId ? resolve(args.agentId) : undefined;
|
|
53
|
+
const a = id ? records.get(id) : null;
|
|
54
|
+
if (args.state !== 'taken' && (!a || !args.safePoint || !args.kind)) throw new Error('Name the agent, its kind and this note’s safe point.');
|
|
55
|
+
if (args.state === 'handed' && a?.status === 'waiting') throw new Error('The agent has not started yet. Keep this note queued.');
|
|
56
|
+
// Flush the descriptor before the holder-bound route write validates it.
|
|
57
|
+
await trace.flush(1_000);
|
|
58
|
+
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) } : {}) };
|
|
59
|
+
const result = await request(fleetEndpoint('agent-message-route', FLEET_URL), { body: { ...identity, messageId: args.messageId, route: next }, timeoutMs: 10_000 });
|
|
60
|
+
if (!result.ok || !result.json?.data?.reported) throw new Error('The route was not recorded. Re-read the note and retry.');
|
|
61
|
+
Object.assign(note, args, { agentId: id });
|
|
62
|
+
taken(args.messageId); // the model naming the UUID proves receipt
|
|
63
|
+
// The API stores the handoff atomically with the route; a crash between
|
|
64
|
+
// this receipt and a trace flush must not lose or duplicate that item.
|
|
65
|
+
return { recorded: true };
|
|
66
|
+
};
|
|
67
|
+
function slot(id) {
|
|
68
|
+
const a = records.get(id);
|
|
69
|
+
if (a.release) return true;
|
|
70
|
+
if (admit('churn')) return false;
|
|
71
|
+
a.release = admit.reserve();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const match = input => [...records.values()].find(a => a.brief === (input.prompt ?? input.message ?? '') && a.status === 'waiting');
|
|
75
|
+
function preSpawn(event) {
|
|
76
|
+
const input = event.tool_input ?? {}, callId = event.tool_use_id;
|
|
77
|
+
if (!callId || typeof (input.prompt ?? input.message) !== 'string') throw new Error('A native spawn needs its call id and prompt.');
|
|
78
|
+
const waiting = match(input);
|
|
79
|
+
const resumed = input.resume && records.has(resolve(input.resume)) ? resolve(input.resume) : null;
|
|
80
|
+
const id = resumed ?? waiting?.id ?? resolve(callId);
|
|
81
|
+
aliases.set(callId, id); calls.set(callId, id);
|
|
82
|
+
if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
|
|
83
|
+
if (calls.size > 128) calls.delete(calls.keys().next().value);
|
|
84
|
+
if (!records.has(id) && records.size >= 32) return { deny: 'This run already has 32 agent records. Continue the work yourself.' };
|
|
85
|
+
const a = emit({ id, brief: input.prompt ?? input.message, title: input.description ?? input.task_name ?? '',
|
|
86
|
+
kind: input.subagent_type ?? input.agent_type ?? '', status: 'waiting', now: WAIT });
|
|
87
|
+
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.` };
|
|
88
|
+
a.started = true;
|
|
89
|
+
a.checkpointSaid = false;
|
|
90
|
+
emit({ id, status: 'running', now: a.title });
|
|
91
|
+
const promptKey = typeof input.prompt === 'string' ? 'prompt' : 'message';
|
|
92
|
+
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.`;
|
|
93
|
+
return { context: `Agent record id: ${id}. Use it with card_orchestrator for routing and questions.`,
|
|
94
|
+
updatedInput: { ...input, ...(['Agent', 'Task'].includes(nativeName(event.tool_name)) ? { run_in_background: true } : {}), [promptKey]: input[promptKey] + childContract } };
|
|
95
|
+
}
|
|
96
|
+
const bind = (native, id) => {
|
|
97
|
+
if (typeof native !== 'string' || native.length > 160 || !id) return;
|
|
98
|
+
const target = resolve(id); aliases.set(native, target);
|
|
99
|
+
if (records.has(target)) records.get(target).nativeId = native;
|
|
100
|
+
if (pending.has(native)) {
|
|
101
|
+
const update = pending.get(native);
|
|
102
|
+
if (terminal(update.status)) release(target);
|
|
103
|
+
emit({ ...update, id: target }); pending.delete(native);
|
|
104
|
+
}
|
|
105
|
+
for (const step of steps.get(native) ?? []) step(target);
|
|
106
|
+
steps.delete(native);
|
|
107
|
+
if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
|
|
108
|
+
};
|
|
109
|
+
function postSpawn(event) {
|
|
110
|
+
const id = calls.get(event.tool_use_id);
|
|
111
|
+
if (!id) return;
|
|
112
|
+
let output = event.tool_response;
|
|
113
|
+
try { if (typeof output === 'string') output = JSON.parse(output); } catch { /* native plain output */ }
|
|
114
|
+
if (Array.isArray(output)) output = output.map(b => b.text ?? '').join('');
|
|
115
|
+
try { if (typeof output === 'string') output = JSON.parse(output); } catch { /* native prose */ }
|
|
116
|
+
bind(output?.agent_id ?? output?.agentId ?? output?.thread_id ?? output?.agent_path, id);
|
|
117
|
+
for (const block of output?.content ?? []) {
|
|
118
|
+
if (typeof block.text !== 'string') continue;
|
|
119
|
+
try { const parsed = JSON.parse(block.text); bind(parsed.agent_id ?? parsed.thread_id ?? parsed.agent_path, id); } catch { /* prose */ }
|
|
120
|
+
}
|
|
121
|
+
if (output?.is_error || output?.isError || event.error) { release(id); emit({ id, status: 'stopped' }); }
|
|
122
|
+
}
|
|
123
|
+
function preResume(event) {
|
|
124
|
+
const input = event.tool_input ?? {};
|
|
125
|
+
const native = input.target ?? input.id;
|
|
126
|
+
if (typeof native !== 'string' || native.length > 160) return { deny: 'Name the native agent to resume.' };
|
|
127
|
+
const id = resolve(native);
|
|
128
|
+
if (!records.has(id)) {
|
|
129
|
+
if (records.size >= 32) return { deny: 'Continue the work in this run’s orchestrator.' };
|
|
130
|
+
emit({ id, kind: '', title: native, brief: input.message ?? '', status: 'waiting', now: WAIT });
|
|
131
|
+
}
|
|
132
|
+
if (!slot(id)) { emit({ id, status: 'waiting', now: WAIT }); return { deny: `${WAIT}. Retry the native follow-up when notified. Agent id: ${id}` }; }
|
|
133
|
+
records.get(id).started = true; records.get(id).checkpointSaid = false;
|
|
134
|
+
emit({ id, status: 'running', now: 'Continuing with the orchestrator’s note' });
|
|
135
|
+
calls.set(event.tool_use_id, id);
|
|
136
|
+
return typeof input.message === 'string' ? { updatedInput: { ...input,
|
|
137
|
+
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.` } } : {};
|
|
138
|
+
}
|
|
139
|
+
function checkpoint(event) {
|
|
140
|
+
const id = resolve(event.agent_id ?? event.session_id);
|
|
141
|
+
if (!records.has(id)) return {};
|
|
142
|
+
const queued = [...notes.values()].filter(n => n.state === 'queued' && n.agentId === id);
|
|
143
|
+
if (!queued.length) return {};
|
|
144
|
+
// The orchestrator, not this hook, chose each checkpoint. Pause tool
|
|
145
|
+
// execution at its next boundary and ask the parent to use native stop /
|
|
146
|
+
// follow-up. The child keeps its worktree and transcript intact.
|
|
147
|
+
const edits = queued.filter(n => n.safePoint === 'after its current edit');
|
|
148
|
+
if (!edits.length) return {};
|
|
149
|
+
if (records.get(id).checkpointSaid) return { deny: 'Remain at the checkpoint until your orchestrator sends the follow-up.' };
|
|
150
|
+
records.get(id).checkpointSaid = true;
|
|
151
|
+
emit({ id, status: 'waiting', now: 'At the checkpoint for a message' });
|
|
152
|
+
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.`);
|
|
153
|
+
return { deny: 'Your orchestrator has a note for this checkpoint. Stop editing and wait for its follow-up; keep your work so far.' };
|
|
154
|
+
}
|
|
155
|
+
async function handle(action, args) {
|
|
156
|
+
if (closed) throw new Error('This run stopped.');
|
|
157
|
+
if (action === 'hook') {
|
|
158
|
+
const ev = args;
|
|
159
|
+
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' }); }
|
|
160
|
+
if (ev.hook_event_name === 'PostToolUseFailure' && (spawnTool(ev.tool_name) || resumeTool(ev.tool_name))) {
|
|
161
|
+
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' }); }
|
|
162
|
+
}
|
|
163
|
+
if (ev.hook_event_name === 'PreToolUse') {
|
|
164
|
+
if (String(ev.tool_name).includes('card_orchestrator') && String(ev.tool_name).endsWith('register_agent')) {
|
|
165
|
+
const id = resolve(ev.tool_input?.agentId);
|
|
166
|
+
if (!records.has(id)) return { deny: 'Use the agent record id in your prompt.' };
|
|
167
|
+
bind(ev.agent_id ?? ev.session_id, id);
|
|
168
|
+
}
|
|
169
|
+
const child = ev.agent_id ?? (rootSession && ev.session_id !== rootSession ? ev.session_id : null);
|
|
170
|
+
if (child && !records.has(resolve(child)) && !['tool_search', 'search_tool'].includes(nativeName(ev.tool_name)))
|
|
171
|
+
return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
|
|
172
|
+
if (spawnTool(ev.tool_name)) return preSpawn(ev);
|
|
173
|
+
if (resumeTool(ev.tool_name)) return preResume(ev);
|
|
174
|
+
const point = checkpoint(ev); if (point.deny) return point;
|
|
175
|
+
if (['AskUserQuestion', 'request_user_input'].includes(nativeName(ev.tool_name)))
|
|
176
|
+
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.' };
|
|
177
|
+
}
|
|
178
|
+
if (ev.hook_event_name === 'PostToolUse' && (spawnTool(ev.tool_name) || resumeTool(ev.tool_name))) postSpawn(ev);
|
|
179
|
+
if (runtime === 'codex' && !ev.agent_id && rootSession && ev.session_id === rootSession) {
|
|
180
|
+
const context = [...inbox.entries()].slice(0, 4); for (const [id] of context) inbox.delete(id);
|
|
181
|
+
return { context: context.map(([, body]) => body).join('\n\n') };
|
|
182
|
+
}
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
if (action === 'route_message') return route(args);
|
|
186
|
+
const id = resolve(args.agentId);
|
|
187
|
+
// A person’s answer starts a new CLI turn. The parent carries the earlier
|
|
188
|
+
// question and native identity in its own conversation; relay that known
|
|
189
|
+
// context instead of inventing a new child or requiring another question.
|
|
190
|
+
if (action === 'answer_agent' && !records.has(id) && typeof id === 'string' && id.length > 0 && id.length <= 160 && records.size < 32 &&
|
|
191
|
+
typeof args.question === 'string' && args.question.trim() && args.question.length <= 4_000) {
|
|
192
|
+
emit({ id, kind: scrubText(args.kind ?? '', 80), title: scrubText(args.title ?? '', 200), brief: '', status: 'asked', now: args.question });
|
|
193
|
+
questions.set(id, { question: args.question });
|
|
194
|
+
if (args.nativeAgentId) bind(args.nativeAgentId, id);
|
|
195
|
+
}
|
|
196
|
+
const a = records.get(id);
|
|
197
|
+
if (!a) throw new Error('Name an agent in this run.');
|
|
198
|
+
if (action === 'register_agent') return { registered: true, agentId: id };
|
|
199
|
+
if (action === 'describe_agent') {
|
|
200
|
+
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.');
|
|
201
|
+
emit({ id, kind: args.kind, title: args.title, namedKind: args.kind, namedTitle: args.title });
|
|
202
|
+
return { recorded: true };
|
|
203
|
+
}
|
|
204
|
+
if (action === 'agent_question') {
|
|
205
|
+
let q = questions.get(id);
|
|
206
|
+
if (!q || q.question !== args.question) {
|
|
207
|
+
if (typeof args.question !== 'string' || args.question.length > 4_000) throw new Error('Keep the question within 4000 characters.');
|
|
208
|
+
q = { question: args.question }; questions.set(id, q); emit({ id, status: 'asked', now: args.question });
|
|
209
|
+
trace.prose('note', args.question, id);
|
|
210
|
+
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.`);
|
|
211
|
+
}
|
|
212
|
+
return q.answer ? { answer: q.answer } : { waiting: true, escalated: q.escalated === true };
|
|
213
|
+
}
|
|
214
|
+
if (action === 'answer_agent') {
|
|
215
|
+
const q = questions.get(id); if (!q) throw new Error('That agent has no unanswered question.');
|
|
216
|
+
if (args.escalate === true) { q.escalated = true; return { askingAgent: { id, nativeAgentId: a.nativeId, kind: a.kind, title: a.title },
|
|
217
|
+
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.' }; }
|
|
218
|
+
if (typeof args.answer !== 'string' || !args.answer.trim() || args.answer.length > 4_000) throw new Error('Give the agent a bounded answer.');
|
|
219
|
+
q.answer = args.answer; trace.handoff(id, runtime, args.answer); emit({ id, status: a.release ? 'running' : 'waiting', now: 'Question answered by the orchestrator' });
|
|
220
|
+
return { answered: true, instruction: 'Give this answer to the child with its native follow-up/resume tool.' };
|
|
221
|
+
}
|
|
222
|
+
throw new Error('Unknown orchestration action.');
|
|
223
|
+
}
|
|
224
|
+
const server = createServer(async (req, res) => {
|
|
225
|
+
if (closed || req.method !== 'POST' || req.url !== '/' || !sameSecret(req.headers.authorization, `Bearer ${capability}`)) { res.writeHead(403).end(); return; }
|
|
226
|
+
try {
|
|
227
|
+
let raw = ''; for await (const chunk of req) { raw += chunk; if (Buffer.byteLength(raw) > 64 * 1024) throw new Error('Orchestration input is too large.'); }
|
|
228
|
+
const { action, args } = JSON.parse(raw);
|
|
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(409, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: text(error.message, 300) })); }
|
|
232
|
+
});
|
|
233
|
+
server.requestTimeout = 5_000;
|
|
234
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
235
|
+
const env = { FLOWVIANT_ORCHESTRATOR_URL: `http://127.0.0.1:${server.address().port}/`, FLOWVIANT_ORCHESTRATOR_CAPABILITY: capability };
|
|
236
|
+
const argv = runningCompiledBinary() ? ['__orchestrator-mcp'] : [fileURLToPath(new URL('../cli.mjs', import.meta.url)), '__orchestrator-mcp'];
|
|
237
|
+
const timer = setInterval(() => {
|
|
238
|
+
if (closed || polling) return;
|
|
239
|
+
polling = true;
|
|
240
|
+
try {
|
|
241
|
+
for (const a of records.values()) if (a.status === 'waiting' && !a.started && !a.release && slot(a.id)) {
|
|
242
|
+
emit({ id: a.id, status: 'waiting', now: 'Ready to start' });
|
|
243
|
+
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.`);
|
|
244
|
+
}
|
|
245
|
+
} finally { polling = false; }
|
|
246
|
+
}, interval);
|
|
247
|
+
timer.unref?.();
|
|
248
|
+
return { env, config: { command: process.execPath, args: argv, env }, handle,
|
|
249
|
+
root(id) { if (typeof id === 'string') rootSession = id; },
|
|
250
|
+
resolve,
|
|
251
|
+
spawnResult(result) {
|
|
252
|
+
const id = calls.get(result.callId);
|
|
253
|
+
if (!id) return;
|
|
254
|
+
if (result.success) { if (result.nativeId) bind(result.nativeId, id); }
|
|
255
|
+
else if (records.get(id)?.started) { release(id); emit({ id, status: 'stopped', now: 'The native spawn did not start' }); }
|
|
256
|
+
},
|
|
257
|
+
attributed(native, step) {
|
|
258
|
+
const id = resolve(native);
|
|
259
|
+
if (records.has(id)) { step(id); return; }
|
|
260
|
+
if (!steps.has(native) && steps.size >= 32) return;
|
|
261
|
+
const queue = steps.get(native) ?? []; queue.push(step); if (queue.length > 40) queue.shift(); steps.set(native, queue);
|
|
262
|
+
},
|
|
263
|
+
note(message) { if (!notes.has(message.id) && notes.size < 64) notes.set(message.id, { ...message, messageId: message.id,
|
|
264
|
+
state: message.route?.state ?? 'deciding', ...(message.route?.nativeAgentId ? { agentId: resolve(message.route.nativeAgentId), safePoint: message.route.safePoint } : {}) }); },
|
|
265
|
+
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 })); },
|
|
266
|
+
input(id, body) { if (closed || inbox.size >= 64 && !inbox.has(id)) return false; inbox.set(id, body); return true; },
|
|
267
|
+
observe(update) {
|
|
268
|
+
if (update.nativeId) bind(update.nativeId, update.id);
|
|
269
|
+
if (update.callId && calls.has(update.callId)) bind(update.id, calls.get(update.callId));
|
|
270
|
+
let id = resolve(update.id);
|
|
271
|
+
if (!records.has(id)) {
|
|
272
|
+
// Bind from a native receipt, never a guessed match on title/kind.
|
|
273
|
+
if (pending.size < 128 || pending.has(id)) pending.set(id, { ...pending.get(id), ...update });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const a = records.get(id);
|
|
277
|
+
if (a?.status === 'waiting' && !a.started) {
|
|
278
|
+
const { status: _status, now: _now, ...metadata } = update;
|
|
279
|
+
emit({ ...metadata, id }); return;
|
|
280
|
+
}
|
|
281
|
+
const finished = terminal(update.status);
|
|
282
|
+
if (finished) release(id);
|
|
283
|
+
if (questions.has(id) && !questions.get(id).answer) update = { ...update, status: 'asked' };
|
|
284
|
+
emit({ ...update, id });
|
|
285
|
+
if (finished) for (const note of notes.values()) if (note.state === 'queued' && note.agentId === id)
|
|
286
|
+
announce(`Agent ${id} finished. Deliver note ${note.messageId} in its next task or a native follow-up and report handed at that checkpoint.`);
|
|
287
|
+
},
|
|
288
|
+
async close(stopped = false) {
|
|
289
|
+
closed = true; clearInterval(timer);
|
|
290
|
+
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' }); }
|
|
291
|
+
server.close(); server.closeAllConnections();
|
|
292
|
+
forgetExtraSecrets(secretKey);
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { ORCHESTRATOR_TOOLS } from './orchestratorProtocol.mjs';
|
|
3
|
+
|
|
4
|
+
export async function callOrchestrator(action, args) {
|
|
5
|
+
const url = new URL(process.env.FLOWVIANT_ORCHESTRATOR_URL);
|
|
6
|
+
if (url.hostname !== '127.0.0.1' || url.protocol !== 'http:') throw new Error('No held run is available.');
|
|
7
|
+
const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_ORCHESTRATOR_CAPABILITY}`, 'Content-Type': 'application/json' },
|
|
8
|
+
body: JSON.stringify({ action, args }), signal: AbortSignal.timeout(15_000) });
|
|
9
|
+
const result = await response.json();
|
|
10
|
+
if (!response.ok) throw new Error(result.error ?? 'This run is no longer held.');
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function orchestratorMcpMain() {
|
|
15
|
+
for await (const line of createInterface({ input: process.stdin })) {
|
|
16
|
+
let req;
|
|
17
|
+
try {
|
|
18
|
+
req = JSON.parse(line); if (req.id === undefined) continue;
|
|
19
|
+
let result;
|
|
20
|
+
if (req.method === 'initialize') result = { protocolVersion: req.params?.protocolVersion ?? '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'card-orchestrator', version: '2' } };
|
|
21
|
+
else if (req.method === 'ping') result = {};
|
|
22
|
+
else if (req.method === 'tools/list') result = { tools: ORCHESTRATOR_TOOLS };
|
|
23
|
+
else if (req.method === 'tools/call' && ORCHESTRATOR_TOOLS.some(t => t.name === req.params?.name)) {
|
|
24
|
+
try { result = { content: [{ type: 'text', text: JSON.stringify(await callOrchestrator(req.params.name, req.params.arguments ?? {})) }] }; }
|
|
25
|
+
catch (error) { result = { isError: true, content: [{ type: 'text', text: error.message }] }; }
|
|
26
|
+
} else { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Unknown method' } })}\n`); continue; }
|
|
27
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: req.id, result })}\n`);
|
|
28
|
+
} catch { if (req?.id !== undefined) process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: req.id, error: { code: -32602, message: 'Invalid request' } })}\n`); }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const ORCHESTRATOR_CONTRACT = `CARD ORCHESTRATOR — this applies to this card's one CLI and worktree.
|
|
2
|
+
You are the model the person chose and remain responsible for the card and its final JSON.
|
|
3
|
+
Work yourself when the card spans one area. Only split work spanning several areas into native in-process subagents by kind (Backend/UI/Tests…). This is authorization to use those native agents on this card. Give each a concrete title, prompt and kind, and post short updates of your own. Do not create extra branches or Flowviant agents. Use a native agent type that can call card_orchestrator MCP (Claude general-purpose); keep the work yourself if none can. After spawning, use describe_agent to report the kind and title you chose when its native role is generic.
|
|
4
|
+
|
|
5
|
+
The daemon gates every native spawn against this project's Turns at once pool, shared with its other turns. Claude children run in the background so you remain available for messages and their questions; Codex spawns are already asynchronous. A refused spawn with a waiting id has not started. Keep working yourself; do not wait for it. When notified of a free slot, retry its native spawn with the same prompt. Never bypass this gate with another CLI process. The orchestrator itself also holds a slot. If no slot becomes available, finish the work yourself.
|
|
6
|
+
|
|
7
|
+
The card_orchestrator MCP tools report routing and child questions. A live person’s note has a Message id. Report each note using route_message:
|
|
8
|
+
- Handle it yourself: taken.
|
|
9
|
+
- Delegate it: queued, with the native agent id, its kind, and the safePoint chosen FOR THIS NOTE: "when it finishes", "after its current edit", or a precise stated checkpoint.
|
|
10
|
+
- When it finishes: wait for that child's completion, then put the note in its next native task/follow-up.
|
|
11
|
+
- After its current edit: the gate pauses further tool use at the next boundary. Use the native stop/interrupt tool, preserve its work so far, and resume it or start a follow-up with that context and the note. Do not interrupt in the middle of an edit.
|
|
12
|
+
- Only after native delivery at that checkpoint: handed with that agent id/kind/safePoint. This appends "From {orchestrator} …" to its stream. Never report a handoff before doing it.
|
|
13
|
+
Use the tools' agent record id when one is supplied by a native spawn gate. Keep a later native id with that record.
|
|
14
|
+
|
|
15
|
+
All child prompts must say: share this worktree, preserve others' edits, return questions to the orchestrator with agent_question, read its answer before continuing, never escalate directly to the person, and do not commit independently of the card's orchestrator. Answer child questions from the card, code and earlier notes with answer_agent when possible. If only the person can decide, mark escalate with answer_agent, then use the existing blocked JSON question card naming the asking child. Leave that child asked until answered. When the person answers in a resumed run, call answer_agent with the earlier agentId, nativeAgentId, kind/title and question from your conversation plus their answer; then give it to that same child through a native follow-up. Never guess a missing identity.
|
|
16
|
+
|
|
17
|
+
Urgent is the person's explicit exception. The daemon uses the whole-turn interrupt/resume fallback when a child cannot be targeted reliably; the same stored note becomes the resumed prompt and reads "interrupted the run". Stop ends the whole run. Do not keep working or hand off notes after Stop.
|
|
18
|
+
|
|
19
|
+
Claude receives notes through its native live stdin. Codex exec cannot accept stdin mid-turn: on supported code-card CLIs, the daemon injects pending notes through the next native tool hook boundary in the same turn. During a tool-free answer or on fenced/older Codex turns, the note remains for the next run; do not claim it was already read. No new model is involved.`;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The run-scoped MCP vocabulary; safe to load in a credential-free client. */
|
|
2
|
+
export const ORCHESTRATOR_TOOLS = [
|
|
3
|
+
{ name: 'register_agent', description: 'A native child calls this first with the agent record id in its prompt, before working. It binds native events to that record.',
|
|
4
|
+
inputSchema: { type: 'object', properties: { agentId: { type: 'string' } }, required: ['agentId'], additionalProperties: false } },
|
|
5
|
+
{ name: 'describe_agent', description: 'The orchestrator reports the kind and title it chose for a child when the native role is generic. Use Backend, UI, Tests or the actual area of work.',
|
|
6
|
+
inputSchema: { type: 'object', properties: { agentId: { type: 'string' }, kind: { type: 'string' }, title: { type: 'string' } }, required: ['agentId', 'kind', 'title'], additionalProperties: false } },
|
|
7
|
+
{ name: 'route_message', description: 'Report where you routed a person’s live note. Choose its safe point per note. Report handed only after native delivery at that checkpoint; taken means you handled it yourself.',
|
|
8
|
+
inputSchema: { type: 'object', properties: { messageId: { type: 'string' }, state: { enum: ['queued', 'handed', 'taken'] },
|
|
9
|
+
agentId: { type: 'string' }, kind: { type: 'string' }, safePoint: { type: 'string' } }, required: ['messageId', 'state'], additionalProperties: false } },
|
|
10
|
+
{ name: 'agent_question', description: 'A child asks its orchestrator before asking the person. Call again to read an answer. The orchestrator answers from the card/code/earlier notes or escalates a real person-only decision.',
|
|
11
|
+
inputSchema: { type: 'object', properties: { agentId: { type: 'string' }, question: { type: 'string' } }, required: ['agentId', 'question'], additionalProperties: false } },
|
|
12
|
+
{ name: 'answer_agent', description: 'Answer a child question from known facts, or mark it for escalation to the person. An escalation returns the attribution to put on the existing blocked question card.',
|
|
13
|
+
inputSchema: { type: 'object', properties: { agentId: { type: 'string' }, answer: { type: 'string' }, escalate: { type: 'boolean' },
|
|
14
|
+
question: { type: 'string' }, nativeAgentId: { type: 'string' }, kind: { type: 'string' }, title: { type: 'string' } }, required: ['agentId'], additionalProperties: false } },
|
|
15
|
+
];
|