flowviant 0.125.5 → 0.125.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/bin/lib/agentInternalWait.mjs +1 -5
  2. package/bin/lib/agentPlan.mjs +13 -163
  3. package/bin/lib/agentRepositories.mjs +3 -1
  4. package/bin/lib/claudeInput.mjs +28 -9
  5. package/bin/lib/claudeMachineMcp.mjs +85 -0
  6. package/bin/lib/claudePersonal.mjs +6 -57
  7. package/bin/lib/claudePosture.mjs +6 -15
  8. package/bin/lib/claudeSettingsArg.mjs +67 -0
  9. package/bin/lib/claudeSubagentProgress.mjs +12 -28
  10. package/bin/lib/claudeSubagents.mjs +4 -2
  11. package/bin/lib/codexFencedMount.mjs +275 -0
  12. package/bin/lib/codexPersonal.mjs +1 -265
  13. package/bin/lib/codexProjectRead.mjs +5 -4
  14. package/bin/lib/codexResearch.mjs +9 -3
  15. package/bin/lib/codexSubagentEvents.mjs +11 -7
  16. package/bin/lib/codexSubagents.mjs +55 -52
  17. package/bin/lib/codexToml.mjs +328 -0
  18. package/bin/lib/conversationIds.mjs +10 -0
  19. package/bin/lib/fleet.mjs +2 -2
  20. package/bin/lib/git.mjs +32 -0
  21. package/bin/lib/heldTool.mjs +49 -0
  22. package/bin/lib/hooks/mcpAllow.mjs +11 -5
  23. package/bin/lib/hooks/orchestrator.mjs +20 -10
  24. package/bin/lib/intakeDraft.mjs +1 -1
  25. package/bin/lib/jsonlTail.mjs +52 -0
  26. package/bin/lib/mcpInventory.mjs +9 -5
  27. package/bin/lib/mcpReasons.mjs +20 -0
  28. package/bin/lib/mcpReportBounds.mjs +10 -0
  29. package/bin/lib/mcpStdio.mjs +35 -0
  30. package/bin/lib/mergeBatch.mjs +6 -24
  31. package/bin/lib/mergeOrder.mjs +18 -0
  32. package/bin/lib/modelJson.mjs +173 -0
  33. package/bin/lib/nativeAgentTools.mjs +72 -0
  34. package/bin/lib/orchestrator.mjs +212 -133
  35. package/bin/lib/orchestratorClient.mjs +17 -21
  36. package/bin/lib/orchestratorHooks.mjs +21 -4
  37. package/bin/lib/orchestratorNative.mjs +58 -0
  38. package/bin/lib/orchestratorPrompt.mjs +7 -4
  39. package/bin/lib/orchestratorProtocol.mjs +25 -3
  40. package/bin/lib/postureTools.mjs +58 -13
  41. package/bin/lib/prWorkflow.mjs +13 -2
  42. package/bin/lib/projectRepositories.mjs +2 -2
  43. package/bin/lib/projectToolRuntimes.mjs +92 -245
  44. package/bin/lib/projectTools.mjs +59 -19
  45. package/bin/lib/repositoryDelivery.mjs +6 -4
  46. package/bin/lib/repositoryMerge.mjs +8 -12
  47. package/bin/lib/repositoryPlaces.mjs +2 -2
  48. package/bin/lib/repositoryPrompts.mjs +42 -0
  49. package/bin/lib/repositoryTool.mjs +9 -32
  50. package/bin/lib/researchUse.mjs +39 -0
  51. package/bin/lib/resumeMarker.mjs +43 -0
  52. package/bin/lib/runTurn.mjs +34 -52
  53. package/bin/lib/runtimeAntigravity.mjs +1 -1
  54. package/bin/lib/runtimeCapabilities.mjs +6 -16
  55. package/bin/lib/runtimeClaude.mjs +47 -20
  56. package/bin/lib/runtimeCodex.mjs +9 -6
  57. package/bin/lib/runtimeEvents.mjs +13 -2
  58. package/bin/lib/runtimeOfferings.mjs +9 -4
  59. package/bin/lib/runtimes.mjs +3 -1
  60. package/bin/lib/subagentBounds.mjs +20 -0
  61. package/bin/lib/trace.mjs +30 -5
  62. package/bin/lib/turnDeclaration.mjs +1 -1
  63. package/bin/lib/turnDiagnostics.mjs +9 -12
  64. package/bin/lib/turnReceipts.mjs +64 -0
  65. package/bin/lib/turnSubagents.mjs +91 -0
  66. package/bin/lib/turnUsage.mjs +9 -6
  67. package/bin/lib/versionOrder.mjs +11 -1
  68. package/bin/lib/work.mjs +6 -6
  69. package/bin/lib/workAgentMerges.mjs +5 -7
  70. package/bin/lib/workAgentPrecheck.mjs +5 -5
  71. package/bin/lib/workAgentRecovery.mjs +2 -1
  72. package/bin/lib/workAgentResume.mjs +121 -0
  73. package/bin/lib/workAgentTools.mjs +111 -0
  74. package/bin/lib/workAgentTurnExecution.mjs +62 -246
  75. package/bin/lib/workAgentTurnOutcome.mjs +30 -3
  76. package/bin/lib/workAgentTurnShape.mjs +90 -0
  77. package/bin/lib/workAgentTurns.mjs +24 -10
  78. package/bin/lib/workArtifactReconcile.mjs +3 -2
  79. package/bin/lib/workMergeBatch.mjs +7 -5
  80. package/bin/lib/workPlaceReports.mjs +3 -3
  81. package/bin/lib/workPlaces.mjs +14 -0
  82. package/bin/lib/workSessionRuntime.mjs +2 -5
  83. package/bin/lib/workSessionTurns.mjs +12 -63
  84. package/bin/lib/workShip.mjs +8 -18
  85. package/bin/lib/workTurnShape.mjs +6 -4
  86. package/package.json +1 -1
