flowviant 0.125.12 → 0.125.15

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 (42) hide show
  1. package/README.md +24 -1
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/agentFiles.mjs +1 -1
  4. package/bin/lib/agentPrerequisiteContract.mjs +8 -0
  5. package/bin/lib/agentTaskKinds.mjs +4 -4
  6. package/bin/lib/artifactContracts.mjs +15 -11
  7. package/bin/lib/artifactPreview.mjs +2 -2
  8. package/bin/lib/captureAuthority.mjs +82 -0
  9. package/bin/lib/claudePosture.mjs +9 -0
  10. package/bin/lib/codexResearch.mjs +12 -1
  11. package/bin/lib/hooks/orchestrator.mjs +1 -1
  12. package/bin/lib/intermediateArtifacts.mjs +228 -0
  13. package/bin/lib/mcpStdio.mjs +11 -3
  14. package/bin/lib/modelExport.mjs +19 -0
  15. package/bin/lib/modelExportProtocol.mjs +80 -0
  16. package/bin/lib/nativeAgentTools.mjs +4 -1
  17. package/bin/lib/orchestrator.mjs +46 -20
  18. package/bin/lib/orchestratorClient.mjs +2 -2
  19. package/bin/lib/orchestratorPrompt.mjs +70 -0
  20. package/bin/lib/pinnedCapture.mjs +35 -7
  21. package/bin/lib/prompts.mjs +10 -3
  22. package/bin/lib/readOnlyRun.mjs +172 -0
  23. package/bin/lib/runTurn.mjs +1 -0
  24. package/bin/lib/runtimeClaude.mjs +6 -5
  25. package/bin/lib/runtimeCodex.mjs +4 -4
  26. package/bin/lib/turnProfile.mjs +1 -0
  27. package/bin/lib/work.mjs +5 -5
  28. package/bin/lib/workAgentMerges.mjs +8 -8
  29. package/bin/lib/workAgentPlans.mjs +69 -43
  30. package/bin/lib/workAgentReview.mjs +24 -1
  31. package/bin/lib/workAgentTurnExecution.mjs +29 -6
  32. package/bin/lib/workAgentTurnOutcome.mjs +15 -3
  33. package/bin/lib/workAgentTurnShape.mjs +6 -5
  34. package/bin/lib/workAgentTurns.mjs +4 -0
  35. package/bin/lib/workAttachments.mjs +4 -2
  36. package/bin/lib/workCappedTurn.mjs +4 -0
  37. package/bin/lib/workLiveMessages.mjs +2 -2
  38. package/bin/lib/workNarration.mjs +3 -2
  39. package/bin/lib/workPlanRecovery.mjs +2 -21
  40. package/bin/lib/workSessionTurns.mjs +109 -22
  41. package/bin/lib/workToolLog.mjs +3 -0
  42. package/package.json +1 -1
