flowviant 0.125.21 → 0.125.23

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.
@@ -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
  }
@@ -38,6 +38,21 @@
38
38
  * The launch obeys shot.mjs's policy: sandboxed unless `sandboxOff`, one retry
39
39
  * without it after a launch that measured a sandbox failure, and the machine
40
40
  * credential stripped from Chrome's environment.
41
+ *
42
+ * A CALLER MAY DRIVE THE HELD PAGE (`work`, 2026-10-26, the video kind's
43
+ * render tool): instead of one screenshot, `work(page)` runs once the page
44
+ * has loaded and is still the page, with `page.screenshot()` and
45
+ * `page.evaluate(expression)`; `initScript` runs before the page's own
46
+ * scripts. Every pin above still holds; the page is checked again after
47
+ * `work` and a page that left is still 'left-page'.
48
+ *
49
+ * A DRIVEN PAGE RUNS ON REAL TIME. Stepping Chrome's virtual time was tried
50
+ * for the video render and measured broken (Chrome 149): once a page's last
51
+ * animation ended the compositor went idle and `Page.captureScreenshot`
52
+ * waited for a frame that never came, whatever the clock or screenshot
53
+ * options — a real video turn's page hung at frame 41 — and CSS and
54
+ * requestAnimationFrame time ran ahead of `performance.now()`. A page that
55
+ * needs a clock brings its own in `initScript` (videoRender.mjs `VIDEO_CLOCK`).
41
56
  */
42
57
 
43
58
  import { spawn } from 'node:child_process';
@@ -147,9 +162,18 @@ export function sameDocument(a, b) {
147
162
  * `launchFailed` means the browser never answered on the pipe — the only
148
163
  * failure a different launch (classic headless, or no sandbox) can change.
149
164
  */
150
- async function captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox, modelExport, signal }, { spawnImpl, env }) {
165
+ async function captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox, modelExport, work, initScript, signal }, { spawnImpl, env }) {
166
+ // The thumbnail's bounded virtual-time policy; never a model export's or a
167
+ // driven page's (see the header).
168
+ const clocked = !modelExport && !work;
151
169
  const userDataDir = mkdtempSync(join(tmpdir(), 'flowviant-pin-'));
152
- const args = [...chromeFlags({ headlessFlag, noSandbox, userDataDir, width, height }), '--remote-debugging-pipe', 'about:blank'];
170
+ // A driven page's WINDOW is at least 400×400, its viewport still exactly
171
+ // width × height (the metrics override below): MEASURED on Chrome 149
172
+ // (2026-10-26), a window 50 px tall or less left screenshots of a driven
173
+ // page waiting on a frame that never came, from about the tenth frame on,
174
+ // while 120 px and taller never did, at any width.
175
+ const windowSize = work ? { width: Math.max(width, 400), height: Math.max(height, 400) } : { width, height };
176
+ const args = [...chromeFlags({ headlessFlag, noSandbox, userDataDir, ...windowSize }), '--remote-debugging-pipe', 'about:blank'];
153
177
  let child;
154
178
  try {
155
179
  child = spawnImpl(browser.bin, args, {
@@ -238,6 +262,9 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
238
262
  const ourSession = new Promise((r) => { ready = r; });
239
263
  let budgetDone;
240
264
  const budget = new Promise((r) => { budgetDone = r; });
265
+ // A driven page is driven once it has loaded.
266
+ let loadedDone;
267
+ const loaded = new Promise((r) => { loadedDone = r; });
241
268
 
242
269
  let navigating = false;
243
270
  // Called only for a target that is paused before it runs. Once the page
@@ -292,6 +319,7 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
292
319
  return;
293
320
  }
294
321
  if (msg.method === 'Emulation.virtualTimeBudgetExpired') budgetDone();
322
+ if (msg.method === 'Page.loadEventFired') loadedDone();
295
323
  });
296
324
 
297
325
  await cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' }).catch(() => {});
@@ -313,11 +341,12 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
313
341
  ours = { targetId: ourTargetId, sessionId };
314
342
 
315
343
  await cdp.send('Page.enable', {}, sessionId);
316
- if (modelExport) await cdp.send('Runtime.enable', {}, sessionId);
344
+ if (modelExport || work) await cdp.send('Runtime.enable', {}, sessionId);
317
345
  if (modelExport) await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: MODEL_EXPORT_BRIDGE }, sessionId);