@@ -0,0 +1,72 @@
1
+ /**
2
+ * WHICH NATIVE TOOLS THE ORCHESTRATION GATE ACTS ON (2026-10-18 SOLID pass).
3
+ *
4
+ * The gate's fail-closed list (hooks/orchestrator.mjs), the controller's
5
+ * spawn/follow-up/stop/ask/discovery checks (orchestrator.mjs), Claude's
6
+ * post-hook matcher (orchestratorHooks.mjs) and the Claude/Codex child
7
+ * readers each spelled these by hand, and the hook's copy of the naming rule
8
+ * had already lost two of the controller's names. The fail-closed hook must
9
+ * gate exactly what the controller admits: a name missing from the hook's
10
+ * copy fails OPEN while the controller is down, so that spawn escapes the
11
+ * project's pool (docs/agent-orchestration.md, Hook transport). Both CLIs
12
+ * have renamed these before (Claude's Task to Agent plus SendMessage in
13
+ * 2.1.302, Codex's V1 to V2 collab namespaces in 0.160/0.163); a rename is a
14
+ * row here.
15
+ *
16
+ * A leaf: the hook process loads it on every tool call, and must load no HTTP
17
+ * server or fleet credential with it.
18
+ */
19
+
20
+ /** Each CLI's native subagent tools, by what the gate does with them. */
21
+ export const NATIVE_AGENT_TOOLS = Object.freeze({
22
+ claude: Object.freeze({
23
+ spawn: Object.freeze(['Agent', 'Task']),
24
+ // Claude Code 2.1.302 continues a spawned child with SendMessage({to}).
25
+ followup: Object.freeze(['SendMessage']),
26
+ // Claude children run in the background so the orchestrator stays
27
+ // available for notes and their questions.
28
+ background: Object.freeze(['Agent', 'Task']),
29
+ stop: Object.freeze(['TaskStop']),
30
+ ask: Object.freeze(['AskUserQuestion']),
31
+ discovery: Object.freeze(['ToolSearch']),
32
+ }),
33
+ codex: Object.freeze({
34
+ spawn: Object.freeze(['spawn_agent']),
35
+ followup: Object.freeze(['followup_task', 'resume_agent', 'send_input']),
36
+ // Codex spawns are already asynchronous.
37
+ background: Object.freeze([]),
38
+ stop: Object.freeze(['interrupt_agent', 'close_agent']),
39
+ // Seen in a rollout, never gated.
40
+ message: Object.freeze(['send_message']),
41
+ ask: Object.freeze(['request_user_input']),
42
+ discovery: Object.freeze(['tool_search', 'search_tool']),
43
+ }),
44
+ });
45
+
46
+ // Codex 0.160 V2 serializes the namespace without punctuation in hook
47
+ // tool_name (measured: collaborationspawn_agent). V1 spawn has an alias.
48
+ // V1 writes it as `multi_agent_v1.` (the hook's note, moved here with its
49
+ // copy of this rule).
50
+ export 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)/, '');
51
+
52
+ const every = row => Object.freeze(Object.values(NATIVE_AGENT_TOOLS).flatMap(tools => tools[row] ?? []));
53
+ const SPAWN = every('spawn'), FOLLOWUP = every('followup'), BACKGROUND = every('background');
54
+ const DISCOVERY = every('discovery'), ASK = every('ask');
55
+
56
+ export const isSpawnTool = name => SPAWN.includes(nativeName(name));
57
+ export const isFollowupTool = name => FOLLOWUP.includes(nativeName(name));
58
+ export const isBackgroundSpawnTool = name => BACKGROUND.includes(nativeName(name));
59
+ export const isDiscoveryTool = name => DISCOVERY.includes(nativeName(name));
60
+ export const isPersonQuestionTool = name => ASK.includes(nativeName(name));
61
+ export const isClaudeFollowupTool = name => NATIVE_AGENT_TOOLS.claude.followup.includes(nativeName(name));
62
+ // The controller reads only Claude's stop: TaskStop names its child as task_id.
63
+ export const isClaudeStopTool = name => NATIVE_AGENT_TOOLS.claude.stop.includes(nativeName(name));
64
+
65
+ /** What the hook refuses while the controller is unreachable: every spawn and
66
+ * follow-up the controller gates. */
67
+ export const GATED_TOOLS = Object.freeze([...SPAWN, ...FOLLOWUP]);
68
+ export const gatedSpawnEvent = event => event?.hook_event_name === 'PreToolUse' && GATED_TOOLS.includes(nativeName(event.tool_name));
69
+
70
+ /** Claude's PostToolUse and PostToolUseFailure matcher (orchestratorHooks.mjs):
71
+ * its spawns and follow-ups. */
72
+ export const CLAUDE_POST_HOOK_MATCHER = [...NATIVE_AGENT_TOOLS.claude.spawn, ...NATIVE_AGENT_TOOLS.claude.followup].join('|');
@@ -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,181 @@ 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.started = true; a.denied = null; a.heldAt = null; a.stops = 0;
104
+ for (const n of notes.values()) if (n.paused && n.state === 'queued' && target(n) === a.id) n.delivering = true;
105
+ if (questions.get(a.id)?.answer) questions.get(a.id).read = true;
106
+ emit({ id: a.id, status: 'running', now });
107
+ };
76
108
  function preSpawn(event) {
77
- const 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);
109
+ const spawn = readSpawn(event);
110
+ if (!spawn) return { deny: 'Give this native spawn a text prompt or message so the run can gate it.' };
111
+ const { callId, brief } = spawn;
112
+ const resumed = spawn.resume !== null && records.has(resolve(spawn.resume)) ? resolve(spawn.resume) : null;
113
+ const id = resumed ?? claim(spawn)?.id ?? resolve(callId);
82
114
  aliases.set(callId, id); calls.set(callId, id);