@@ -0,0 +1,80 @@
1
+ /** The bounded model export handshake used inside the pinned browser. */
2
+ import { ARTIFACT_BINARY_MODEL_MAX_BYTES } from './artifactPolicy.mjs';
3
+
4
+ export function usableGlb(bytes) {
5
+ return Buffer.isBuffer(bytes) && bytes.length >= 12 && bytes.length <= ARTIFACT_BINARY_MODEL_MAX_BYTES &&
6
+ bytes.readUInt32LE(0) === 0x46546c67 && bytes.readUInt32LE(4) === 2 && bytes.readUInt32LE(8) === bytes.length;
7
+ }
8
+
9
+ /** A working model input also needs readable glTF JSON, geometry and its
10
+ * declared resources. The browser's existing header/cap rule remains intact;
11
+ * this stricter local prerequisite check prevents a header-only/partial file
12
+ * from advancing the producer. It does not attest shape, scale or engine use. */
13
+ export function usableModelGlb(bytes) {
14
+ if (!usableGlb(bytes)) return false;
15
+ try {
16
+ let offset = 12, json = null, binary = null;
17
+ while (offset < bytes.length) {
18
+ if (offset + 8 > bytes.length) return false;
19
+ const length = bytes.readUInt32LE(offset), type = bytes.readUInt32LE(offset + 4);
20
+ if (length % 4 || offset + 8 + length > bytes.length) return false;
21
+ const chunk = bytes.subarray(offset + 8, offset + 8 + length);
22
+ if (offset === 12 && type !== 0x4e4f534a) return false;
23
+ if (type === 0x4e4f534a) { if (json) return false; json = JSON.parse(chunk.toString('utf8')); }
24
+ if (type === 0x004e4942) { if (binary) return false; binary = chunk; }
25
+ offset += 8 + length;
26
+ }
27
+ if (json?.asset?.version !== '2.0' || !binary || !Array.isArray(json.meshes) || !json.meshes.length ||
28
+ !Array.isArray(json.buffers) || json.buffers.length !== 1) return false;
29
+ const buffer = json.buffers[0];
30
+ if (buffer.uri != null || !Number.isInteger(buffer.byteLength) || buffer.byteLength < 1 ||
31
+ buffer.byteLength > binary.length || binary.length - buffer.byteLength > 3) return false;
32
+ const views = json.bufferViews ?? [], accessors = json.accessors ?? [], images = json.images ?? [];
33
+ if (!Array.isArray(views) || !Array.isArray(accessors) || !Array.isArray(images)) return false;
34
+ if (views.some(view => view.buffer !== 0 || !Number.isInteger(view.byteLength) || view.byteLength < 0 ||
35
+ !Number.isInteger(view.byteOffset ?? 0) || (view.byteOffset ?? 0) < 0 ||
36
+ (view.byteOffset ?? 0) + view.byteLength > buffer.byteLength)) return false;
37
+ if (images.some(image => image.uri != null || !Number.isInteger(image.bufferView) || !views[image.bufferView])) return false;
38
+ if ((json.textures ?? []).some(texture => !Number.isInteger(texture.source) || !images[texture.source])) return false;
39
+ if (!json.meshes.every(mesh => Array.isArray(mesh.primitives) && mesh.primitives.length && mesh.primitives.every(primitive => {
40
+ const position = accessors[primitive.attributes?.POSITION];
41
+ return position && Number.isInteger(position.count) && position.count > 0 && views[position.bufferView];
42
+ }))) return false;
43
+ return Array.isArray(json.nodes) && json.nodes.some(node => Number.isInteger(node.mesh) && json.meshes[node.mesh]);
44
+ } catch { return false; }
45
+ }
46
+
47
+ /** Installed before any page script runs. The top-level sandbox's parent is
48
+ * itself, so the existing parent/source-checked handshake runs unchanged.
49
+ * Accept one solicited answer, bound before conversion over the CDP pipe. */
50
+ export const MODEL_EXPORT_BRIDGE = `(() => {
51
+ let requested = false, settled = false;
52
+ window.__flowviantExport = new Promise(resolve => {
53
+ addEventListener('message', e => {
54
+ if (e.source !== window || settled) return;
55
+ const said = e.data;
56
+ if (!requested && said?.flowviant === 'glb-ready') {
57
+ requested = true;
58
+ window.postMessage({flowviant:'export-glb'}, '*');
59
+ } else if (requested && said?.flowviant === 'glb-error') {
60
+ settled = true;
61
+ const message = String(said.message ?? 'Export failed');
62
+ resolve({error: message.length <= 500 ? message : 'The page returned a long export error. Inspect this page and repair its export handler.'});
63
+ } else if (requested && said?.flowviant === 'glb') {
64
+ settled = true;
65
+ const value = said.glb;
66
+ if (Object.prototype.toString.call(value) !== '[object ArrayBuffer]' ||
67
+ value.byteLength < 12 || value.byteLength > ${ARTIFACT_BINARY_MODEL_MAX_BYTES}) {
68
+ resolve({error:'The page did not return a bounded .glb ArrayBuffer.'}); return;
69
+ }
70
+ const bytes = new Uint8Array(value), head = new DataView(value);
71
+ if (head.getUint32(0,true) !== 0x46546c67 || head.getUint32(4,true) !== 2 || head.getUint32(8,true) !== bytes.length) {
72
+ resolve({error:'The page returned an invalid .glb header.'}); return;
73
+ }
74
+ let binary = '';
75
+ for (let i=0; i<bytes.length; i+=8192) binary += String.fromCharCode(...bytes.subarray(i,i+8192));
76
+ resolve({base64:btoa(binary)});
77
+ }
78
+ });
79
+ });
80
+ })();`;
@@ -74,14 +74,17 @@ export const isShellTool = name => nativeName(name) === 'Bash';
74
74
  export const isClaudeFollowupTool = name => NATIVE_AGENT_TOOLS.claude.followup.includes(nativeName(name));
75
75
  // The controller reads only Claude's stop: TaskStop names its child as task_id.
76
76
  export const isClaudeStopTool = name => NATIVE_AGENT_TOOLS.claude.stop.includes(nativeName(name));
77
+ /** Both runtime hook spellings for the capture server. */
78
+ export const captureToolName = name => String(name ?? '').match(/^(?:mcp__flowviant__|mcp[./]flowviant[./]|flowviant[./])([a-z_]+)$/)?.[1] ?? null;
77
79
 
78
80
  /** What the hook refuses while the controller is unreachable: every spawn and
79
81
  * follow-up the controller gates, and on a run whose children only read
80
82
  * (`children` 'read', a research card) a child's write or shell, which only
81
83
  * the controller can fence read-only. */
82
84
  export const GATED_TOOLS = Object.freeze([...SPAWN, ...FOLLOWUP]);
83
- export const gatedSpawnEvent = (event, children = 'edit') => event?.hook_event_name === 'PreToolUse' &&
85
+ export const gatedSpawnEvent = (event, children = 'edit', readOnlyRun = false) => event?.hook_event_name === 'PreToolUse' &&
84
86
  (GATED_TOOLS.includes(nativeName(event.tool_name)) ||
87
+ (readOnlyRun && (isChildWriteTool(event.tool_name) || Boolean(captureToolName(event.tool_name)) || /^(?:mcp__|mcp[./])/.test(String(event.tool_name)))) ||
85
88
  (children === 'read' && Boolean(event.agent_id) && (isChildWriteTool(event.tool_name) || isShellTool(event.tool_name))));
86
89
 
