flowviant 0.125.20 → 0.125.22

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 (45) hide show
  1. package/README.md +11 -1
  2. package/bin/cli.mjs +13 -0
  3. package/bin/lib/agentCards.mjs +24 -1
  4. package/bin/lib/agentPlan.mjs +3 -0
  5. package/bin/lib/agentQueueContext.mjs +20 -1
  6. package/bin/lib/artifactContracts.mjs +132 -2
  7. package/bin/lib/artifactIteration.mjs +2 -1
  8. package/bin/lib/artifactReporter.mjs +2 -2
  9. package/bin/lib/artifacts.mjs +8 -1
  10. package/bin/lib/claudeStream.mjs +7 -0
  11. package/bin/lib/codexFencedMount.mjs +2 -2
  12. package/bin/lib/codexSubagents.mjs +6 -2
  13. package/bin/lib/knowledgeFetch.mjs +8 -3
  14. package/bin/lib/knowledgeFiles.mjs +4 -4
  15. package/bin/lib/orchestrator.mjs +40 -14
  16. package/bin/lib/orchestratorHooks.mjs +15 -2
  17. package/bin/lib/orchestratorNative.mjs +30 -9
  18. package/bin/lib/pinnedCapture.mjs +19 -8
  19. package/bin/lib/postureTools.mjs +2 -2
  20. package/bin/lib/prompts.mjs +25 -4
  21. package/bin/lib/publicReferenceBrowser.mjs +180 -0
  22. package/bin/lib/publicReferenceNetwork.mjs +131 -0
  23. package/bin/lib/publicReferenceProtocol.mjs +55 -0
  24. package/bin/lib/publicReferenceRuntime.mjs +65 -0
  25. package/bin/lib/publicReferenceTool.mjs +89 -0
  26. package/bin/lib/referenceCaptureProtocol.mjs +12 -0
  27. package/bin/lib/referenceCaptures.mjs +140 -0
  28. package/bin/lib/runTurn.mjs +8 -0
  29. package/bin/lib/runtimeCodex.mjs +1 -1
  30. package/bin/lib/runtimeEvents.mjs +24 -2
  31. package/bin/lib/turnGaps.mjs +47 -0
  32. package/bin/lib/uiReview.mjs +305 -0
  33. package/bin/lib/uiReviewBrowser.mjs +212 -0
  34. package/bin/lib/uiReviewContract.mjs +51 -0
  35. package/bin/lib/uiReviewTurn.mjs +15 -0
  36. package/bin/lib/websiteAccess.mjs +68 -0
  37. package/bin/lib/websiteTrace.mjs +36 -0
  38. package/bin/lib/work.mjs +1 -0
  39. package/bin/lib/workAgentPrecheck.mjs +4 -1
  40. package/bin/lib/workAgentTools.mjs +3 -2
  41. package/bin/lib/workAgentTurnExecution.mjs +49 -5
  42. package/bin/lib/workAgentTurnOutcome.mjs +13 -3
  43. package/bin/lib/workAgentTurns.mjs +2 -0
  44. package/bin/lib/workToolLog.mjs +24 -3
  45. package/package.json +1 -1
@@ -58,7 +58,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
58
58
  // wakes never evicts a note input() accepted.
59
59
  const inbox = new Map(), wakes = new Map();
60
60
  const pending = new Map(), steps = new Map();
61
- let closed = false, polling = false, rootSession = null, offerAt = 0;
61
+ // Whether this run admitted a sealed (Codex V2) spawn or follow-up, whose
62
+ // child reads its contract at SubagentStart or its first tool instead.
63
+ let closed = false, polling = false, rootSession = null, offerAt = 0, sealedRun = false;
62
64
  const identity = readOnlyRun ? { ...readOnlyRun, instance: DAEMON_INSTANCE } : { scope: 'agent', turnId, instance: DAEMON_INSTANCE };
63
65
  const ended = () => closed || signal?.aborted;
64
66
  const resolve = id => aliases.get(id) ?? id;
@@ -123,6 +125,19 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
123
125
  return held.find(a => a.brief === brief) ?? held.find(a => title && a.title === title) ?? held.find(a => kind && a.kind === kind)
124
126
  ?? held[0] ?? open.find(a => a.brief === brief) ?? open.find(a => title && a.title === title);
125
127
  };
128
+ // A child's contract, naming its record by its id (the words ride its
129
+ // prompt) or, with no id, a sealed Codex V2 child's record by its task
130
+ // name: the plain `Task name: /root/<name>` header Codex puts on its task
131
+ // message, which resolves through the spawn's task_name alias (`named`).
132
+ // The start hook cannot say which spawn a child came from; its name can.
133
+ const childContract = (id = null) => {
134
+ const ref = id ?? 'your task name: the "Task name:" line of your task message, such as /root/backend',
135
+ arg = id ? `agentId=${id}` : 'agentId set to that task name';
136
+ return (readOnlyRun
137
+ ? `\n\nRead-only investigation agent record id: ${ref}. FIRST call card_orchestrator.register_agent with ${arg} 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 ${arg}, 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.'}`
138
+ : `\n\nCard orchestrator agent record id: ${ref}. FIRST call card_orchestrator.register_agent with ${arg} before doing work (tool discovery is allowed). Share this worktree and preserve others’ edits. Ask questions through card_orchestrator.agent_question with ${arg}; 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.`)
139
+ + (!readOnlyRun ? assignmentContext() : '') + (access === 'read' && !readOnlyRun ? READ_ONLY_CHILD : '');
140
+ };
126
141
  // Admitted: the offer is spent, and notes that paused this child are being