83
115
  if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
84
116
  if (calls.size > 128) calls.delete(calls.keys().next().value);
85
- if (!records.has(id) && records.size >= 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';
117
+ if (!records.has(id) && records.size >= SUBAGENT_CAP) return { deny: `This run already has ${SUBAGENT_CAP} agent records. Continue the work yourself.` };
118
+ const a = emit({ id, brief, title: spawn.title ?? '', kind: spawn.kind ?? '', status: 'waiting', now: WAIT });
119
+ if (!slot(id)) { a.denied = resumed ? 'resume' : 'spawn'; return { deny: `${WAIT}. Agent id: ${id}. Keep working yourself; you will be told when this spawn can be retried. Do not wait for a child that has not started.` }; }
120
+ // Claude's SendMessage addresses a child by its Agent `name`, Codex V2 by its task name.
121
+ for (const name of spawn.names) if (filled(name) && name.length <= 160) aliases.set(name, id);
122
+ start(a, a.title);
93
123
  const childContract = `\n\nCard orchestrator agent record id: ${id}. FIRST call card_orchestrator.register_agent with agentId=${id} before doing work (tool discovery is allowed). Share this worktree and preserve others’ edits. Ask questions through card_orchestrator.agent_question with agentId=${id}; read its answer before proceeding. Only your orchestrator escalates person-only decisions. Return your work to it; it owns the card’s final commit and JSON.`;
94
- return { context: `Agent record id: ${id}. Use it with card_orchestrator for routing and questions.`,
95
- updatedInput: { ...input, ...(['Agent', 'Task'].includes(nativeName(event.tool_name)) ? { run_in_background: true } : {}), [promptKey]: input[promptKey] + childContract } };
124
+ return { context: `Agent record id: ${id}. Use it with card_orchestrator for routing and questions.${spawn.relocated ? ' Its isolation and cwd were removed: children share this card’s worktree on this machine.' : ''}`,
125
+ updatedInput: withChildContract(event, spawn, childContract) };
126
+ }
127
+ // Native readings never revive a settled record or start a waiting spawn
128
+ // or follow-up (Codex records a refused call's start anyway); only an
129
+ // admitted spawn or follow-up does.
130
+ // …except the CLI's own run of a follow-up admitted while the child ran,
131
+ // counted again if its held slot already lapsed.
132
+ const resumeQueued = a => { a.queued = false; slot(a.id); emit({ id: a.id, status: 'running', now: 'Continuing with the orchestrator’s note' }); };
133
+ const apply = (id, update) => {
134
+ const a = records.get(id), { status: _status, now: _now, ...metadata } = update;
135
+ if (terminal(a.status) && a.queued && update.status === 'running') { resumeQueued(a); return; }
136
+ if ((a.status === 'waiting' && (!a.started || a.denied)) || terminal(a.status)) { emit({ ...metadata, id }); return; }
137
+ if (terminal(update.status)) { settle(id, update); return; }
138
+ emit({ ...update, id, ...(questions.has(id) && !questions.get(id).answer ? { status: 'asked' } : {}) });
139
+ };
140
+ function settle(id, update) {
141
+ const a = records.get(id), live = Boolean(a.release) && !a.denied;
142
+ // Claude 2.1.302 and Codex 0.163 run a child again when it finished before
143
+ // a follow-up queued while it ran reached a tool round: hold its slot until
144
+ // that run's own stop.
145
+ if (a.stops > 1) a.queued = false;
146
+ if (!a.queued) release(id); else a.heldAt ??= clock();
147
+ if (terminal(a.status)) return;
148
+ emit({ ...update, id, ...(questions.has(id) && !questions.get(id).answer ? { status: 'asked' } : {}) });
149
+ if (live) for (const note of notes.values()) if (note.state === 'queued' && target(note) === id)
150
+ announce(`Agent ${id} finished. Deliver note ${note.messageId} in its next task or a native follow-up and report handed at that checkpoint.`);
96
151
  }