87
90
  /** Claude's PostToolUse and PostToolUseFailure matcher (orchestratorHooks.mjs):
@@ -11,7 +11,7 @@ import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs'
11
11
  import { OrchestratorInputTooLarge, readOrchestratorInput } from './orchestratorInput.mjs';
12
12
  import { SAFE_POINT_AFTER_EDIT as AFTER_EDIT } from './orchestratorProtocol.mjs';
13
13
  import { MACHINE_MESSAGE_ROUTE_STATES, MESSAGE_ROUTE_FIELD_MAX, OPEN_MESSAGE_ROUTE_STATES, ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
14
- import { isChildWriteTool, isClaudeStopTool, isDiscoveryTool, isFollowupTool, isPersonQuestionTool, isShellTool, isSpawnTool } from './nativeAgentTools.mjs';
14
+ import { captureToolName, isChildWriteTool, isClaudeStopTool, isDiscoveryTool, isFollowupTool, isPersonQuestionTool, isShellTool, isSpawnTool } from './nativeAgentTools.mjs';
15
15
  import { childBrain, childChoice, followupTarget, readOnlyShell, readSpawn, spawnReceipt, withChildContract } from './orchestratorNative.mjs';
16
16
  import { SETTLED_SUBAGENT_STATUSES, SUBAGENT_CAP, SUBAGENT_FIELD_CAPS } from './subagentBounds.mjs';
17
17
 
@@ -35,18 +35,23 @@ const text = (s, n) => scrubNativeText(s, n, scrub);
35
35
  * on Codex, only for a model in `catalog`, the measured spawnable models.
36
36
  */
37
37
  export async function createOrchestrator({ turnId, runtime, trace, admit, request = fleetRequest, notify = () => false, taken = () => {},
38
- interval = 500, hold = 60_000, clock = Date.now, scrubText = text, children = {} } = {}) {
39
- const access = children.access === 'read' ? 'read' : 'edit',
38
+ interval = 500, hold = 60_000, clock = Date.now, scrubText = text, children = {}, readOnlyRun = null, signal, rootTools = null } = {}) {
39
+ // Consults belong to a held attempt, never to a card turn. This capability
40
+ // is entirely local; it cannot report routes or borrow an agent principal.
41
+ if (readOnlyRun && (!['plan', 'capture'].includes(readOnlyRun.scope) || !filled(readOnlyRun.id) ||
42
+ !Number.isSafeInteger(readOnlyRun.attempt) || readOnlyRun.attempt < 0)) throw new Error('A read-only run needs its held attempt identity.');
43
+ if (rootTools && readOnlyRun?.scope !== 'capture') throw new Error('Only a capture coordinator may authorize capture tools.');
44
+ const access = (readOnlyRun || children.access === 'read') ? 'read' : 'edit',
40
45
  card = { model: children.model ?? null, effort: children.effort ?? null, catalog: Array.isArray(children.catalog) ? children.catalog : [] };
41
46
  // A Codex research child's shell runs in a read-only bubblewrap view
42
47
  // (orchestratorNative.mjs `readOnlyShell`); without one it is refused.
43
48
  // Claude's research shell is the posture's guarded readers already.
44
- const bwrap = runtime === 'codex' && access === 'read'
49
+ const bwrap = runtime === 'codex' && access === 'read' && !readOnlyRun
45
50
  ? (children.bwrap !== undefined ? children.bwrap : ['/usr/bin/bwrap', '/bin/bwrap'].find(path => existsSync(path)) ?? null) : null;
46
51
  if (typeof admit !== 'function' || typeof admit.reserve !== 'function') throw new Error('This run has no project admission pool.');
47
52
  // Minted before the server exists, so the scrubber knows it from the start.
48
53
  const capability = mintHeldCapability();
49
- const secretKey = `orchestrator:${turnId}:${capability.slice(0, 8)}`;
54
+ const secretKey = `orchestrator:${readOnlyRun ? `${readOnlyRun.scope}:${readOnlyRun.id}:${readOnlyRun.attempt}` : turnId}:${capability.slice(0, 8)}`;
50
55
  learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_ORCHESTRATOR_CAPABILITY', value: capability }]);
51
56
  const records = new Map(), aliases = new Map(), calls = new Map(), notes = new Map(), questions = new Map();
52
57
  // A person's notes (Codex) and the daemon's wakes queue apart: a burst of
@@ -54,7 +59,8 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
54
59
  const inbox = new Map(), wakes = new Map();
55
60
  const pending = new Map(), steps = new Map();
56
61
  let closed = false, polling = false, rootSession = null, offerAt = 0;
57
- const identity = { scope: 'agent', turnId, instance: DAEMON_INSTANCE };
62
+ const identity = readOnlyRun ? { ...readOnlyRun, instance: DAEMON_INSTANCE } : { scope: 'agent', turnId, instance: DAEMON_INSTANCE };
63
+ const ended = () => closed || signal?.aborted;
58
64
  const resolve = id => aliases.get(id) ?? id;
59
65
  // A re-held note keeps its native id; resolve it when matched, not when held.
60
66
  const target = note => note.agentId ? resolve(note.agentId) : undefined;
@@ -78,6 +84,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
78
84
  };
79
85
  const field = (key, value, cap) => { const v = filled(value) ? scrubText(value, cap) : ''; return v.trim() ? { [key]: v } : {}; };