346
+ if (work && initScript) await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript }, sessionId);
318
347
  await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, sessionId);
319
348
  await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId);
320
- if (!modelExport) await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pause' }, sessionId);
349
+ if (clocked) await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pause' }, sessionId);
321
350
  await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => {});
322
351
  navigating = true;
323
352
  const nav = await cdp.send('Page.navigate', { url }, sessionId);
@@ -326,7 +355,7 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
326
355
  }
327
356
  // Model readiness/export waits on actual loaded textures and the page's
328
357
  // handler. Thumbnails keep their bounded virtual-time screenshot policy.
329
- if (!modelExport) await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pauseIfNetworkFetchesPending', budget: budgetMs }, sessionId);
358
+ if (clocked) await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pauseIfNetworkFetchesPending', budget: budgetMs }, sessionId);
330
359
  let exported = null;
331
360
  if (modelExport) {
332
361
  const answer = await cdp.send('Runtime.evaluate', {
@@ -338,7 +367,8 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
338
367
  // A Chrome that never reports the budget is captured on the wall clock,
339
368
  // short of the deadline, rather than never.
340
369
  let wall;
341
- if (!modelExport) await Promise.race([budget, new Promise((r) => { wall = setTimeout(r, Math.max(budgetMs, timeoutMs - EXIT_GRACE_MS)); })]);
370
+ if (clocked) await Promise.race([budget, new Promise((r) => { wall = setTimeout(r, Math.max(budgetMs, timeoutMs - EXIT_GRACE_MS)); })]);
371
+ else if (work) await Promise.race([loaded, new Promise((r) => { wall = setTimeout(r, Math.max(budgetMs, timeoutMs - EXIT_GRACE_MS)); })]);
342
372
  clearTimeout(wall);
343
373
 
344
374
  const history = await cdp.send('Page.getNavigationHistory', {}, sessionId);
@@ -353,6 +383,25 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
353
383
 
354
384
  if (modelExport) return { ok: true, base64: exported.base64 };
355
385
 
386
+ if (work) {
387
+ const page = {
388
+ screenshot: async () => {
389
+ const shot = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId);
390
+ return Buffer.from(shot.data ?? '', 'base64');
391
+ },
392
+ evaluate: async (expression) => {
393
+ const answer = await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId);
394
+ if (answer.exceptionDetails) throw new Error(String(answer.exceptionDetails.exception?.description ?? answer.exceptionDetails.text ?? 'evaluation failed').slice(0, 500));
395
+ return answer.result?.value;
396
+ },
397
+ left: () => left,
398
+ pageError: () => pageError,
399
+ };
400
+ const value = await work(page);
401
+ if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
402
+ return { ok: true, value };
403
+ }
404
+
356
405
  const shot = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId);
357
406
  // One last look: a navigation that landed during the capture.
358
407
  if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
@@ -390,6 +439,8 @@ export async function capturePinnedPage({
390
439
  budgetMs = 5_000,
391
440
  allowSubresource = () => false,
392
441
  modelExport = false,
442
+ work = null,
443
+ initScript = null,
393
444
  signal,
394
445
  browser = resolveBrowser(),
395
446
  spawn: spawnImpl = spawn,
@@ -406,7 +457,7 @@ export async function capturePinnedPage({
406
457
  let last = null;
407
458
  for (const headlessFlag of ['--headless=new', '--headless']) {
408
459
  const once = () =>
409
- captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox, modelExport, signal }, { spawnImpl, env });
460
+ captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox, modelExport, work, initScript, signal }, { spawnImpl, env });
410
461
  let r = await once();
411
462
  if (!r.ok && r.sandboxFailed) {
412
463
  noSandbox = true; // sticky: the one retry this capture gets
@@ -219,7 +219,10 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
219
219
  mcpServers[name] = { type: config.type ?? 'http', url, ...(Object.keys(headers).length ? { headers } : {}) };
220
220
  }
221
221
  }