97
152
  const bind = (native, id) => {
98
153
  if (typeof native !== 'string' || native.length > 160 || !id) return;
99
154
  const target = resolve(id); aliases.set(native, target);
100
155
  if (records.has(target)) records.get(target).nativeId = native;
101
156
  if (pending.has(native)) {
102
- const update = pending.get(native);
103
- if (terminal(update.status)) release(target);
104
- emit({ ...update, id: target }); pending.delete(native);
157
+ const update = pending.get(native); pending.delete(native);
158
+ if (records.has(target)) apply(target, update);
105
159
  }
106
160
  for (const step of steps.get(native) ?? []) step(target);
107
161
  steps.delete(native);
108
162
  if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
109
163
  };
164
+ // Codex V2 names a child `backend` or `/root/backend` interchangeably.
165
+ const named = native => {
166
+ if (aliases.has(native) || records.has(native)) return resolve(native);
167
+ const hits = new Set([...aliases].filter(([k]) => k.endsWith(`/${native}`) || native.endsWith(`/${k}`)).map(([, v]) => v));
168
+ return hits.size === 1 ? [...hits][0] : native;
169
+ };
110
170
  function postSpawn(event) {
111
171
  const id = calls.get(event.tool_use_id);
112
172
  if (!id) return;
113
- 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' }); }
173
+ const { ids, failed } = spawnReceipt(event);
174
+ for (const native of ids) bind(native, id);
175
+ if (failed) { release(id); emit({ id, status: 'stopped' }); }
123
176
  }