80
86
  const route = async args => {
87
+ if (readOnlyRun) throw new Error('Read-only coordination has no card routing or board-write capability.');
81
88
  const note = notes.get(args.messageId);
82
89
  if (!note) throw new Error('That note is not held by this run.');
83
90
  if (!MACHINE_MESSAGE_ROUTE_STATES.includes(args.state)) throw new Error('Choose queued, handed or taken.');
@@ -148,9 +155,11 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
148
155
  // stream, and the parent is asked to say why in its own update.
149
156
  const choice = childChoice(event, spawn, card);
150
157
  if (choice) trace.prose('note', `Runs on ${choice}.`, id);
151
- 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. When you are done, end with your work or findings as your final message: that is how they reach your orchestrator, which owns the card’s final commit and JSON. agent_question is only for a question you cannot settle yourself, never for handing back results.`;
158
+ const childContract = readOnlyRun
159
+ ? `\n\nRead-only investigation agent record id: ${id}. FIRST call card_orchestrator.register_agent with agentId=${id} before work (tool discovery is allowed). Share this checkout and its repository/knowledge read boundary. Write no file, edit nothing, commit nothing, start no work and call no board or staging tool. Return findings, source paths and uncertainties in your final message to the coordinator. Ask questions only through card_orchestrator.agent_question with agentId=${id}, and read its answer before proceeding. ${readOnlyRun.scope === 'capture' ? 'Call no flowviant tool, including streaming. Only the coordinator dedupes and authors staged outcomes and asks the person through the existing single-question flowviant-ask fence.' : 'Only the coordinator reconciles the complete workload and returns the proposal.'}`
160
+ : `\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. When you are done, end with your work or findings as your final message: that is how they reach your orchestrator, which owns the card’s final commit and JSON. agent_question is only for a question you cannot settle yourself, never for handing back results.`;
152
161
  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.' : ''}${choice ? ` This child runs on ${choice}: name that choice and why in your next update.` : ''}`,
153
- updatedInput: withChildContract(event, spawn, childContract + (access === 'read' ? READ_ONLY_CHILD : ''), chosen.brain) };
162
+ updatedInput: withChildContract(event, spawn, childContract + (access === 'read' && !readOnlyRun ? READ_ONLY_CHILD : ''), chosen.brain) };
154
163
  }
155
164
  // Native readings never revive a settled record or start a waiting spawn
156
165
  // or follow-up (Codex records a refused call's start anyway); only an
@@ -214,6 +223,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
214
223
  if (native === null) return claude ? null : { deny: 'Name the native agent to resume.' };
215
224
  const id = named(native);
216
225
  if (!records.has(id)) {
226
+ if (readOnlyRun) return { deny: 'That child does not belong to this read-only attempt. Start a new investigation here.' };
217
227
  // SendMessage also reaches "main" and teammates: only a known child is a follow-up.
218
228
  if (claude) return null;
219
229
  if (records.size >= SUBAGENT_CAP) return { deny: 'Continue the work in this run’s orchestrator.' };
@@ -271,22 +281,30 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
271
281
  }
272
282
  if (child && !records.has(resolve(child)) && !isDiscoveryTool(ev.tool_name))
273
283
  return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
274
- if (child && access === 'read' && isChildWriteTool(ev.tool_name)) return { deny: READ_ONLY_REFUSAL };
284
+ if ((child && access === 'read' || readOnlyRun) && isChildWriteTool(ev.tool_name)) return { deny: readOnlyRun ? 'This attempt only reads: no file writes.' : READ_ONLY_REFUSAL };
285
+ if (rootTools && captureToolName(ev.tool_name)) {
286
+ if (child || !rootSession || ev.session_id !== rootSession) return { deny: 'Only the top-level capture coordinator may call capture tools. Return findings to it.' };
287
+ return rootTools.authorize(ev);
288
+ }
289
+ if (readOnlyRun && /^(?:mcp__|mcp[./])/.test(String(ev.tool_name)) && !String(ev.tool_name).includes(ORCHESTRATOR_SERVER))
290
+ return { deny: 'This read-only attempt has only internal coordination tools; no board, staging or external MCP capability.' };
275
291
  if (isSpawnTool(ev.tool_name)) return preSpawn(ev);
276
292
  if (isFollowupTool(ev.tool_name)) { const resumed = preResume(ev); if (resumed) return resumed; }
277
293
  const point = checkpoint(ev); if (point.deny) return point;
278
- if (child && access === 'read' && runtime === 'codex' && isShellTool(ev.tool_name)) {
294
+ if (child && access === 'read' && !readOnlyRun && runtime === 'codex' && isShellTool(ev.tool_name)) {
279
295
  const fenced = readOnlyShell(ev, bwrap);
280
296
  return fenced ? { updatedInput: fenced } : { deny: `${READ_ONLY_REFUSAL} ${bwrap ? 'Its shell could not be fenced read-only: give the command as text.' : 'This machine has no bubblewrap to keep a child’s shell read-only, so a child here searches the web; the orchestrator reads files.'}` };
281
297
  }
282
298
  if (isPersonQuestionTool(ev.tool_name))
283
- 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.' };
299
+ return { deny: readOnlyRun?.scope === 'capture' ? 'A child asks the coordinator with agent_question. Only the coordinator asks a person-only question through the existing single-question flowviant-ask fence.' : 'A child asks its orchestrator with agent_question first. The orchestrator escalates only person-only decisions through the existing blocked JSON question card.' };
284
300
  }
285
301
  if (ev.hook_event_name === 'PostToolUse' && (isSpawnTool(ev.tool_name) || isFollowupTool(ev.tool_name))) postSpawn(ev);
286
302
  return {};
287
303
  }