222
- Object.assign(mcpServers, turnServers); // daemon-owned names cannot be replaced by project config
222
+ // Daemon-owned names cannot be replaced by project config. A turn server's
223
+ // `toolTimeoutSec` is the Codex mount's alone (codexFencedMount.mjs); it is
224
+ // no key of Claude's mcp.json.
225
+ for (const [name, { toolTimeoutSec: _codexOnly, ...config }] of Object.entries(turnServers)) mcpServers[name] = config;
223
226
  const mcpPath = join(dir, 'mcp.json');
224
227
  writeFileSync(mcpPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
225
228
  const pointers = [];
@@ -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, SYSTEM_AGENT_VIDEO, 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
@@ -642,6 +652,9 @@ with the matching name your CLI exposes.
642
652
  "An illustration", "a hero image", "a photo of", "artwork for". Only on a
643
653
  machine whose Codex makes images: if stage_card refuses the kind, say so
644
654
  rather than staging another kind in its place.
655
+ - "video": an animation — GIF, MP4 or WebM files rendered from a page the
656
+ agent writes; no code changed. "A GIF", "an animation", "a video of",
657
+ "a looping clip", "animate the logo".
645
658
  - "deck": a presentation — one HTML slide deck; no code changed. "Slides",
646
659
  "a deck", "a presentation", "a pitch".
647
660
  - "research": findings or a requested Markdown document — read the repo and the web; no code changed.
@@ -881,6 +894,7 @@ export const INTAKE_TASK_TYPE_MEANINGS = Object.freeze({
881
894
  model3d: 'Game-ready or product assets',
882
895
  vector: 'SVG icons, logos, marks',
883
896
  image: 'Illustrations, photos, artwork',
897
+ video: 'Animated GIFs and short clips',
884
898
  research: 'Find out and cite',
885
899
  writing: 'Docs, copy, specs',
886
900
  });
@@ -993,15 +1007,16 @@ THE RULES THAT MATTER:
993
1007
 
994
1008
  8. EVERY CARD HAS A KIND. A "code" card lands as commits and is merged; every
995
1009
  other kind — "design" (a mockup), "model" (a 3D model), "image" (pictures),
996
- "deck" (a presentation) and "research" (findings or a requested document) —
1010
+ "video" (GIF, MP4 or WebM animations), "deck" (a presentation) and
1011
+ "research" (findings or a requested document) —
997
1012
  changes nothing in the repository. Research retains findings, sources and
998
1013
  uncertainties directly on its source card; only requested documents need
999
1014
  a Markdown file. The other non-code kinds hand back files. Keep non-code cards in agents of their
1000
1015
  own unless they are prerequisites for code, including an investigation
1001
1016
  whose findings the next card fixes. Independent artifacts mixed into a code
1002
1017
  agent turn a review that is "look at this and
1003
- accept it" into a merge. Image cards run on Codex. Mockup, 3D model and deck
1004
- cards run on Claude OR Codex, so dependent image and model work belongs on
1018
+ accept it" into a merge. Image cards run on Codex. Mockup, 3D model, video
1019
+ and deck cards run on Claude OR Codex, so dependent image and model work belongs on
1005
1020
  ONE Codex agent. Research runs on Claude, or on Codex where that machine
1006
1021
  reports support; keep each group on a CLI that can run all its cards.
1007
1022
  Different end products alone are not a reason to split dependent work.
@@ -1268,6 +1283,8 @@ implementation and ask for the person's decision. Unknown generator-source
1268
1283
  identity is not a claim that an embedded GLB image is the original PNG. Opening
1269
1284
  a result handoff never authorizes an automatic asset commit or merge.
1270
1285
 
1286
+ ${WHOLE_REQUEST}
1287
+
1271
1288
  ${HELP_THEM_DECIDE}`;
1272
1289
 
1273
1290
  /**
@@ -1437,6 +1454,7 @@ export const AGENT_CONTRACTS = Object.freeze({
1437
1454
  design: SYSTEM_AGENT_DESIGN,
1438
1455
  model: SYSTEM_AGENT_MODEL,
1439
1456
  image: SYSTEM_AGENT_IMAGE,
1457
+ video: SYSTEM_AGENT_VIDEO,
1440
1458
  deck: SYSTEM_AGENT_DECK,
1441
1459
  research: SYSTEM_AGENT_RESEARCH,
1442
1460
  });
@@ -1444,6 +1462,7 @@ export const AGENT_HANDS_BACK = Object.freeze({
1444
1462
  design: 'a DESIGN card: it hands back a mockup',
1445
1463
  model: 'a 3D MODEL card: it hands back a model to view and download as a .glb',
1446
1464
  image: 'an IMAGE card: it hands back pictures as PNG or WebP files',
1465
+ video: 'a VIDEO card: it hands back animation files as GIF, MP4 or WebM',
1447
1466
  deck: 'a PRESENTATION card: it hands back an HTML slide deck',
1448
1467
  research: 'a RESEARCH card: it hands back findings or a requested document',
1449
1468
  });
@@ -1814,6 +1833,15 @@ HOW TO READ IT:
1814
1833
  asked for that the diff does not show, changes that reach further than the
1815
1834
  card did. Not formatting, not naming preferences, not the rewrite you would
1816
1835
  have preferred.
1836
+ 6. THE WHOLE REQUEST, AND WHAT THE AGENT SAYS IS MISSING. A card may carry
1837
+ THE PERSON'S OWN WORDS (the chat it was planned in) and the agent's
1838
+ declared gaps. Judge the diff against the words as well as the criteria:
1839
+ a screen, state, rule or linked design file the person asked for that the
1840
+ diff does not show and the gaps do not list is a concern, named in the
1841
+ card's note. A declared gap is the agent being honest, not a concern by
1842
+ itself; put the serious ones in "overall" so the reviewer meets them first.
1843
+ A linked design you cannot open is named in the note as unchecked, never
1844
+ judged from its name.
1817
1845
 
1818
1846
  END YOUR TURN WITH ONE JSON OBJECT AND NOTHING AFTER IT, in a \`\`\`json fence:
1819
1847
 
@@ -347,7 +347,7 @@ export const CODEX_RUNTIME = {
347
347
  if (a.includes('--ignore-user-config')) a.push(...(personal?.args ?? []));
348
348
 
349
349
  a.push(...mcp, ...resultSchemaArgs);
350
- if (gated) a.push(...codexOrchestratorArgs());
350
+ if (gated) a.push(...codexOrchestratorArgs(codexVersion));
351
351
  a.push(streamInput ? '-' : codexPrompt({ system, prompt, profile }));
352
352
  return a;
353
353
  },
@@ -0,0 +1,47 @@
1
+ /**
2
+ * WHAT THE AGENT SAYS IT DID NOT DELIVER (2026-10-25).
3
+ *
4
+ * An audit of eighteen merged runs found the same shape in most of them: the
5
+ * delivery report said what was done and how it was checked, and nothing in
6
+ * the contract asked what was NOT done against the request — so a dropped
7
+ * design state came back as a "decision" ("Before Review they keep the old
8
+ * per-card plates"), a faked measurement as "measured 2500ms", and the pre-review,
9
+ * grading only the card's own checklist, passed both. The contract's
10
+ * `WHOLE_REQUEST` now asks for `gaps`; this reads them and says them where the
11
+ * person reviews (Verification on a code card, the summary on any other).
12
+ *
13
+ * THREE STATES, the house rule: no key is unknown and renders NOTHING (an
14
+ * older prompt, a resumed conversation that never saw it); `[]` is the
15
+ * agent's claim that it walked the request and nothing is missing; entries are
16
+ * listed. A malformed entry costs that entry alone.
17
+ */
18
+ export const GAP_STATUSES = ['not built', 'partly', 'unverified', 'unowned'];
19
+ export const MAX_GAPS = 40;
20
+ export const MAX_GAP_ITEM = 300;
21
+ export const MAX_GAP_WHY = 800;
22
+
23
+ const cut = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
24
+
25
+ /** The declared gaps, scrubbed then bounded; undefined when none were declared. */
26
+ export function parseGaps(raw, scrub) {
27
+ if (!Array.isArray(raw)) return undefined;
28
+ const out = [];
29
+ for (const g of raw) {
30
+ if (out.length >= MAX_GAPS) break;
31
+ if (!g || typeof g !== 'object' || typeof g.item !== 'string' || !g.item.trim()) continue;
32
+ const status = typeof g.status === 'string' ? g.status.trim().toLowerCase() : '';
33
+ if (!GAP_STATUSES.includes(status)) continue;
34
+ const why = typeof g.why === 'string' && g.why.trim() ? cut(scrub(g.why.trim()), MAX_GAP_WHY) : null;
35
+ out.push({ item: cut(scrub(g.item.trim()), MAX_GAP_ITEM), status, ...(why ? { why } : {}) });
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /** The section the person reads, or null for no declaration. */
41
+ export function gapsMarkdown(gaps) {
42
+ if (!Array.isArray(gaps)) return null;
43
+ if (!gaps.length) return '**Gaps against the request:** none reported.';
44
+ return ['**Gaps against the request**', ...gaps.map((g) => `- **${g.status}** — ${oneLine(g.item)}${g.why ? `: ${oneLine(g.why)}` : ''}`)].join('\n');
45
+ }
46
+
47
+ const oneLine = (s) => s.replace(/\s*\n\s*/g, ' ');
@@ -46,6 +46,6 @@ export function validateReviewPlan(plan) {
46
46
 
47
47
  export function uiReviewInstructions(argv) {
48
48
  return `LOCAL UI SELF-REVIEW — before delivering UI-changing code, use this turn's owned headless runner: ${JSON.stringify([...argv, '<plan.json>'])}.
49
- Read repository preview scripts and affected acceptance criteria first. Write a JSON plan with command (argv array), basis (repository file/script), cwd (relative directory, optional), port (dedicated port explicitly set in command), readyPath, fixture: { isolated: true, description }, and scenarios: [{ name, path, actions: [{ type, selector, value }] }]. Actions: click, fill, submit, assertText, assertUrl (value is a local path), waitFor. Use only disposable local fixtures; never production data, real email, payments, personal sessions or credentials. The runner starts and measures its own preview and permits only that preview's resources. External dependencies are blocked and reported; use local fixtures instead.
49
+ Read repository preview scripts and affected acceptance criteria first. Write a JSON plan with command (argv array), basis (repository file/script), cwd (relative directory, optional), port (dedicated port explicitly set in command), readyPath, fixture: { isolated: true, description }, and scenarios: [{ name, path, actions: [{ type, selector, value }] }]. Actions: click, fill, submit, assertText, assertUrl (value is a local path), waitFor. Use only disposable local fixtures; never production data, real email, payments, personal sessions or credentials. The runner starts and measures its own preview and permits only that preview's resources. External dependencies are blocked and reported; use local fixtures instead. Shape the fixture from the case the request describes, not from your implementation: the person's screenshot or report (its counts, names and states, such as a 23-card agent or a page with no stored render) or a committed export of real data among the repository's test fixtures when one exists. A fixture shaped only by your own code shows the code renders, not that the request is met: list that as an "unverified" gap.
50
50
  Run desktop and mobile scenarios; read the returned report and OPEN EVERY returned screenshot using card_ui_review.inspect_screenshot when that tool is actually mounted (it returns the exact PNG as native image input), or your runtime's actual image-input tool (for example Read or view_image). Record which files you visually inspected and what you observed in delivery verification. A PNG path, capture success, tool listing or a skill is not proof of inspection. If image input is unavailable, report visual inspection skipped with its measured reason. Check console/network findings, expected and observed interaction results. Fix in-scope problems in this same worktree and rerun affected checks. At most ${UI_REVIEW_MAX_RUNS} runs per turn, each at most ${UI_REVIEW_TIMEOUT_MS / 1000}s; at the bound deliver remaining findings honestly. Later source edits invalidate earlier evidence; commit before the final run when possible. Include changed routes, ordered actions/outcomes, viewport sizes, screenshots, changes prompted by review and every unrun/unavailable check with reasons in verification. Missing browser, runnable preview or safe fixtures is unavailable, never passed; do not install tools or weaken isolation. This review never approves or merges work. For non-UI changes, state why browser review is not applicable.`;
51
51
  }