124
177
  function preResume(event) {
125
- const input = event.tool_input ?? {};
126
- 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);
178
+ const input = event.tool_input ?? {}, { claude, native } = followupTarget(event);
179
+ if (native === null) return claude ? null : { deny: 'Name the native agent to resume.' };
180
+ const id = named(native);
129
181
  if (!records.has(id)) {
130
- 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 });
182
+ // SendMessage also reaches "main" and teammates: only a known child is a follow-up.
183
+ if (claude) return null;
184
+ if (records.size >= SUBAGENT_CAP) return { deny: 'Continue the work in this run’s orchestrator.' };
185
+ emit({ id, kind: '', title: native, brief: typeof input.message === 'string' ? input.message : '', status: 'waiting', now: WAIT, resumable: true });
132
186
  }
133
- if (!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' });
187
+ if (spawnable(records.get(id))) return { deny: `Agent ${id} has not started. Retry its native spawn instead.` };
188
+ // A refused follow-up retried on its offered slot reaches a finished child.
189
+ const prior = records.get(id), running = Boolean(prior.release) && !prior.denied && !terminal(prior.status);
190
+ if (!slot(id)) { records.get(id).denied = 'resume'; emit({ id, status: 'waiting', now: WAIT }); return { deny: `${WAIT}. Retry the native follow-up when notified. Agent id: ${id}` }; }
191
+ start(records.get(id), 'Continuing with the orchestrator’s note');
192
+ records.get(id).queued = running;
136
193
  calls.set(event.tool_use_id, id);
137
194
  return typeof input.message === 'string' ? { updatedInput: { ...input,
138
195
  message: input.message + `\n\nCurrent card orchestrator agent record id: ${id}. Before continuing work, call card_orchestrator.register_agent with this id. Return questions to the orchestrator.` } } : {};
139
196
  }