288
304
  async function handle(action, args) {
289
- if (closed) throw new Error('This run stopped.');
305
+ if (ended()) throw new Error('This run stopped.');
306
+ if (action === 'capture_describe' && rootTools) return rootTools.describe();
307
+ if (action === 'capture_call' && rootTools) return rootTools.call(args);
290
308
  if (action === 'hook') {
291
309
  const result = hook(args);
292
310
  // Root PreToolUse/PostToolUse additionalContext is measured on both CLIs.
@@ -300,7 +318,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
300
318
  // A person’s answer starts a new CLI turn. The parent carries the earlier
301
319
  // question and native identity in its own conversation; relay that known
302
320
  // context instead of inventing a new child or requiring another question.
303
- if (action === 'answer_agent' && !records.has(id) && typeof id === 'string' && id.length > 0 && id.length <= 160 && records.size < SUBAGENT_CAP &&
321
+ if (!readOnlyRun && action === 'answer_agent' && !records.has(id) && typeof id === 'string' && id.length > 0 && id.length <= 160 && records.size < SUBAGENT_CAP &&
304
322
  typeof args.question === 'string' && args.question.trim() && args.question.length <= 4_000) {
305
323
  // Resumable, never spawnable: no slot is offered until a gated follow-up targets it.
306
324
  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 });
@@ -319,7 +337,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
319
337
  let q = questions.get(id);
320
338
  // The person answers in a later run; the child ends now so this run settles on the question card.
321
339
  if (q?.escalated && !q.answer) return { waiting: true, escalated: true,
322
- 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.' };
340
+ instruction: readOnlyRun ? 'Stop this investigation and return your findings and the unresolved question to the coordinator. This attempt will not resume after it ends.' : '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.' };
323
341
  // A given answer stands for a reworded poll until the child has it.
324
342
  if (q?.answer && (!q.read || q.question === args.question)) { q.read = true; return { answer: q.answer }; }
325
343
  if (!q || q.question !== args.question) {
@@ -333,7 +351,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
333
351
  if (action === 'answer_agent') {
334
352
  const q = questions.get(id); if (!q) throw new Error('That agent has no unanswered question.');
335
353
  if (args.escalate === true) { q.escalated = true; return { askingAgent: { id, nativeAgentId: a.nativeId, kind: a.kind, title: a.title },
336
- 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.' }; }
354
+ question: `${a.kind || a.title || id} asks: ${q.question}`, instruction: readOnlyRun?.scope === 'capture' ? 'The child is told to stop and return findings. Consolidate person-only uncertainties into the existing one-question flowviant-ask fence in this conversation. Keep staged row IDs, provisional assumptions and the coordinator final reply; do not return a card-delivery JSON or stage a card per child.' : readOnlyRun ? 'The child is told to stop and return findings. Retain the uncertainty in proposal evidence, or use the existing question shape if this is a diagnostic turn. Keep the turn’s existing final JSON protocol.' : '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.' }; }
337
355
  if (typeof args.answer !== 'string' || !args.answer.trim() || args.answer.length > 4_000) throw new Error('Give the agent a bounded answer.');
338
356
  q.answer = args.answer; trace.handoff(id, runtime, args.answer); emit({ id, status: a.release ? 'running' : 'waiting', now: 'Question answered by the orchestrator' });
339
357
  return { answered: true, instruction: 'Give this answer to the child with its native follow-up/resume tool.' };
@@ -350,12 +368,17 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
350
368
  error: text(error.message, 300), ...(error instanceof OrchestratorInputTooLarge ? { code: error.code } : {}),
351
369
  })); }
352
370
  };
353
- const { server, env: heldEnv, args: argv } = await listenHeldTool({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: 5_000,
354
- capability, isClosed: () => closed, respond });
371
+ let listener;
372
+ try {
373
+ listener = await listenHeldTool({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: 5_000,
374
+ capability, isClosed: ended, respond });
375
+ } catch (error) { forgetExtraSecrets(secretKey); throw error; }
376
+ const { server, env: heldEnv, args: argv } = listener;
355
377
  // The hook reads it to refuse a research child's write while this is down.
356
- const env = { ...heldEnv, FLOWVIANT_ORCHESTRATOR_CHILDREN: access };
378
+ const env = { ...heldEnv, FLOWVIANT_ORCHESTRATOR_CHILDREN: access,
379
+ ...(readOnlyRun ? { FLOWVIANT_ORCHESTRATOR_READ_ONLY: '1' } : {}) };
357
380
  const timer = setInterval(() => {
358
- if (closed || polling) return;
381
+ if (ended() || polling) return;
359
382
  polling = true;
360
383
  try {
361
384
  const now = clock();
@@ -397,6 +420,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
397
420
  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 })); },
398
421
  input(id, body) { if (closed || inbox.size >= 64 && !inbox.has(id)) return false; inbox.set(id, body); return true; },
399
422
  observe(update) {
423
+ if (ended()) return;
400
424
  if (update.nativeId) bind(update.nativeId, update.id);
401
425
  if (update.callId && calls.has(update.callId)) bind(update.id, calls.get(update.callId));
402
426
  const id = resolve(update.id);
@@ -408,10 +432,12 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
408
432
  apply(id, update);
409
433
  },
410
434
  async close(stopped = false) {
435
+ if (closed) return;
411
436
  closed = true; clearInterval(timer);
437
+ rootTools?.close();
412
438
  // Only an escalated question waits for the person; any other asked child ended with the run.
413
439
  for (const a of records.values()) { release(a.id); const q = questions.get(a.id);
414
- 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' }); }
440
+ if (!terminal(a.status) && !(a.status === 'asked' && !readOnlyRun && !stopped && q?.escalated && !q.answer)) emit({ id: a.id, status: 'stopped', now: stopped ? 'Stopped with the run' : 'Run ended' }); }
415
441
  server.close(); server.closeAllConnections();
416
442
  forgetExtraSecrets(secretKey);
417
443
  },