127
142
  // delivered, so its checkpoint lets it work until their route lands.
128
143
  const start = (a, now) => {
@@ -146,7 +161,8 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
146
161
  if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
147
162
  if (calls.size > 128) calls.delete(calls.keys().next().value);
148
163
  if (!records.has(id) && records.size >= SUBAGENT_CAP) return { deny: `This run already has ${SUBAGENT_CAP} agent records. Continue the work yourself.` };
149
- const a = emit({ id, brief, title: spawn.title ?? '', kind: spawn.kind ?? '', status: 'waiting', now: WAIT });
164
+ // A sealed message's words are unknown: no brief, never its ciphertext.
165
+ const a = emit({ id, ...(spawn.sealed ? {} : { brief }), title: spawn.title ?? '', kind: spawn.kind ?? '', status: 'waiting', now: WAIT });
150
166
  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.` }; }
151
167
  // Claude's SendMessage addresses a child by its Agent `name`, Codex V2 by its task name.
152
168
  for (const name of spawn.names) if (filled(name) && name.length <= 160) aliases.set(name, id);
@@ -155,11 +171,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
155
171
  // stream, and the parent is asked to say why in its own update.
156
172
  const choice = childChoice(event, spawn, card);
157
173
  if (choice) trace.prose('note', `Runs on ${choice}.`, id);
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.`;
174
+ if (spawn.sealed) sealedRun = true;
161
175
  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.` : ''}`,
162
- updatedInput: withChildContract(event, spawn, childContract + (!readOnlyRun ? assignmentContext() : '') + (access === 'read' && !readOnlyRun ? READ_ONLY_CHILD : ''), chosen.brain) };
176
+ updatedInput: withChildContract(event, spawn, spawn.sealed ? '' : childContract(id), chosen.brain) };
163
177
  }
164
178
  // Native readings never revive a settled record or start a waiting spawn
165
179
  // or follow-up (Codex records a refused call's start anyway); only an
@@ -205,10 +219,13 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
205
219
  steps.delete(native);
206
220
  if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
207
221
  };
208
- // Codex V2 names a child `backend` or `/root/backend` interchangeably.
222
+ // Codex V2 names a child `backend` or `/root/backend` interchangeably, and
223
+ // a sealed child registers by the canonical path its task message wears.
209
224
  const named = native => {
225
+ if (typeof native !== 'string') return resolve(native);
210
226
  if (aliases.has(native) || records.has(native)) return resolve(native);
211
- const hits = new Set([...aliases].filter(([k]) => k.endsWith(`/${native}`) || native.endsWith(`/${k}`)).map(([, v]) => v));
227
+ const near = k => k.endsWith(`/${native}`) || native.endsWith(`/${k}`);
228
+ const hits = new Set([...[...aliases].filter(([k]) => near(k)).map(([, v]) => v), ...[...records.keys()].filter(near)]);
212
229
  return hits.size === 1 ? [...hits][0] : native;
213
230
  };
214
231
  function postSpawn(event) {
@@ -219,7 +236,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
219
236
  if (failed) { release(id); emit({ id, status: 'stopped' }); }
220
237
  }