140
197
  function checkpoint(event) {
141
- const id = resolve(event.agent_id ?? event.session_id);
198
+ const child = childOf(event);
199
+ if (!child || isDiscoveryTool(event.tool_name) || String(event.tool_name).includes(ORCHESTRATOR_SERVER)) return {};
200
+ const id = resolve(child);
142
201
  if (!records.has(id)) return {};
143
- const queued = [...notes.values()].filter(n => n.state === 'queued' && n.agentId === id);
144
- if (!queued.length) return {};
145
202
  // The orchestrator, not this hook, chose each checkpoint. Pause tool
146
- // execution at its next boundary and ask the parent 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');
203
+ // execution at its next boundary, once per note, and ask the parent for a
204
+ // native follow-up. The child keeps its worktree and transcript intact.
205
+ const edits = [...notes.values()].filter(n => n.state === 'queued' && target(n) === id && !n.delivering && pointOf(n.safePoint) === AFTER_EDIT);
149
206
  if (!edits.length) return {};
150
- 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.`);
207
+ const fresh = edits.filter(n => !n.paused);
208
+ if (!fresh.length) return { deny: 'Remain at the checkpoint until your orchestrator sends the follow-up. Your card_orchestrator tools still work.' };
209
+ for (const n of fresh) n.paused = true;
210
+ const native = records.get(id).nativeId;
211
+ emit({ id, status: 'waiting', now: HELD });
212
+ announce(`Agent ${id} is between tools. ${runtime === 'claude' ? `Send it note ${fresh.map(n => n.messageId).join(' ')} with SendMessage to ${native ?? 'its agent id'}; it arrives at its next tool round` :
213
+ `Stop it with the native tool and resume/follow up with note ${fresh.map(n => n.messageId).join(' ')} plus its work so far`}. Then report handed.`);
154
214
  return { deny: 'Your orchestrator has a note for this checkpoint. Stop editing and wait for its follow-up; keep your work so far.' };
155
215
  }
216
+ function hook(ev) {
217
+ // A child's next tool round carries a follow-up queued to it; after its
218
+ // stop, its own tool use is the CLI running it again with that follow-up.
219
+ const kid = records.get(resolve(childOf(ev)));
220
+ if (kid && ['PreToolUse', 'PostToolUse'].includes(ev.hook_event_name)) { if (kid.queued && terminal(kid.status)) resumeQueued(kid); kid.queued = false; }
221
+ // A native stop (TaskStop takes the agent id or name as task_id) cancels that resume.
222
+ const stopped = ev.hook_event_name === 'PreToolUse' && isClaudeStopTool(ev.tool_name) && records.get(named(String(ev.tool_input?.task_id ?? '')));
223
+ if (stopped) { stopped.queued = false; if (terminal(stopped.status)) release(stopped.id); }
224
+ if (ev.hook_event_name === 'SubagentStop') { const id = resolve(childOf(ev)); if (records.has(id)) { records.get(id).stops = (records.get(id).stops ?? 0) + 1; settle(id, { status: 'done' }); } }
225
+ if (ev.hook_event_name === 'PostToolUseFailure' && (isSpawnTool(ev.tool_name) || isFollowupTool(ev.tool_name))) {
226
+ const id = calls.get(ev.tool_use_id); if (id && records.get(id)?.started) { release(id); emit({ id, status: 'stopped', now: 'The native spawn did not start' }); }
227
+ }
228
+ if (ev.hook_event_name === 'PreToolUse') {
229
+ const child = childOf(ev);
230
+ if (String(ev.tool_name).includes(ORCHESTRATOR_SERVER) && String(ev.tool_name).endsWith('register_agent')) {
231
+ // Binding the root would pause the orchestrator at its children's checkpoints.
232
+ if (!child) return { deny: 'Only a native child registers an agent record. As the orchestrator, use the record id with describe_agent, route_message or answer_agent.' };
233
+ const id = resolve(ev.tool_input?.agentId);
234
+ if (!records.has(id)) return { deny: 'Use the agent record id in your prompt.' };
235
+ bind(child, id);
236
+ }
237
+ if (child && !records.has(resolve(child)) && !isDiscoveryTool(ev.tool_name))
238
+ return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
239
+ if (isSpawnTool(ev.tool_name)) return preSpawn(ev);
240
+ if (isFollowupTool(ev.tool_name)) { const resumed = preResume(ev); if (resumed) return resumed; }
241
+ const point = checkpoint(ev); if (point.deny) return point;
242
+ if (isPersonQuestionTool(ev.tool_name))
243
+ return { deny: 'A child asks its orchestrator with agent_question first. The orchestrator escalates only person-only decisions through the existing blocked JSON question card.' };
244
+ }
245
+ if (ev.hook_event_name === 'PostToolUse' && (isSpawnTool(ev.tool_name) || isFollowupTool(ev.tool_name))) postSpawn(ev);
246
+ return {};
247
+ }
156
248
  async function handle(action, args) {
157
249
  if (closed) throw new Error('This run stopped.');
158
250
  if (action === 'hook') {
159
- const 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 {};
251
+ const result = hook(args);
252
+ // Root PreToolUse/PostToolUse additionalContext is measured on both CLIs.
253
+ if (result.deny || !['PreToolUse', 'PostToolUse'].includes(args.hook_event_name) || args.agent_id || !rootSession || args.session_id !== rootSession) return result;
254
+ const held = [];
255
+ for (const box of [inbox, wakes]) for (const [key, body] of box) if (held.length < 4) { box.delete(key); held.push(body); }
256
+ return held.length ? { ...result, context: [result.context, ...held].filter(Boolean).join('\n\n') } : result;
185
257
  }
186
258
  if (action === 'route_message') return route(args);
187
259
  const id = resolve(args.agentId);
188
260
  // A person’s answer starts a new CLI turn. The parent carries the earlier
189
261
  // question and native identity in its own conversation; relay that known
190
262
  // context instead of inventing a new child or requiring another question.
191
- if (action === 'answer_agent' && !records.has(id) && typeof id === 'string' && id.length > 0 && id.length <= 160 && records.size < 32 &&
263
+ if (action === 'answer_agent' && !records.has(id) && typeof id === 'string' && id.length > 0 && id.length <= 160 && records.size < SUBAGENT_CAP &&
192
264
  typeof args.question === 'string' && args.question.trim() && args.question.length <= 4_000) {
193
- emit({ id, kind: scrubText(args.kind ?? '', 80), title: scrubText(args.title ?? '', 200), brief: '', status: 'asked', now: args.question });
265
+ // Resumable, never spawnable: no slot is offered until a gated follow-up targets it.
266
+ emit({ id, kind: scrubText(args.kind ?? '', SUBAGENT_FIELD_CAPS.kind), title: scrubText(args.title ?? '', SUBAGENT_FIELD_CAPS.title), brief: '', status: 'asked', now: args.question, resumable: true });
194
267
  questions.set(id, { question: args.question });
195
268
  if (args.nativeAgentId) bind(args.nativeAgentId, id);
196
269
  }
@@ -198,32 +271,36 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
198
271
  if (!a) throw new Error('Name an agent in this run.');
199
272
  if (action === 'register_agent') return { registered: true, agentId: id };
200
273
  if (action === 'describe_agent') {
201
- if (typeof args.kind !== 'string' || !args.kind.trim() || args.kind.length > 80 || typeof args.title !== 'string' || !args.title.trim() || args.title.length > 200) throw new Error('Give this agent a bounded kind and title.');
274
+ if (typeof args.kind !== 'string' || !args.kind.trim() || args.kind.length > SUBAGENT_FIELD_CAPS.kind || typeof args.title !== 'string' || !args.title.trim() || args.title.length > SUBAGENT_FIELD_CAPS.title) throw new Error('Give this agent a bounded kind and title.');
202
275
  emit({ id, kind: args.kind, title: args.title, namedKind: args.kind, namedTitle: args.title });
203
276
  return { recorded: true };
204
277
  }
205
278
  if (action === 'agent_question') {
206
279
  let q = questions.get(id);
280
+ // The person answers in a later run; the child ends now so this run settles on the question card.
281
+ if (q?.escalated && !q.answer) return { waiting: true, escalated: true,
282
+ instruction: 'Your orchestrator escalated this to the person. Stop now: end your work with a short summary of where you are. You will be continued with the answer.' };
283
+ // A given answer stands for a reworded poll until the child has it.
284
+ if (q?.answer && (!q.read || q.question === args.question)) { q.read = true; return { answer: q.answer }; }
207
285
  if (!q || q.question !== args.question) {
208
286
  if (typeof args.question !== 'string' || args.question.length > 4_000) throw new Error('Keep the question within 4000 characters.');
209
287
  q = { question: args.question }; questions.set(id, q); emit({ id, status: 'asked', now: args.question });
210
288
  trace.prose('note', args.question, id);
211
289
  announce(`${a.kind || a.title || id} asks: ${args.question}\nAnswer with answer_agent from the card, code and earlier notes if possible. Escalate only a real person-only decision.`);
212
290
  }
213
- return q.answer ? { answer: q.answer } : { waiting: true, escalated: q.escalated === true };
291
+ return { waiting: true, escalated: false };
214
292
  }
215
293
  if (action === 'answer_agent') {
216
294
  const q = questions.get(id); if (!q) throw new Error('That agent has no unanswered question.');
217
295
  if (args.escalate === true) { q.escalated = true; return { askingAgent: { id, nativeAgentId: a.nativeId, kind: a.kind, title: a.title },
218
- question: `${a.kind || a.title || id} asks: ${q.question}`, instruction: 'Return the existing blocked JSON question card with this attribution. Leave the agent asked until the person answers.' }; }
296
+ question: `${a.kind || a.title || id} asks: ${q.question}`, instruction: 'The child is told to stop. Once every child has finished or been stopped, return the existing blocked JSON question card with this attribution. Leave the agent asked until the person answers.' }; }
219
297
  if (typeof args.answer !== 'string' || !args.answer.trim() || args.answer.length > 4_000) throw new Error('Give the agent a bounded answer.');
220
298
  q.answer = args.answer; trace.handoff(id, runtime, args.answer); emit({ id, status: a.release ? 'running' : 'waiting', now: 'Question answered by the orchestrator' });
221
299
  return { answered: true, instruction: 'Give this answer to the child with its native follow-up/resume tool.' };
222
300
  }
223
301
  throw new Error('Unknown orchestration action.');
224
302
  }
225
- const server = createServer(async (req, res) => {
226
- if (closed || req.method !== 'POST' || req.url !== '/' || !sameSecret(req.headers.authorization, `Bearer ${capability}`)) { res.writeHead(403).end(); return; }
303
+ const respond = async (req, res) => {
227
304
  try {
228
305
  const { action, args } = await readOrchestratorInput(req);
229
306
  const result = await handle(action, args ?? {});
@@ -232,18 +309,28 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
232
309
  { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify({
233
310
  error: text(error.message, 300), ...(error instanceof OrchestratorInputTooLarge ? { code: error.code } : {}),
234
311
  })); }
235
- });
236
- server.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'];
312
+ };
313
+ const { server, env, args: argv } = await listenHeldTool({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: 5_000,
314
+ capability, isClosed: () => closed, respond });
240
315
  const timer = setInterval(() => {
241
316
  if (closed || polling) return;
242
317
  polling = true;
243
318
  try {
244
- 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.`);
319
+ const now = clock();
320
+ for (const a of records.values()) {
321
+ // A follow-up sent during the child's last tool round reaches it there
322
+ // and resumes nothing; on Claude no post hook says so (2.1.302).
323
+ if (a.queued && a.release && terminal(a.status)) { if (now - (a.heldAt ??= now) >= hold) release(a.id); continue; }
324
+ if (!a.denied) continue;
325
+ // An offer the parent leaves unused lapses: the slot returns to the
326
+ // project's other turns, and this run is offered one again later.
327
+ if (a.release) { if (now - (a.heldAt ?? now) >= hold) { release(a.id); offerAt = now + hold; emit({ id: a.id, status: 'waiting', now: WAIT }); } continue; }
328
+ if (now < offerAt || !slot(a.id)) continue;
329
+ a.heldAt = now;
330
+ const resume = a.denied === 'resume';
331
+ emit({ id: a.id, status: 'waiting', now: resume ? 'Ready to continue' : 'Ready to start' });
332
+ announce(resume ? `A slot is available for agent ${a.id}. Retry its native follow-up${runtime === 'claude' && a.nativeId ? ` (SendMessage to ${a.nativeId})` : ''} now; the slot is held for about a minute. If it is no longer needed, finish the run yourself.`
333
+ : `A slot is available for agent ${a.id}. Retry its native spawn now, with the same or a reworded prompt; the slot is held for about a minute. If it is no longer needed, finish the run yourself.`);
247
334
  }