@@ -9,13 +9,13 @@ export const ORCHESTRATOR_CLIENT_TIMEOUT_MS = 15_000;
9
9
  /** The controller's own refusal (a 409 body's words), relayed as said. */
10
10
  export class OrchestratorRefusal extends Error {}
11
11
 
12
- export async function callOrchestrator(action, args) {
12
+ export async function callOrchestrator(action, args, timeoutMs = ORCHESTRATOR_CLIENT_TIMEOUT_MS) {
13
13
  const url = new URL(process.env.FLOWVIANT_ORCHESTRATOR_URL);
14
14
  if (url.hostname !== '127.0.0.1' || url.protocol !== 'http:') throw new Error('No held run is available.');
15
15
  const body = JSON.stringify({ action, args });
16
16
  if (Buffer.byteLength(body) > ORCHESTRATOR_REQUEST_BYTES) throw new OrchestratorInputTooLarge();
17
17
  const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_ORCHESTRATOR_CAPABILITY}`, 'Content-Type': 'application/json' },
18
- body, signal: AbortSignal.timeout(ORCHESTRATOR_CLIENT_TIMEOUT_MS) });
18
+ body, signal: AbortSignal.timeout(timeoutMs) });
19
19
  // A closed or foreign run answers a bare 403: no body to parse.
20
20
  let result = null;
21
21
  try { result = JSON.parse(await response.text()); } catch { result = null; }
@@ -38,6 +38,76 @@ const atCard = (model, effort) =>
38
38
 
39
39
  const childBrain = ({ runtime, model, effort }) => `CHILD MODEL AND EFFORT. ${atCard(model, effort)} By default every child runs at the card's model and effort${model || effort ? '; the gate names them when your spawn names neither, so a child never falls back to the CLI\'s own global default' : ', which it inherits from this run when your spawn names neither'}. For a simple, well-bounded child — a search, a sweep of file reads, a mechanical edit — you may choose a cheaper model or a lower effort. Keep the card's model and effort for hard or judgment-heavy work. Never choose a model or effort above the card's, and only models of this card's own CLI; the gate refuses an effort above the card's. ${CHILD_SPELLING[runtime] ?? ''} When you choose anything other than the card's model or effort for a child, name the choice and why in your update.`.replace(' ', ' ');
40
40
 
41
+ /** Investigators do not propose separate plans. Only this coordinator sees
42
+ * and reconciles the complete selection before the existing JSON validator. */
43
+ export function planningOrchestratorContract({ diagnosis = false, ...brain } = {}) {
44
+ return `READ-ONLY PLANNING COORDINATOR
45
+ ${diagnosis ? 'You alone return the existing diagnostic JSON for the entire selected workload. A fresh planning consult follows a repaired diagnosis; do not return a proposal here.' : 'You alone return the existing proposal JSON for the entire selected workload.'}
46
+ Identify independent repository questions or code areas before investigating.
47
+ Delegate independent investigations to native children while you inspect one
48
+ part yourself. Dependent investigations wait for their inputs; small inseparable
49
+ work stays solo. Use only native children that can call card_orchestrator, sharing
50
+ this checkout, its repository/knowledge read boundary and runtime restrictions.
51
+ Every child only reads: no file writes, commits, worktrees, board/staging tools,
52
+ project/personal MCP or network capability. Ask for findings, source paths and
53
+ uncertainties, not a partial proposal. A claimed citation is not measured proof
54
+ of a successful read. Preserve reader failures and unknown results.
55
+ Reconcile all findings and conflicts across the complete selection, including
56
+ exact card identities, prerequisites first, declared dependencies, cross-source
57
+ file collisions, retained research/source context and live work. Source composers
58
+ are provenance, never grouping boundaries. Never drop an uninvestigated card.
59
+ ${diagnosis ? 'Keep the existing repaired/question diagnostic shape.' : 'Keep the existing proposal shape and per-group human acceptance: no work starts.'}
60
+ Children ask you through agent_question; answer with answer_agent and its native
61
+ follow-up, from this context and the repository. Retain unresolved uncertainties
62
+ in proposal evidence; the existing diagnostic question protocol still applies
63
+ when diagnosing a failed attempt. Only you ask any person-only question.
64
+ The root and children share the project's Turns at once pool. A refused spawn
65
+ has not started: continue investigating yourself. Retry only when offered a
66
+ slot, and finish solo if none becomes available. Do not bypass admission with
67
+ another CLI process. Wait for every started child to finish or stop before your
68
+ final proposal. Stop ends the whole attempt; stale children cannot be resumed.
69
+ ${childBrain(brain).replaceAll('card', 'planning turn')}`;
70
+ }
71
+
72
+ export function captureOrchestratorContract(brain = {}) {
73
+ return `READ-ONLY CAPTURE INVESTIGATIONS
74
+ You own this one ongoing conversation and its staged proposals. For a broad
75
+ request with independent repository questions, give bounded read-only questions
76
+ to native children while you investigate another area. Small focused requests,
77
+ simple corrections and short follow-ups with no independent investigations stay
78
+ solo. Dependent questions wait for their inputs. Use only native children that
79
+ can call card_orchestrator, sharing this checkout and its repository/knowledge
80
+ read boundary. They gain no network or project/personal MCP access.
81
+ Children write no files and call NO flowviant tools, including list/staging or
82
+ stream_session_turn. They return findings, source paths and uncertainties to
83
+ you; you alone ask the person, using the existing single-question fence, and
84
+ you alone read list_cards/list_staged/read_card, dedupe the complete request,
85
+ stage, edit or withdraw rows and stream the conversation. The tool gate and
86
+ credential relay enforce this for descendants and resumed children too.
87
+ Before staging, reconcile all findings with the original request, selected
88
+ repository catalog, exact attached/retained reference versions, prior decisions,
89
+ assumptions and dependencies. A claimed citation does not establish a successful
90
+ read. Preserve actual failed/unknown reader outcomes. Keep grounded references
91
+ and unresolved assumptions on every affected brief. Stage independently shippable
92
+ outcomes, never one card per child or one card per investigation step. Put
93
+ prerequisites first and describe dependencies in both briefs. Refine existing
94
+ staged rows in place; never duplicate or withdraw/restage them to recover.
95
+ Ordinary conversational questions do not create unsolicited cards. Human
96
+ landing, Plan and acceptance controls remain the only ways work starts.
97
+ Each child registers with card_orchestrator and asks you with agent_question;
98
+ answer from this conversation and repository using answer_agent and native
99
+ follow-up. Consolidate person-only uncertainties into the existing one question.
100
+ Wait for every started child to finish or stop before your final reply. Preserve
101
+ the existing final-answer and streamed-reply protocol; child prose is not a
102
+ second conversation reply. A refused spawn has not started: continue yourself,
103
+ retry only when a slot is offered, and finish solo if none is available.
104
+ The root and children share the project's Turns at once pool. Stop ends the
105
+ whole run. Every new native invocation has a fresh capability; do not resume a
106
+ child from a previous turn or retry. Preserve staged row IDs and use list_staged
107
+ to reconcile an interrupted call before repeating it.
108
+ ${childBrain(brain).replaceAll('card', 'capture turn')}`;
109
+ }
110
+
41
111
  /**
42
112
  * The orchestrator's contract for one run: its posture's split and child
43
113
  * rules, the card's own model and effort, then the gate, routing and child
@@ -46,6 +46,7 @@ import { join } from 'node:path';
46
46
  import { tmpdir, platform } from 'node:os';
47
47
  import { SANDBOX_FAILURE_RE, chromeEnv, chromeFlags, resolveBrowser, sandboxOff } from './shot.mjs';
48
48
  import { killGroup } from './processGroup.mjs';
49
+ import { MODEL_EXPORT_BRIDGE } from './modelExportProtocol.mjs';
49
50
 
50
51
  /** How long a closing browser gets to exit before it is killed. */