221
238
  function preResume(event) {
222
- const input = event.tool_input ?? {}, { claude, native } = followupTarget(event);
239
+ const input = event.tool_input ?? {}, { claude, native, sealed } = followupTarget(event);
223
240
  if (native === null) return claude ? null : { deny: 'Name the native agent to resume.' };
224
241
  const id = named(native);
225
242
  if (!records.has(id)) {
@@ -227,7 +244,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
227
244
  // SendMessage also reaches "main" and teammates: only a known child is a follow-up.
228
245
  if (claude) return null;
229
246
  if (records.size >= SUBAGENT_CAP) return { deny: 'Continue the work in this run’s orchestrator.' };
230
- emit({ id, kind: '', title: native, brief: typeof input.message === 'string' ? input.message : '', status: 'waiting', now: WAIT, resumable: true });
247
+ emit({ id, kind: '', title: native, ...(sealed ? {} : { brief: typeof input.message === 'string' ? input.message : '' }), status: 'waiting', now: WAIT, resumable: true });
231
248
  }
232
249
  if (spawnable(records.get(id))) return { deny: `Agent ${id} has not started. Retry its native spawn instead.` };
233
250
  // A refused follow-up retried on its offered slot reaches a finished child.
@@ -236,6 +253,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
236
253
  start(records.get(id), 'Continuing with the orchestrator’s note');
237
254
  records.get(id).queued = running;
238
255
  calls.set(event.tool_use_id, id);
256
+ // A sealed follow-up rides as written; its child registers by task name
257
+ // if this run has not bound it yet (the refusal at its first tool says how).
258
+ if (sealed) { sealedRun = true; return {}; }
239
259
  return typeof input.message === 'string' ? { updatedInput: { ...input,
240
260
  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.` } } : {};
241
261
  }
@@ -259,6 +279,10 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
259
279
  return { deny: 'Your orchestrator has a note for this checkpoint. Stop editing and wait for its follow-up; keep your work so far.' };
260
280
  }
261
281
  function hook(ev) {
282
+ // A sealed child's contract, as it starts (measured on codex-cli 0.163
283
+ // and 0.164: this additionalContext reached the child, and the event
284
+ // names the child's agent_id but not the spawn that started it).
285
+ if (ev.hook_event_name === 'SubagentStart') return sealedRun && childOf(ev) ? { context: childContract().trimStart() } : {};
262
286
  // A child's next tool round carries a follow-up queued to it; after its
263
287
  // stop, its own tool use is the CLI running it again with that follow-up.
264
288
  const kid = records.get(resolve(childOf(ev)));
@@ -275,12 +299,14 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
275
299
  if (String(ev.tool_name).includes(ORCHESTRATOR_SERVER) && String(ev.tool_name).endsWith('register_agent')) {
276
300
  // Binding the root would pause the orchestrator at its children's checkpoints.
277
301
  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.' };
278
- const id = resolve(ev.tool_input?.agentId);
279
- if (!records.has(id)) return { deny: 'Use the agent record id in your prompt.' };
302
+ const id = named(ev.tool_input?.agentId);
303
+ if (!records.has(id)) return { deny: sealedRun ? `That names no agent in this run.${childContract()}` : 'Use the agent record id in your prompt.' };
280
304
  bind(child, id);
281
305
  }
306
+ // A sealed child that missed (or never got) its start's words reads
307
+ // them here, whole: an earlier run's follow-up, or a CLI without the hook.
282
308
  if (child && !records.has(resolve(child)) && !isDiscoveryTool(ev.tool_name))
283
- return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
309
+ return { deny: sealedRun ? `Register with your orchestrator before working.${childContract()}` : 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
284
310
  if ((child && access === 'read' || readOnlyRun) && isChildWriteTool(ev.tool_name)) return { deny: readOnlyRun ? 'This attempt only reads: no file writes.' : READ_ONLY_REFUSAL };
285
311
  if (rootTools && captureToolName(ev.tool_name)) {
286
312
  if (child || !rootSession || ev.session_id !== rootSession) return { deny: 'Only the top-level capture coordinator may call capture tools. Return findings to it.' };
@@ -314,7 +340,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
314
340
  return held.length ? { ...result, context: [result.context, ...held].filter(Boolean).join('\n\n') } : result;
315
341
  }
316
342
  if (action === 'route_message') return route(args);
317
- const id = resolve(args.agentId);
343
+ const id = named(args.agentId);
318
344
  // A person’s answer starts a new CLI turn. The parent carries the earlier
319
345
  // question and native identity in its own conversation; relay that known
320
346
  // context instead of inventing a new child or requiring another question.
@@ -24,9 +24,22 @@ export function orchestratorHooks() {
24
24
  return { PreToolUse: [{ matcher: '.*', hooks }], PostToolUse: [{ matcher: spawns, hooks }], PostToolUseFailure: [{ matcher: spawns, hooks }], SubagentStop: [{ hooks }] };
25
25
  }
26
26
 
27
- export function codexOrchestratorArgs() {
27
+ /**
28
+ * CODEX'S SubagentStart HANDS A SEALED CHILD ITS CONTRACT (2026-10-24). A V2
29
+ * spawn's message is ciphertext the gate must not edit (orchestratorNative.mjs
30
+ * `isSealedMessage`), so the words reach the child as this hook's
31
+ * additionalContext. Measured on real codex-cli 0.163.0 and 0.164.0 with this
32
+ * exact `matcher=".*"` row: the hook ran once per child and the child obeyed
33
+ * its context. Both CLIs start normally with the key (and with an unknown
34
+ * event's, offline). An older CLI is unmeasured, so it goes without; its
35
+ * children meet the same words when the gate refuses their first tool.
36
+ */
37
+ export const CODEX_SUBAGENT_START_MIN = '0.163.0';
38
+ export function codexOrchestratorArgs(codexVersion) {
28
39
  // A Codex `-c` value is TOML, quoted by the one quoter (codexToml.mjs).
29
40
  const row = `{matcher=".*",hooks=[{type="command",command=${toml(command())},timeout=${ORCHESTRATOR_HOOK_TIMEOUT_S}}]}`;
41
+ const start = versionAtLeast(String(codexVersion ?? '').replace(/^codex-cli\s+/, ''), CODEX_SUBAGENT_START_MIN);
30
42
  return ['--dangerously-bypass-hook-trust', '-c', 'features.hooks=true',
31
- '-c', `hooks.PreToolUse=[${row}]`, '-c', `hooks.PostToolUse=[${row}]`, '-c', `hooks.SubagentStop=[${row}]`];
43
+ '-c', `hooks.PreToolUse=[${row}]`, '-c', `hooks.PostToolUse=[${row}]`, '-c', `hooks.SubagentStop=[${row}]`,
44
+ ...(start ? ['-c', `hooks.SubagentStart=[${row}]`] : [])];
32
45
  }
@@ -12,8 +12,27 @@
12
12
  */
13
13
  import { isBackgroundSpawnTool, isClaudeFollowupTool, isSpawnTool } from './nativeAgentTools.mjs';
14
14
 
15
+ /**
16
+ * A CODEX V2 MESSAGE IS CIPHERTEXT (2026-10-24). Since codex-cli 0.138
17
+ * (openai/codex PR #26210) the model writes spawn_agent's and followup_task's
18
+ * `message` encrypted, and the child's first request hands it back to be
19
+ * decrypted. The gate appended its contract to it, so every V2 child of the
20
+ * card runs from 10-19 to 10-24 (133 of them) died on that request with
21
+ * `invalid_encrypted_content` before its first tool. Measured on real 0.164:
22
+ * no hook and an identity rewrite both ran the child; one appended line failed
23
+ * it. Sealed, a message is relayed untouched and never read as a brief.
24
+ * V2 is the `collaboration` namespace (all 209 real messages, every one a
25
+ * `gAAAAA…` token); the token's shape is checked too, so a sealed message under
26
+ * another spelling is never edited. Codex V1 and Claude write plain text.
27
+ */
28
+ const SEALED_TOKEN = /^gAAAAA[A-Za-z0-9_-]{40,}={0,2}$/;
29
+ export const isSealedMessage = (toolName, message) => typeof message === 'string' &&
30
+ !isBackgroundSpawnTool(toolName) && !isClaudeFollowupTool(toolName) &&
31
+ (/^collaboration[./]?(?:spawn_agent|followup_task)$/.test(String(toolName ?? '')) || SEALED_TOKEN.test(message));
32
+
15
33
  /** A spawn's prompt and the fields a waiting record is matched and named by,
16
- * or null when the gate cannot hold it (no call id or no text prompt). */
34
+ * or null when the gate cannot hold it (no call id or no text prompt). A
35
+ * sealed message (`isSealedMessage`) gives no brief: its words are unknown. */
17
36
  export function readSpawn(event) {
18
37
  // A child stays in the card's worktree on this machine, inside the pool.
19
38
  const { isolation, cwd, ...input } = event.tool_input ?? {}, callId = event.tool_use_id;
@@ -21,18 +40,20 @@ export function readSpawn(event) {
21
40
  const items = Array.isArray(input.items) ? input.items : null;
22
41
  const key = ['prompt', 'message'].find(k => typeof input[k] === 'string') ?? (items ? 'items' : null);
23
42
  if (!callId || !key) return null;
24
- const brief = key === 'items' ? items.filter(i => i?.type === 'text' && typeof i.text === 'string').map(i => i.text).join('\n') : input[key];
25
- return { callId, input, items, key, brief, relocated: isolation !== undefined || cwd !== undefined,
43
+ const sealed = key === 'message' && isSealedMessage(event.tool_name, input.message);
44
+ const brief = sealed ? null : key === 'items' ? items.filter(i => i?.type === 'text' && typeof i.text === 'string').map(i => i.text).join('\n') : input[key];
45
+ return { callId, input, items, key, brief, sealed, relocated: isolation !== undefined || cwd !== undefined,
26
46
  resume: typeof input.resume === 'string' ? input.resume : null,
27
47
  title: input.description ?? input.task_name, kind: input.subagent_type ?? input.agent_type,
28
48
  names: [input.name, input.task_name] };
29
49
  }
30
50
 
31
51
  /** The spawn's input as the gate rewrites it: the contract appended to its
32
- * prompt, a Claude child sent to the background, and the child's model and
33
- * effort (`brain`, from `childBrain`) where the spawn named none. */
52
+ * prompt (never to a sealed message, whose child is handed it at its start),
53
+ * a Claude child sent to the background, and the child's model and effort
54
+ * (`brain`, from `childBrain`) where the spawn named none. */
34
55
  export const withChildContract = (event, spawn, contract, brain = {}) => ({ ...spawn.input, ...brain, ...(isBackgroundSpawnTool(event.tool_name) ? { run_in_background: true } : {}),
35
- ...(spawn.key === 'items' ? { items: [...spawn.items, { type: 'text', text: contract.trimStart() }] } : { [spawn.key]: spawn.input[spawn.key] + contract }) });
56
+ ...(spawn.sealed ? {} : spawn.key === 'items' ? { items: [...spawn.items, { type: 'text', text: contract.trimStart() }] } : { [spawn.key]: spawn.input[spawn.key] + contract }) });
36
57
 
37
58
  /** Both CLIs' effort ladder, lowest first (Codex stops at xhigh). */
38
59
  export const EFFORT_LADDER = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
@@ -178,11 +199,11 @@ export function spawnReceipt(event) {
178
199
  return { ids, failed: Boolean(output?.is_error || output?.isError || event.error) };
179
200
  }
180
201
 
181
- /** Which child a follow-up addresses (null when it names none in bounds), and
182
- * whether it is Claude's SendMessage. */
202
+ /** Which child a follow-up addresses (null when it names none in bounds),
203
+ * whether it is Claude's SendMessage, and whether its message is sealed. */
183
204
  export function followupTarget(event) {
184
205
  const input = event.tool_input ?? {}, claude = isClaudeFollowupTool(event.tool_name);
185
206
  // Measured Claude 2.1.302 PreToolUse SendMessage input: {to, message, recipient, content, …}.
186
207
  const native = claude ? input.to ?? input.recipient ?? input.target ?? input.id : input.target ?? input.id;
187
- return { claude, native: typeof native === 'string' && native.length <= 160 ? native : null };
208
+ return { claude, native: typeof native === 'string' && native.length <= 160 ? native : null, sealed: isSealedMessage(event.tool_name, input.message) };
188
209
  }
@@ -59,17 +59,28 @@ const ATTACH_WAIT_MS = 2_000;
59
59
  * protocol's error; `on(fn)` receives every event; `close(reason)` rejects
60
60
  * whatever is still waiting (the browser is gone).
61
61
  */
62
- export function createCdpClient(writable, readable) {
62
+ export function createCdpClient(writable, readable, { maxMessageBytes = Infinity } = {}) {
63
63
  let nextId = 0;
64
64
  let closed = null;
65
65
  const waiting = new Map();
66
66
  const listeners = [];
67
67
  let chunks = [];
68
+ let chunkBytes = 0;
69
+ const close = reason => {
70
+ if (closed) return;
71
+ closed = reason;
72
+ chunks = []; chunkBytes = 0;
73
+ for (const w of waiting.values()) w.reject(new Error(reason));
74
+ waiting.clear();
75
+ };
68
76
  readable.on('data', (d) => {
77
+ if (closed) return;
69
78
  let start = 0;
70
79
  for (let i = 0; i < d.length; i++) {
71
80
  if (d[i] !== 0) continue;
72
81
  chunks.push(d.subarray(start, i));
82
+ chunkBytes += i - start;
83
+ if (chunkBytes > maxMessageBytes) { close('DevTools message exceeded the reference bound.'); return; }
73
84
  start = i + 1;
74
85
  let msg;
75
86
  try {
@@ -78,6 +89,7 @@ export function createCdpClient(writable, readable) {
78
89
  msg = null;
79
90
  }
80
91
  chunks = [];
92
+ chunkBytes = 0;
81
93
  if (!msg) continue;
82
94
  if (msg.id != null && waiting.has(msg.id)) {
83
95
  const w = waiting.get(msg.id);
@@ -88,7 +100,11 @@ export function createCdpClient(writable, readable) {
88
100
  for (const fn of listeners) fn(msg);
89
101
  }
90
102
  }
91
- if (start < d.length) chunks.push(d.subarray(start));
103
+ if (start < d.length) {
104
+ chunkBytes += d.length - start;
105
+ if (chunkBytes > maxMessageBytes) { close('DevTools message exceeded the reference bound.'); return; }
106
+ chunks.push(d.subarray(start));
107
+ }
92
108
  });
93
109
  return {
94
110
  send(method, params = {}, sessionId) {
@@ -108,12 +124,7 @@ export function createCdpClient(writable, readable) {
108
124
  on(fn) {
109
125
  listeners.push(fn);
110
126
  },
111
- close(reason) {
112
- if (closed) return;
113
- closed = reason;
114
- for (const w of waiting.values()) w.reject(new Error(reason));
115
- waiting.clear();
116
- },
127
+ close,
117
128
  };
118
129
  }
119
130
 
@@ -15,10 +15,10 @@ const POSTURES = {
15
15
  research: {
16
16
  claude: {
17
17
  has: 'the repository’s files (never .env files), the project’s knowledge library, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
18
- lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or browsers), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
18
+ lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or direct browser launches), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
19
19
  // An orchestrated research card (2026-10-19): its children read under
20
20
  // CARD ORCHESTRATOR, so the note does not withhold delegation.
21
- orchestratedLacks: 'no measured native image-generation tool, guarded read commands only (no helper scripts or browsers), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
21
+ orchestratedLacks: 'no measured native image-generation tool, guarded read commands only (no helper scripts or direct browser launches), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
22
22
  },
23
23
  codex: {
24
24
  has: 'a sandboxed shell over an isolated copy of the repository (no .env, credential or git files, and no network from the shell), web search, and writing under .flowviant/artifacts/ and the granted scratch directory',
@@ -24,7 +24,7 @@ import { AGENT_PREREQUISITE_CONTRACT } from './agentPrerequisiteContract.mjs';
24
24
  import { agentTaskKindOf } from './agentTaskKinds.mjs';
25
25
  // A card's type (0.106.0) likewise: the table holds the words, this file prints them.
26
26
  import { AGENT_TASK_TYPES, agentTaskTypeFor, agentTaskTypeOf } from './agentTaskTypes.mjs';
27
- import { ARTIFACT_CONTRACT_TAIL, HELP_THEM_DECIDE, LOOK_HEADLESS, SYSTEM_AGENT_DECK, SYSTEM_AGENT_IMAGE, SYSTEM_AGENT_MODEL } from './artifactContracts.mjs';
27
+ import { ARTIFACT_CONTRACT_TAIL, HELP_THEM_DECIDE, LOOK_HEADLESS, SYSTEM_AGENT_DECK, SYSTEM_AGENT_IMAGE, SYSTEM_AGENT_MODEL, WHOLE_REQUEST } from './artifactContracts.mjs';
28
28
  import { isLibraryReference } from './knowledgeLibrary.mjs';
29
29
  import { readCardThread } from './cardThread.mjs';
30
30
  import { AGENT_FILE_LINE_MAX, AGENT_FILES_PER_MESSAGE_MAX, CARD_FILES_PER_TURN_MAX } from './agentFiles.mjs';
@@ -581,9 +581,19 @@ with the matching name your CLI exposes.
581
581
  ask: each brief names the original ask, the part this card delivers and
582
582
  what its sibling cards own, and carries the context its agent needs — the
583
583
  design link or kept reference, the expected look and behaviour of its
584
- area, the files you found. If you could not open or read a reference, say
585
- so in each brief and in \`assumed\`, and cut by what the person described
586
- and the current product shows; never invent what it contains. For example,
584
+ area, the files you found. Copy the person's links, design references and
585
+ file lists into every brief they shape, word for word. If you could not
586
+ open or read a reference, say so in each brief and in \`assumed\`, and cut
587
+ by what the person described and the current product shows; never invent
588
+ what it contains. AN UNREAD REFERENCE NEVER NARROWS SCOPE: write "UNREAD
589
+ AT PLANNING: <the link or name>. The agent working this card opens it
590
+ itself (a Claude Code agent reads a claude.ai/design link with its
591
+ DesignSync tool), builds this card's share of all of it, and names any part
592
+ no card owns." Never write that the agent should use only what you could
593
+ read as its scope, and never state that the reference is unavailable to the
594
+ agent: your read access is not its. When the person names a design or
595
+ mockup without a link, look for its link in the repository's docs, decision
596
+ records and the Library, and carry what you found. For example,
587
597
  "remake the settings page from this design", where the design shows a new
588
598
  sidebar, a redesigned profile form, a billing tab and inline save
589
599
  confirmations, is four cards, each carrying the design link and the
@@ -1268,6 +1278,8 @@ implementation and ask for the person's decision. Unknown generator-source
1268
1278
  identity is not a claim that an embedded GLB image is the original PNG. Opening
1269
1279
  a result handoff never authorizes an automatic asset commit or merge.
1270
1280
 
1281
+ ${WHOLE_REQUEST}
1282
+
1271
1283
  ${HELP_THEM_DECIDE}`;
1272
1284
 
1273
1285
  /**
@@ -1814,6 +1826,15 @@ HOW TO READ IT:
1814
1826
  asked for that the diff does not show, changes that reach further than the
1815
1827
  card did. Not formatting, not naming preferences, not the rewrite you would
1816
1828
  have preferred.
1829
+ 6. THE WHOLE REQUEST, AND WHAT THE AGENT SAYS IS MISSING. A card may carry
1830
+ THE PERSON'S OWN WORDS (the chat it was planned in) and the agent's
1831
+ declared gaps. Judge the diff against the words as well as the criteria:
1832
+ a screen, state, rule or linked design file the person asked for that the
1833
+ diff does not show and the gaps do not list is a concern, named in the
1834
+ card's note. A declared gap is the agent being honest, not a concern by
1835
+ itself; put the serious ones in "overall" so the reviewer meets them first.
1836
+ A linked design you cannot open is named in the note as unchecked, never
1837
+ judged from its name.
1817
1838
 
1818
1839
  END YOUR TURN WITH ONE JSON OBJECT AND NOTHING AFTER IT, in a \`\`\`json fence:
1819
1840
 
@@ -0,0 +1,180 @@
1
+ /** Chromium has an empty private root and no network. Public response bytes
2
+ * enter only through the bounded DNS-pinned broker, over a private CDP pipe.
3
+ * Unsupported namespace or Chrome sandbox launch never retries unsandboxed. */
4
+ import { spawn } from 'node:child_process';
5
+ import { existsSync, openSync, readSync, closeSync, realpathSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { resolveBrowser, SANDBOX_FAILURE_RE } from './shot.mjs';
9
+ import { createCdpClient } from './pinnedCapture.mjs';
10
+ import { killGroup } from './processGroup.mjs';
11
+ import { createReferenceNetwork, publicReferenceUrl } from './publicReferenceNetwork.mjs';
12
+ import { REFERENCE_BOUNDS as B } from './publicReferenceProtocol.mjs';
13
+
14
+ export function referenceBrowserLaunch(browser = resolveBrowser()) {
15
+ if (process.platform !== 'linux') return { unavailable: 'unsupported_isolation' };
16
+ if (process.getuid?.() === 0) return { unavailable: 'root_browser_unsupported' };
17
+ if (!browser || browser.viaWindows) return { unavailable: 'no_native_browser' };
18
+ const bwrap = ['/usr/bin/bwrap', '/bin/bwrap'].find(existsSync);
19
+ if (!bwrap) return { unavailable: 'no_namespace_sandbox' };
20
+ let binary;
21
+ try {
22
+ const real = realpathSync(browser.bin);
23
+ binary = [real, join(dirname(real), 'chrome'), join(dirname(real), 'chromium')].find(file => {
24
+ let fd;
25
+ try { fd = openSync(file, 'r'); const magic = Buffer.alloc(4); readSync(fd, magic, 0, 4, 0); return magic.equals(Buffer.from([127, 69, 76, 70])); }
26
+ catch { return false; } finally { if (fd !== undefined) closeSync(fd); }
27
+ });
28
+ } catch { /* An unsupported wrapper cannot widen the filesystem mounts. */ }
29
+ if (!binary) return { unavailable: 'unsupported_browser_layout' };
30
+ const root = dirname(binary);
31
+ // A broad system-bin bind would expose shell/network helpers. Only browser
32
+ // distribution files, runtime libraries and fonts are in the private root.
33
+ if (!['/opt/google/chrome', '/opt/google/chrome-beta', '/opt/google/chrome-unstable',
34
+ '/usr/lib/chromium', '/usr/lib/chromium-browser', '/opt/microsoft/msedge', '/opt/brave.com/brave'].includes(root))
35
+ return { unavailable: 'unsupported_browser_layout' };
36
+ const args = ['--unshare-all', '--die-with-parent', '--new-session', '--cap-drop', 'ALL',
37
+ '--proc', '/proc', '--dev', '/dev', '--size', String(B.scratchBytes), '--tmpfs', '/tmp',
38
+ '--size', String(B.profileBytes), '--tmpfs', '/profile', '--size', '1048576', '--tmpfs', '/home', '--size', '1048576', '--tmpfs', '/etc',
39
+ '--ro-bind', root, '/browser'];
40
+ for (const dir of ['/usr/lib', '/lib', '/lib64', '/usr/share/fonts', '/usr/share/fontconfig', '/etc/fonts'])
41
+ if (existsSync(dir)) args.push('--ro-bind', dir, dir);
42
+ args.push('--clearenv', '--setenv', 'HOME', '/profile', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'LANG', 'C.UTF-8',
43
+ '--', `/browser/${binary.slice(root.length + 1)}`,
44
+ '--headless=new', '--remote-debugging-pipe', '--user-data-dir=/profile', '--no-first-run', '--no-default-browser-check',
45
+ '--disable-gpu', '--disable-dev-shm-usage', '--disable-extensions', '--disable-background-networking',
46
+ '--disable-component-update', '--disable-sync', '--disable-breakpad', '--disable-crash-reporter',
47
+ '--disable-domain-reliability', '--disable-default-apps', '--no-pings', '--no-proxy-server',
48
+ '--disable-quic', '--disable-features=MediaRouter,OptimizationHints,AutofillServerCommunication',
49
+ '--renderer-process-limit=2', '--js-flags=--max-old-space-size=128', '--force-color-profile=srgb',
50
+ `--window-size=${B.width},${B.height}`, 'about:blank');
51
+ return { command: bwrap, args, env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' } };
52
+ }
53
+
54
+ export async function capturePublicReference({ url, signal, attemptId = randomUUID() }, {
55
+ launch = referenceBrowserLaunch, spawnBrowser = spawn, networkFactory = createReferenceNetwork,
56
+ } = {}) {
57
+ const attemptedAt = new Date().toISOString();
58
+ const capture = { version: 1, attemptId, requestedUrl: url, attemptedAt, capturedAt: null, viewport: { width: B.width, height: B.height },
59
+ observation: { access: 'unknown', observedAt: attemptedAt }, contentAssessment: 'unverified' };
60
+ try { publicReferenceUrl(url); } catch { return { ...capture, reason: 'blocked_destination', observation: { ...capture.observation, access: 'permission_denied', permissionSource: 'flowviant' } }; }
61
+ if (signal?.aborted) return { ...capture, reason: 'aborted' };
62
+ const spec = launch();
63
+ if (spec.unavailable) return { ...capture, reason: spec.unavailable, observation: { ...capture.observation, access: 'unavailable' } };
64
+ const controller = new AbortController();
65
+ const network = networkFactory({ signal: controller.signal });
66
+ let child, cdp, timer, abort, interrupt, started = false, stderr = '';
67
+ let documents = 0, requests = 0, blocked = 0, mainFrame, target, session, loaded = false, pending = 0, networkFailure;
68
+ const responses = new Map();
69
+ const finish = (access, reason) => ({ ...capture, blockedRequests: blocked, ...(reason ? { reason } : {}),
70
+ observation: { ...capture.observation, observedAt: new Date().toISOString(), access,
71
+ ...(['blocked_destination', 'blocked_method'].includes(reason) ? { access: 'permission_denied', permissionSource: 'flowviant' } : {}) } });
72
+ const interrupted = new Promise(resolve => {
73
+ interrupt = (access, reason) => { controller.abort(); if (child) killGroup(child); resolve(finish(access, reason)); };
74
+ abort = () => interrupt('unknown', 'aborted');
75
+ if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
76
+ timer = setTimeout(() => interrupt(!started ? 'unavailable' : loaded ? 'partial' : 'failed', 'timeout'), B.timeoutMs);
77
+ });
78
+ const run = async () => {
79
+ child = spawnBrowser(spec.command, spec.args, { env: spec.env, detached: true, stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] });
80
+ child.stderr.on('data', data => { stderr = (stderr + data.toString()).slice(-4000); });
81
+ cdp = createCdpClient(child.stdio[3], child.stdio[4], { maxMessageBytes: 8 * 1024 * 1024 });
82
+ for (const fd of [3, 4]) child.stdio[fd].on('error', () => {});
83
+ child.once('error', () => cdp.close('Browser unavailable.'));
84
+ child.once('exit', () => cdp.close('Browser exited.'));
85
+ await cdp.send('Browser.getVersion');
86
+ started = true;
87
+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' });
88
+ // Only our page runs. Popups, out-of-process frames and workers are closed
89
+ // while paused; in-process requests still pass the broker. A bypass has
90
+ // no network route, no user home and no host credential files to reach.
91
+ cdp.on(message => {
92
+ const p = message.params ?? {};
93
+ if (message.method === 'Target.attachedToTarget' && p.targetInfo?.targetId !== target) {
94
+ if (p.waitingForDebugger && typeof p.targetInfo?.targetId === 'string') { blocked++; cdp.send('Target.closeTarget', { targetId: p.targetInfo.targetId }).catch(() => {}); }
95
+ }
96
+ if (message.sessionId !== session) return;
97
+ if (message.method === 'Page.lifecycleEvent' && p.frameId === mainFrame && ['DOMContentLoaded', 'load'].includes(p.name)) loaded = true;
98
+ if (message.method !== 'Fetch.requestPaused') return;
99
+ pending++;
100
+ void (async () => {
101
+ try {
102
+ if (controller.signal.aborted) throw new Error('aborted');
103
+ if (++requests > B.requests) { interrupt(loaded ? 'partial' : 'failed', 'request_limit'); return; }
104
+ if (p.resourceType === 'Document' && ++documents > B.documents) { interrupt(loaded ? 'partial' : 'failed', 'navigation_limit'); return; }
105
+ const response = await network.request(p.request.url, p.request.method);
106
+ if (p.resourceType === 'Document' && p.frameId === mainFrame) responses.set(publicReferenceUrl(p.request.url).href, { status: response.status,
107
+ challenge: response.headers.some(h => h.name.toLowerCase() === 'cf-mitigated' && h.value.toLowerCase() === 'challenge') });
108
+ await cdp.send('Fetch.fulfillRequest', { requestId: p.requestId, responseCode: response.status, responseHeaders: response.headers, body: response.body.toString('base64') }, session);
109
+ } catch (error) {
110
+ if (error.reason === 'byte_limit' || error.reason === 'request_limit') { interrupt(loaded ? 'partial' : 'failed', error.reason); return; }
111
+ if (p.resourceType === 'Document' && p.frameId === mainFrame) networkFailure = error.reason ?? 'navigation_failed';
112
+ blocked++;
113
+ await cdp.send('Fetch.failRequest', { requestId: p.requestId, errorReason: 'BlockedByClient' }, session).catch(() => {});
114
+ } finally { pending--; }
115
+ })();
116
+ });
117
+ const context = await cdp.send('Target.createBrowserContext', { disposeOnDetach: true });
118
+ const created = await cdp.send('Target.createTarget', { url: 'about:blank', browserContextId: context.browserContextId });
119
+ target = created.targetId;
120
+ session = (await cdp.send('Target.attachToTarget', { targetId: target, flatten: true })).sessionId;
121
+ await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, session);
122
+ await cdp.send('Page.enable', {}, session);
123
+ await cdp.send('Page.setLifecycleEventsEnabled', { enabled: true }, session);
124
+ await cdp.send('Network.enable', {}, session);
125
+ await cdp.send('Network.setBypassServiceWorker', { bypass: true }, session);
126
+ await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }, session);
127
+ await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, session);
128
+ await cdp.send('Emulation.setDeviceMetricsOverride', { width: B.width, height: B.height, deviceScaleFactor: 1, mobile: false }, session);
129
+ mainFrame = (await cdp.send('Page.getFrameTree', {}, session)).frameTree.frame.id;
130
+ await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
131
+ const nav = await cdp.send('Page.navigate', { url }, session);
132
+ if (nav.errorText) return finish('failed', networkFailure ?? 'navigation_failed');
133
+ const until = Date.now() + 12_000;
134
+ while ((!loaded || pending) && Date.now() < until && !controller.signal.aborted) await new Promise(resolve => setTimeout(resolve, 50));
135
+ if (controller.signal.aborted) return finish('unknown', 'aborted');
136
+ const frame = (await cdp.send('Page.getFrameTree', {}, session)).frameTree.frame;
137
+ let finalUrl;
138
+ try { publicReferenceUrl(frame.url); finalUrl = frame.url; } catch { return finish('failed', 'unverified_document'); }
139
+ const response = responses.get(publicReferenceUrl(finalUrl).href);
140
+ if (!response || frame.unreachableUrl || !loaded) return finish('failed', networkFailure ?? 'unverified_document');
141
+ capture.finalUrl = finalUrl;
142
+ capture.observation.finalUrl = finalUrl;
143
+ capture.observation.httpStatus = response.status;
144
+ // Content remains untrusted source data. Restrict what evaluation returns;
145
+ // there is no tool for arbitrary JS, clicks, forms or document mutation.
146
+ const content = await cdp.send('Runtime.evaluate', { returnByValue: true, timeout: 2000, expression: `JSON.stringify({text:(document.body?.innerText||'').slice(0,${B.textChars}),links:Array.from(document.querySelectorAll('a[href]')).slice(0,${B.links}).map(a=>({url:a.href.slice(0,4096),text:(a.innerText||'').slice(0,160)}))})` }, session);
147
+ const parsed = JSON.parse(content.result?.value ?? '{}');
148
+ capture.text = typeof parsed.text === 'string' ? parsed.text.slice(0, B.textChars) : '';
149
+ capture.capturedAt = new Date().toISOString();
150
+ capture.links = Array.isArray(parsed.links) ? parsed.links.slice(0, B.links).flatMap(link => {
151
+ try { publicReferenceUrl(link.url); return typeof link.text === 'string' ? [{ url: link.url, text: link.text.slice(0, 160) }] : []; } catch { return []; }
152
+ }) : [];
153
+ const shot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, session);
154
+ const png = Buffer.from(shot.data ?? '', 'base64');
155
+ if (!png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return finish('partial', 'screenshot_unavailable');
156
+ if (png.length > B.screenshotBytes) return finish('partial', 'screenshot_limit');
157
+ capture.screenshot = { mimeType: 'image/png', data: png.toString('base64') };
158
+ capture.capturedAt = new Date().toISOString();
159
+ const after = (await cdp.send('Page.getFrameTree', {}, session)).frameTree.frame;
160
+ if (after.loaderId !== frame.loaderId || after.url !== frame.url || pending) {
161
+ delete capture.screenshot; delete capture.text; delete capture.links; capture.capturedAt = null;
162
+ return finish('partial', 'document_changed');
163
+ }
164
+ return finish(response.challenge ? 'challenge' : response.status >= 400 ? 'failed' : blocked ? 'partial' : 'loaded');
165
+ };
166
+ try {
167
+ return await Promise.race([run().catch(() => finish(started ? 'failed' : 'unavailable', SANDBOX_FAILURE_RE.test(stderr) ? 'browser_sandbox_unavailable' : started ? 'browser_failed' : 'isolation_launch_failed')), interrupted]);
168
+ } finally {
169
+ clearTimeout(timer); signal?.removeEventListener('abort', abort);
170
+ controller.abort(); network.close(); cdp?.close('Reference capture ended.');
171
+ if (child) {
172
+ const exited = new Promise(resolve => { if (child.exitCode !== null || child.signalCode !== null) resolve(); else { child.once('exit', resolve); child.once('error', resolve); } });
173
+ killGroup(child);
174
+ let reapTimer;
175
+ await Promise.race([exited, new Promise(resolve => { reapTimer = setTimeout(resolve, 2000); })]);
176
+ clearTimeout(reapTimer);
177
+ for (const stream of child.stdio ?? []) stream?.destroy?.();
178
+ }
179
+ }
180
+ }