248
335
  } finally { polling = false; }
249
336
  }, interval);
@@ -260,37 +347,29 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
260
347
  attributed(native, step) {
261
348
  const id = resolve(native);
262
349
  if (records.has(id)) { step(id); return; }
263
- if (!steps.has(native) && steps.size >= 32) return;
350
+ if (!steps.has(native) && steps.size >= SUBAGENT_CAP) return;
264
351
  const queue = steps.get(native) ?? []; queue.push(step); if (queue.length > 40) queue.shift(); steps.set(native, queue);
265
352
  },
266
353
  note(message) { if (!notes.has(message.id) && notes.size < 64) notes.set(message.id, { ...message, messageId: message.id,
267
- state: message.route?.state ?? 'deciding', ...(message.route?.nativeAgentId ? { agentId: 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 })); },
354
+ state: message.route?.state ?? 'deciding', ...(message.route?.nativeAgentId ? { agentId: message.route.nativeAgentId, safePoint: message.route.safePoint } : {}) }); },
355
+ outstanding() { return [...notes.values()].filter(n => OPEN_MESSAGE_ROUTE_STATES.includes(n.state)).map(n => ({ id: n.messageId, state: n.state, body: n.body, agentId: target(n), safePoint: n.safePoint })); },
269
356
  input(id, body) { if (closed || inbox.size >= 64 && !inbox.has(id)) return false; inbox.set(id, body); return true; },
270
357
  observe(update) {
271
358
  if (update.nativeId) bind(update.nativeId, update.id);
272
359
  if (update.callId && calls.has(update.callId)) bind(update.id, calls.get(update.callId));
273
- let id = resolve(update.id);
360
+ const id = resolve(update.id);
274
361
  if (!records.has(id)) {
275
362
  // Bind from a native receipt, never a guessed match on title/kind.
276
363
  if (pending.size < 128 || pending.has(id)) pending.set(id, { ...pending.get(id), ...update });
277
364
  return;
278
365
  }
279
- 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.`);
366
+ apply(id, update);
290
367
  },
291
368
  async close(stopped = false) {
292
369
  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' }); }
370
+ // Only an escalated question waits for the person; any other asked child ended with the run.
371
+ for (const a of records.values()) { release(a.id); const q = questions.get(a.id);
372
+ if (!terminal(a.status) && !(a.status === 'asked' && !stopped && q?.escalated && !q.answer)) emit({ id: a.id, status: 'stopped', now: stopped ? 'Stopped with the run' : 'Run ended' }); }
294
373
  server.close(); server.closeAllConnections();
295
374
  forgetExtraSecrets(secretKey);
296
375
  },