51
52
  const EXIT_GRACE_MS = 3_000;
@@ -135,7 +136,7 @@ export function sameDocument(a, b) {
135
136
  * `launchFailed` means the browser never answered on the pipe — the only
136
137
  * failure a different launch (classic headless, or no sandbox) can change.
137
138
  */
138
- async function captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox }, { spawnImpl, env }) {
139
+ async function captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox, modelExport, signal }, { spawnImpl, env }) {
139
140
  const userDataDir = mkdtempSync(join(tmpdir(), 'flowviant-pin-'));
140
141
  const args = [...chromeFlags({ headlessFlag, noSandbox, userDataDir, width, height }), '--remote-debugging-pipe', 'about:blank'];
141
142
  let child;
@@ -190,10 +191,16 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
190
191
  };
191
192
 
192
193
  let left = null; // the first sign the page was left, in words
194
+ let pageError = null;
193
195
  const leave = (why) => { if (!left) left = why; };
194
196
  let deadline;
195
197
  const timedOut = new Promise((resolve) => {
196
- deadline = setTimeout(() => resolve({ ok: false, reason: 'timeout', message: `the page did not finish within ${timeoutMs / 1000}s.` }), timeoutMs);
198
+ deadline = setTimeout(() => resolve({ ok: false, reason: 'timeout', message: `the page did not finish within ${timeoutMs / 1000}s${pageError ? ': ' + pageError : '.'}` }), timeoutMs);
199
+ });
200
+ let onAbort;
201
+ const aborted = new Promise(resolve => {
202
+ onAbort = () => resolve({ ok: false, reason: 'aborted', message: 'The export was interrupted.' });
203
+ if (signal?.aborted) onAbort(); else signal?.addEventListener('abort', onAbort, { once: true });
197
204
  });
198
205
 
199
206
  const run = async () => {
@@ -247,6 +254,10 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
247
254
  return;
248
255
  }
249
256
  if (!ours || msg.sessionId !== ours.sessionId) return;
257
+ if (msg.method === 'Runtime.exceptionThrown') {
258
+ const message = String(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'Page script failed');
259
+ pageError = message.length <= 500 ? message : 'A page script failed. Inspect this page and repair it.';
260
+ }
250
261
  if (msg.method === 'Fetch.requestPaused') {
251
262
  const requestId = p.requestId;
252
263
  const reqUrl = p.request?.url ?? '';
@@ -291,21 +302,32 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
291
302
  ours = { targetId: ourTargetId, sessionId };
292
303
 
293
304
  await cdp.send('Page.enable', {}, sessionId);
305
+ if (modelExport) await cdp.send('Runtime.enable', {}, sessionId);
306
+ if (modelExport) await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: MODEL_EXPORT_BRIDGE }, sessionId);
294
307
  await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, sessionId);
295
308
  await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId);
296
- await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pause' }, sessionId);
309
+ if (!modelExport) await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pause' }, sessionId);
297
310
  await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => {});
298
311
  navigating = true;
299
312
  const nav = await cdp.send('Page.navigate', { url }, sessionId);
300
313
  if (nav.errorText) {
301
314
  return { ok: false, reason: 'render', message: `the page did not load (${nav.errorText}).` };
302
315
  }
303
- // Let fonts and scripts settle, as `--virtual-time-budget` does.
304
- await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pauseIfNetworkFetchesPending', budget: budgetMs }, sessionId);
316
+ // Model readiness/export waits on actual loaded textures and the page's
317
+ // handler. Thumbnails keep their bounded virtual-time screenshot policy.
318
+ if (!modelExport) await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pauseIfNetworkFetchesPending', budget: budgetMs }, sessionId);
319
+ let exported = null;
320
+ if (modelExport) {
321
+ const answer = await cdp.send('Runtime.evaluate', {
322
+ expression: 'window.__flowviantExport', awaitPromise: true, returnByValue: true,
323
+ }, sessionId);
324
+ exported = answer.result?.value;
325
+ if (!exported?.base64) return { ok: false, reason: 'export', message: exported?.error ?? 'The page did not supply its model export.' };
326
+ }
305
327
  // A Chrome that never reports the budget is captured on the wall clock,
306
328
  // short of the deadline, rather than never.
307
329
  let wall;
308
- await Promise.race([budget, new Promise((r) => { wall = setTimeout(r, Math.max(budgetMs, timeoutMs - EXIT_GRACE_MS)); })]);
330
+ if (!modelExport) await Promise.race([budget, new Promise((r) => { wall = setTimeout(r, Math.max(budgetMs, timeoutMs - EXIT_GRACE_MS)); })]);
309
331
  clearTimeout(wall);
310
332
 
311
333
  const history = await cdp.send('Page.getNavigationHistory', {}, sessionId);
@@ -318,6 +340,8 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
318
340
  }
319
341
  if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
320
342
 
343
+ if (modelExport) return { ok: true, base64: exported.base64 };
344
+
321
345
  const shot = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId);
322
346
  // One last look: a navigation that landed during the capture.
323
347
  if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
@@ -329,9 +353,11 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
329
353
  return await Promise.race([
330
354
  run().catch((e) => ({ ok: false, reason: 'render', message: e?.message ?? String(e) })),
331
355
  timedOut,
356
+ aborted,
332
357
  ]);
333
358
  } finally {
334
359
  clearTimeout(deadline);
360
+ signal?.removeEventListener('abort', onAbort);
335
361
  await shutdown();
336
362
  }
337
363
  }
@@ -352,6 +378,8 @@ export async function capturePinnedPage({
352
378
  timeoutMs = 60_000,
353
379
  budgetMs = 5_000,
354
380
  allowSubresource = () => false,
381
+ modelExport = false,
382
+ signal,
355
383
  browser = resolveBrowser(),
356
384
  spawn: spawnImpl = spawn,
357
385
  uid = process.getuid?.(),
@@ -367,7 +395,7 @@ export async function capturePinnedPage({
367
395
  let last = null;
368
396
  for (const headlessFlag of ['--headless=new', '--headless']) {
369
397
  const once = () =>
370
- captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox }, { spawnImpl, env });
398
+ captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox, modelExport, signal }, { spawnImpl, env });
371
399
  let r = await once();
372
400
  if (!r.ok && r.sandboxFailed) {
373
401
  noSandbox = true; // sticky: the one retry this capture gets
@@ -1172,8 +1172,11 @@ or, if you are stopping to ask:
1172
1172
  \`\`\`
1173
1173
 
1174
1174
  "raised" is optional and only for work you did that was NOT this card. Do not
1175
- list the card itself there. Do not put commit shas in the summary — they are
1176
- read from git.
1175
+ list the card itself there. Each raised brief states the failure, the repair
1176
+ already completed on this branch and its verification or unverified limits.
1177
+ Raised work stays with this delivery; it is not a request for another execution.
1178
+ Use the separate prerequisite reply for explicitly requested unfinished work.
1179
+ Do not put commit shas in the summary — they are read from git.
1177
1180
 
1178
1181
  "progress" is REQUIRED on both shapes. It is the running account a person reads
1179
1182
  at the top of this run, and it REPLACES the previous one whole — so write it
@@ -1255,7 +1258,11 @@ and unresolved choices. Never invent physical defaults or replace approved
1255
1258
  texture bytes with new canvas art or regenerated images. Read the retained
1256
1259
  files and distinguish browser export receipts, structural checks and human
1257
1260
  claims. Check decoding/resources and engine import with available local tools;
1258
- label checks not run and why. If a required model, GLB, buffer, original generated
1261
+ label checks not run and why. On a same-agent workflow, first read the measured
1262
+ intermediate-output paths and producer/card/hash receipts in this prompt; these
1263
+ working inputs require no human Download, acceptance or Library retention. Use
1264
+ the designated page's own materialized GLB and preserve original texture PNGs.
1265
+ If a required model, GLB, buffer, original generated
1259
1266
  PNG or texture mapping is measured missing or unusable, STOP dependent
1260
1267
  implementation and ask for the person's decision. Unknown generator-source
1261
1268
  identity is not a claim that an embedded GLB image is the original PNG. Opening