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
package/README.md CHANGED
@@ -188,7 +188,17 @@ From **0.86.0**, a project whose owner turns on **Publish agent branches** has e
188
188
 
189
189
  You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed), behind the daemon's own gate. Viewers reach the share through Flowviant's relay at `<name>.flowviant.dev`, never the tunnel's own address, and the gate admits only the share's password or a short-lived grant signed with the share's key. Flowviant stores that key and the password with the share, and its relay mints grants for signed-in project members — or for anyone with the link, if the share's owner opens it to everyone. Since 0.72.0 the gate also lets the Workbench embed the share in a frame: it rewrites the response's frame policy to permit exactly the app's origin, and a framed sign-in gets a partitioned cookie so it works where third-party cookies are blocked.
190
190
 
191
- The daemon never executes anything the repository declares. An earlier version read a `.flowviant/preview.json` from the branch and spawned the command it named — that start path was removed in 0.53.0 and is not coming back; see the header of `bin/lib/preview.mjs` for exactly what it did, so nobody rebuilds it.
191
+ Sharing never starts a repository command. An earlier version read a `.flowviant/preview.json` from the branch and spawned the command it named — that sharing start path was removed in 0.53.0; see the header of `bin/lib/preview.mjs`.
192
+
193
+ ## Local UI self-review in code agents
194
+
195
+ During a code build, the same agent can run a bounded local Chromium review before delivery. Its turn supplies the exact helper command and JSON-plan format. The agent chooses the repository-supported preview argv and dedicated port, names its script/configuration basis, and describes disposable fixtures. The runner starts that command inside the worktree with an isolated home and no inherited SDK credentials, measures its listener and process group, then checks HTTP readiness. It does not install a browser or dependencies, share a preview, or open a public tunnel.
196
+
197
+ Each plan contains local routes and ordered CSS-selector actions (`click`, `fill`, `submit`, `waitFor`, `assertText`, `assertUrl`). Chromium uses fresh contexts for 1440×900 desktop and 390×844 mobile captures. Click/submit mean DOM activation; assertions establish the resulting state. A preview-only proxy and request fence block other destinations, popups, downloads, frames, workers and sockets. Apps requiring those capabilities must report the affected checks skipped or use a disposable fixture that works within this boundary. Repository code still executes as the user's UID; the runner is not a sandbox for a malicious backend or a substitute for choosing safe fixtures.
198
+
199
+ On Claude Code and Codex build turns, `card_ui_review.inspect_screenshot` can supply an exact captured PNG as native image input. The agent must actually inspect the images and report what it saw, or report the runtime's image-input failure. A screenshot or image-input receipt alone never counts as a visual verdict. Other build CLIs can use their existing image tools if available and must name unavailable capabilities honestly.
200
+
201
+ The agent can fix in-scope findings and rerun up to three attempts per turn, each capped at two minutes including startup. Every attempt stops its owned browser and preview, including on cancellation; teardown also tracks their process groups. Reports and screenshots use `.flowviant/artifacts/`, and the existing delivery Verification retains routes, viewports, actions/outcomes, scrubbed diagnostics and source identity. Tracked/nonignored file bytes and HEAD are remeasured at delivery; later changes mark evidence stale. Ignored build/runtime files are outside that source measurement. The project check, fresh read-only pre-review, human approval and merge remain separate.
192
202
 
193
203
  ## Modes
194
204
 
package/bin/cli.mjs CHANGED
@@ -85,6 +85,19 @@ if (process.argv[2] === '__capture-mcp') {
85
85
  process.exit(process.exitCode ?? 0);
86
86
  }
87
87
 
88
+ if (process.argv[2] === '__reference-mcp') {
89
+ await (await import('./lib/publicReferenceTool.mjs')).referenceToolMain();
90
+ // A screenshot can exceed the pipe buffer. EOF on the client's input must
91
+ // not truncate a completed result while stdout is still draining.
92
+ await new Promise(resolve => process.stdout.write('', resolve));
93
+ process.exit(process.exitCode ?? 0);
94
+ }
95
+
96
+ if (['__ui-review', '__ui-review-mcp'].includes(process.argv[2])) {
97
+ await (await import('./lib/uiReview.mjs')).uiReviewMain(process.argv[2] === '__ui-review-mcp');
98
+ process.exit(process.exitCode ?? 0);
99
+ }
100
+
88
101
  if (process.argv[2] === '__orchestrator-mcp') {
89
102
  await (await import('./lib/orchestratorClient.mjs')).orchestratorMcpMain();
90
103
  process.exit(process.exitCode ?? 0);
@@ -70,6 +70,24 @@ export function stashCard(path, taskId, spec) {
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * WHAT THE AGENT SAID IT DID NOT DELIVER on a card (2026-10-25, turnGaps.mjs),
75
+ * kept beside the spec so the pre-review can read the request, the diff and
76
+ * the agent's own account of what is missing together. Same append rule; the
77
+ * reader keeps the last declaration per card.
78
+ */
79
+ export function stashGaps(path, taskId, gaps) {
80
+ if (!path || !Array.isArray(gaps)) return false;
81
+ const id = String(taskId ?? '').trim();
82
+ if (!id) return false;
83
+ try {
84
+ appendFileSync(path, JSON.stringify({ taskId: id.slice(0, 64), gaps }) + '\n');
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
73
91
  /**
74
92
  * What this box holds, newest spec per card, in the order the cards were worked.
75
93
  *
@@ -89,6 +107,7 @@ export function readStash(path) {
89
107
  * order stays the order the cards were first handed out — which is the order
90
108
  * the branch was built in, and the order a reviewer reads them in. */
91
109
  const byId = new Map();
110
+ const gapsById = new Map();
92
111
  for (const line of raw.split('\n')) {
93
112
  if (!line.trim()) continue;
94
113
  let v;
@@ -99,10 +118,14 @@ export function readStash(path) {
99
118
  }
100
119
  if (!v || typeof v !== 'object') continue;
101
120
  const taskId = typeof v.taskId === 'string' ? v.taskId.trim() : '';
121
+ if (taskId && Array.isArray(v.gaps)) {
122
+ gapsById.set(taskId, v.gaps);
123
+ continue;
124
+ }
102
125
  const prompt = typeof v.prompt === 'string' ? v.prompt : '';
103
126
  if (!taskId || !prompt.trim()) continue;
104
127
  byId.set(taskId, prompt);
105
128
  }
106
- const all = [...byId].map(([taskId, prompt]) => ({ taskId, prompt }));
129
+ const all = [...byId].map(([taskId, prompt]) => ({ taskId, prompt, ...(gapsById.has(taskId) ? { gaps: gapsById.get(taskId) } : {}) }));
107
130
  return all;
108
131
  }
@@ -35,6 +35,7 @@ import { AGENT_TASK_KINDS } from './agentTaskKinds.mjs';
35
35
  import { blockedAnswer } from './agentAsk.mjs';
36
36
  import { isResearchUse } from './researchUse.mjs';
37
37
  import { candidateObjects } from './modelJson.mjs';
38
+ import { parseGaps } from './turnGaps.mjs';
38
39
 
39
40
  /** The biggest a single proposal may be. Bounds on a machine, not a policy:
40
41
  * the server caps these again at its own boundary. */
@@ -272,6 +273,8 @@ export function parseTurnResult(text, scrub = envScrub) {
272
273
  ...(typeof v.automation === 'string' && v.automation.trim()
273
274
  ? { automation: scrub(v.automation.trim()) } : {}),
274
275
  ...(parseTestFindings(v.testFindings, scrub) ? { testFindings: parseTestFindings(v.testFindings, scrub) } : {}),
276
+ // What it says it did not deliver (turnGaps.mjs): absent is unknown.
277
+ ...(parseGaps(v.gaps, scrub) ? { gaps: parseGaps(v.gaps, scrub) } : {}),
275
278
  ...progressOf(v, scrub),
276
279
  raised: Array.isArray(v.raised)
277
280
  ? v.raised
@@ -14,6 +14,9 @@ an order alone is not a declared dependency. requirements.status=missing is
14
14
  unavailable, never an empty brief. Discussions carry their existing omission
15
15
  counts and may be unavailable; do not treat a shortened discussion as full
16
16
  requirements. The brief and criteria marked full are supplied without clipping.
17
+ sourceChats are the person's exact words in the New task chat these cards were
18
+ planned in, up to each card's landing; a brief is the planner's paraphrase of
19
+ them, and links, references and file lists there are part of the request.
17
20
  Human messages are exact recorded words across this agent, with the preceding
18
21
  question when present. taskId on a message is where it was recorded, not an
19
22
  implicit scope restriction. target.kind=agent explicitly addresses the whole
@@ -32,7 +35,9 @@ relying on an excluded decision; do not recover it from older native memory.
32
35
  Latest outcomes and progress are reported summaries, not complete specifications,
33
36
  proof that checks passed, human acceptance or evidence that bytes exist locally.
34
37
  Stored outputs name exact ids/turns/hashes, not a successful local read. Retained
35
- result selections preserve their exact scope. Use the active turn's measured
38
+ result selections preserve their exact scope. localInputs lists the retained
39
+ paths that exist in this worktree this turn; any other retained path named in
40
+ the snapshot does not. Use the active turn's measured
36
41
  input manifest and prerequisite receipts before relying on files; never replace
37
42
  or regenerate missing selected inputs or assume queue knowledge discovers them.
38
43
  Only execute the active turn's assigned work. Upcoming cards are context, not
@@ -44,6 +49,20 @@ only relevant prior findings/decisions/input identities, not the entire queue.
44
49
  ${fence('AGENT QUEUE SNAPSHOT', context)}`;
45
50
  }
46
51
 
52
+ /** THE CARD'S SOURCE CHAT, as words the pre-review reads beside its spec
53
+ * (2026-10-25): the stash is per card and the snapshot is gone by review
54
+ * entry, so the person's request is kept with the spec it shaped. Empty when
55
+ * the card landed from no chat (or the server sent no source chats). */
56
+ export function sourceChatText(context, taskId) {
57
+ const chats = (context?.sourceChats?.chats ?? []).filter((c) => Array.isArray(c.taskIds) && c.taskIds.includes(taskId));
58
+ if (!chats.length) return '';
59
+ const said = chats.flatMap((c) => [
60
+ ...(c.messages ?? []).map((m) => `${m.answering ? `(answering: ${m.answering})\n` : ''}${m.body}`),
61
+ ...(c.omitted ? [`(${c.omitted} more message${c.omitted === 1 ? '' : 's'} omitted)`] : []),
62
+ ]);
63
+ return `\n\nTHE PERSON'S OWN WORDS (the New task chat this card was planned in; the brief above paraphrases them):\n${said.join('\n\n')}`;
64
+ }
65
+
47
66
  /** The gate supplies active identity/fences, not upcoming work to a child.
48
67
  * Relevant findings still travel with the root's focused assignment. */
49
68
  export function agentChildScope(job) {
@@ -82,6 +82,49 @@ is missing, so you lead:
82
82
  outside decision — stop and name exactly that dependency; a skip never
83
83
  supplies it.`;
84
84
 
85
+ /**
86
+ * THE WHOLE REQUEST, NOT ONLY THE BRIEF (2026-10-25) — every agent contract
87
+ * closes on it, before HELP_THEM_DECIDE.
88
+ *
89
+ * Eighteen merged runs were audited and most delivered part of what was asked
90
+ * while every check passed: a Codex planner could not open a Claude Design
91
+ * file and wrote "use only the described controls as scope" into the brief;
92
+ * the Claude agent, which could open it, never tried; an agent recast the
93
+ * design states between its cards as "decisions"; another satisfied "measure
94
+ * the waits" with a timer it wrote. The brief is a paraphrase, the person's
95
+ * words now ride the queue snapshot (`sourceChats`), a reference the agent
96
+ * can open is the agent's to open, and the delivery says what is missing
97
+ * (`gaps`, turnGaps.mjs) instead of leaving the person to find it.
98
+ */
99
+ export const WHOLE_REQUEST = `THE WHOLE REQUEST, NOT ONLY THE BRIEF:
100
+ - A brief is a planner's paraphrase. When the queue snapshot carries
101
+ "sourceChats", those are the person's exact words from the chat this card
102
+ was planned in: read them before you start. Links, design references, file
103
+ lists and pasted text there are part of the request.
104
+ - A REFERENCE YOU CAN OPEN, YOU OPEN. Read every design or reference that the
105
+ brief, the source chat, the card's discussion or the person's messages link
106
+ to before you build against it. A claude.ai/design link is read with a
107
+ DesignSync tool where your CLI offers one: load it (ToolSearch
108
+ "select:DesignSync"), then get_file the named file and its imports,
109
+ read-only, never its write methods. A brief that says a reference "could not be opened", "is
110
+ unavailable" or "use only X as scope" describes the planner's access, not
111
+ yours: try it yourself. If you still cannot read it, name it in "gaps" and
112
+ build nothing that depends on guessing what it contains.
113
+ - COVER THE REQUEST, THEN SAY WHAT IS MISSING. Before you deliver, walk the
114
+ request: the source chat's words, every frame, state and rule a reference
115
+ you read shows or states, the brief and each "done when". Add "gaps" to the
116
+ delivered JSON object, one entry per item this card was meant to deliver
117
+ that you did not build ("not built"), built in part ("partly") or did not
118
+ check against the real thing ("unverified"), and per part of the request
119
+ that NO card in this run owns ("unowned"), so the person can file it:
120
+ "gaps": [{ "item": "what is missing", "status": "not built | partly | unverified | unowned", "why": "one sentence" }]
121
+ "gaps": [] says you walked the request and nothing is missing. Never recast
122
+ a gap as a decision: a choice you made in place of the request is a gap
123
+ ("partly") with its reason.
124
+ - A "reproduce", "measure" or "verify" step is done only against the real
125
+ thing. A mock, timer or fixture you wrote yourself does not satisfy it:
126
+ list it as "unverified".`;
127
+
85
128
  /**
86
129
  * Rule 3, rule 4 and the final JSON a file-handing contract ends with.
87
130
  * `ask` is rule 4's body after its number (the decision only a person can
@@ -119,6 +162,8 @@ or, if you are stopping to ask:
119
162
  "progress" is REQUIRED on both shapes and REPLACES the previous one whole —
120
163
  past tense, plain sentences, no card ids.
121
164
 
165
+ ${WHOLE_REQUEST}
166
+
122
167
  ${HELP_THEM_DECIDE}`;
123
168
 
124
169
  /**
@@ -158,7 +203,54 @@ export const LOOK_HEADLESS = `${NO_WINDOW} To look at your page, screenshot it
158
203
  * so it posts to `parent` with `'*'`; the app accepts only its own frame's
159
204
  * messages. A page with no handshake (every 0.105.0 bundle) is still viewed;
160
205
  * it is just never offered a Download .glb.
206
+ *
207
+ * THE MANIFEST AND THE POSTER (2026-10-25, the owner's `Output Viewer Frame`;
208
+ * decision record `2026-10-25-flowviant-draws-standard-outputs`). Flowviant
209
+ * draws the exported GLB in its own viewer, and every control over it, so the
210
+ * page becomes a full-bleed canvas and its folder gains `view.json`, the
211
+ * manifest the web reads when present (packages/shared/src/modelViewer.ts —
212
+ * its literal here is `MODEL_VIEW_MANIFEST`, held to that schema by
213
+ * agentTaskKinds.test.mjs). It uploads on the general rule — an allowlisted
214
+ * type the turn changed is prepared and sent (intermediateArtifacts.mjs) — so
215
+ * only these words had been keeping it out. It proves no delivery: the page
216
+ * and its measured export still do.
217
+ *
218
+ * THE POSTER IS THE DAEMON'S RENDER OF THE PAGE (0.125.21). These words first
219
+ * asked the agent to screenshot its own `poster.png`, the still a list or a
220
+ * nested kit shows without loading a GLB; but a Claude model card cannot run
221
+ * Chrome under its fenced shell, and the daemon already renders every HTML
222
+ * artifact it uploads (artifactPreview.mjs `renderDesignPreview`), which the
223
+ * server stores as that artifact's preview. So the page's default view IS the
224
+ * poster, and the contract asks for that view instead: the whole model,
225
+ * full-bleed, nothing over it, standing within the render's few seconds.
226
+ * "poster" stays optional in the shape: a poster.png an agent already has
227
+ * still ships on the same general rule.
161
228
  */
229
+ export const MODEL_VIEW_MANIFEST = `{
230
+ "kind": "map",
231
+ "title": "Street and metro map",
232
+ "entry": "street-metro-map/index.glb",
233
+ "poster": "street-metro-map/poster.png",
234
+ "view": {
235
+ "units": { "metre": 1, "unit": "m" },
236
+ "upAxis": "Y",
237
+ "front": "+Z",
238
+ "defaultView": "overview",
239
+ "views": [
240
+ { "id": "overview", "label": "Overview", "group": "overview", "direction": "iso" },
241
+ { "id": "plan", "label": "Plan", "group": "overview", "direction": "top" },
242
+ { "id": "konbini", "label": "Konbini", "group": "spot", "area": "Shops", "direction": "front", "part": "konbini" },
243
+ { "id": "north-platform", "label": "North platform", "group": "spot", "area": "Station",
244
+ "camera": { "position": [42, 6, -18], "target": [42, 1, -4] } }
245
+ ],
246
+ "layers": [
247
+ { "id": "konbini", "label": "Konbini", "part": "konbini" },
248
+ { "id": "station-platforms", "label": "Station platforms", "part": "station-platforms" }
249
+ ]
250
+ },
251
+ "parts": ["konbini", "station-platforms"]
252
+ }`;
253
+
162
254
  export const SYSTEM_AGENT_MODEL = `You are the human's own Claude, working one 3D MODEL card in a git worktree of
163
255
  their repository. Nobody is watching this run. You have the repo and the tools
164
256
  TOOLS ON THIS CARD names below — no board, no chat.
@@ -177,8 +269,9 @@ WHAT TO DO:
177
269
 
178
270
  2. Write ONE viewer, .flowviant/artifacts/<kebab-name>/index.html — a short
179
271
  kebab-case name for what it is (for example
180
- .flowviant/artifacts/reading-chair/index.html). Keep the viewer self-contained;
181
- do not add scripts or notes in its folder. Flowviant materializes index.glb
272
+ .flowviant/artifacts/reading-chair/index.html). Keep the viewer self-contained:
273
+ beside it, its folder holds only the view.json below (and a poster.png, if
274
+ you already have one), never scripts or notes. Flowviant materializes index.glb
182
275
  beside this exact page before completing the card, through the handler below.
183
276
  Keep original generated texture PNGs elsewhere under .flowviant/artifacts/;
184
277
  preserve their bytes and material mapping. The page BUILDS
@@ -186,6 +279,9 @@ WHAT TO DO:
186
279
  - three.js as ES modules from cdn.jsdelivr.net/npm, through an import map
187
280
  for "three" and "three/addons/"; orbit controls, lights and a neutral
188
281
  ground. Nothing else loads from the network, and nothing can be sent.
282
+ - the page is the scene and nothing else: a full-bleed canvas, with no
283
+ title, panel, legend, button or text over the model. Flowviant draws
284
+ every control.
189
285
  - the model is ONE THREE.Group named for what it is, built from three.js
190
286
  geometry (lathe, extrude, tube, box and the rest, one mesh per part),
191
287
  with MeshStandardMaterial only. Colour, roughness and metalness are
@@ -228,6 +324,40 @@ WHAT TO DO:
228
324
 
229
325
  ${LOOK_HEADLESS}
230
326
 
327
+ THE PAGE'S DEFAULT VIEW IS THE POSTER FLOWVIANT SHOWS. Flowviant renders
328
+ this page itself when it uploads it, and that still is the model's picture
329
+ wherever the model is listed. So the page opens on the default view below:
330
+ the whole model, fitted full-bleed to the frame, with nothing over it — no
331
+ loading text, label or overlay — standing within a few seconds of load.
332
+ Take no poster yourself; a poster.png you already have may sit in the
333
+ folder, named in "poster" below.
334
+
335
+ Then write <kebab-name>/view.json — how Flowviant's viewer frames the
336
+ model, exactly in this shape (a map's, with every field):
337
+
338
+ ${MODEL_VIEW_MANIFEST.replace(/^/gm, ' ')}
339
+
340
+ "kind" is "model" for one object, "map" for a scene that places several
341
+ kits. "entry" is a path under .flowviant/artifacts/: this folder's
342
+ index.glb. "poster" is optional: name this folder's poster.png only when
343
+ the folder holds one; otherwise leave the field out. Declare "units"
344
+ once, as the size of the scene's unit in metres —
345
+ { "stud": 0.28, "unit": "m" } for a scene built in studs — and never
346
+ write units or any other text into the scene; Flowviant draws the scale
347
+ bar. "upAxis" is "Y" (three.js is Y-up); "front" is the side the model
348
+ faces: "+X", "-X", "+Z" or "-Z". "views": up to three "overview" views,
349
+ "defaultView" naming one; a map adds named "spot" views, each grouped
350
+ under its "area". A view looks from a "direction" ("iso", "top",
351
+ "front", "side" or "back") fitted to the whole scene or to one "part", or
352
+ from a "camera" position and target in scene units. "layers", for a map:
353
+ one per kit, its "part" the kit's scene node name exactly. "parts": every
354
+ node you name in code (.name, which GLTFExporter keeps); each "part"
355
+ above is one of them, spelled the same. A single model has no spots and
356
+ no layers: Flowviant gives it Display and Turntable. A label or area is a
357
+ few words, 40 characters at most; at most 40 views, 60 layers and 200
358
+ parts — a manifest past any limit is not read. The page's opening view
359
+ is the one "defaultView" names, so the poster and the viewer agree.
360
+
231
361
  ${ARTIFACT_CONTRACT_TAIL({
232
362
  ask: `If you cannot model it without a DECISION only a person can make — which of
233
363
  two directions, what size or style, which asset it stands beside — STOP AND
@@ -1,4 +1,5 @@
1
1
  /** Explicit iteration relationships, plus measured local deletions. No model runs here. */
2
+ import { REFERENCE_CAPTURE_FILE } from './referenceCaptureProtocol.mjs';
2
3
  import { constants, openSync, closeSync, fstatSync, readFileSync, lstatSync, unlinkSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
3
4
  import { join, dirname, resolve, sep } from 'node:path';
4
5
  import { createHash } from 'node:crypto';
@@ -117,7 +118,7 @@ export function seenArtifacts(record, stored, present, bornAt = null) {
117
118
  let seen = record;
118
119
  if (!seen) {
119
120
  const since = Number.isFinite(bornAt)
120
- ? stored.filter((a) => storedAt(a) >= bornAt).map((a) => a.name) : [];
121
+ ? stored.filter((a) => storedAt(a) >= bornAt && !REFERENCE_CAPTURE_FILE.test(a.name)).map((a) => a.name) : [];
121
122
  seen = since.some((n) => present.has(n)) ? new Set(since) : new Set();
122
123
  }
123
124
  return new Set([...seen, ...present].filter((n) => names.has(n) || present.has(n)));
@@ -297,7 +297,7 @@ export function createArtifactReporter({
297
297
 
298
298
  /** Upload what this turn changed in `placeDir`. Resolves to the number of
299
299
  * bodies SENT this call (delivered or held), for the tests and the log. */
300
- const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null }) => {
300
+ const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null, referenceCapture = false }) => {
301
301
  if (!placeDir || (!sessionId && !agentId)) return 0;
302
302
  const changes = iterationChanges(placeDir, before);
303
303
  const protectedNames = imageRevisionProtection(before?.stored, before?.imageRevision);
@@ -347,7 +347,7 @@ export function createArtifactReporter({
347
347
  failures.push(`Required intermediate output ${name} is missing, unsafe or excluded from reporting; its handoff is unfinished.`);
348
348
  let n = 0;
349
349
  for (const entry of changed) {
350
- const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
350
+ const body = buildUpload(entry, { sessionId, agentId, turnId, ...(referenceCapture ? { referenceCapture: true } : {}) }, scrub, secretIn);
351
351
  if (required.has(entry.name) && (!body?.bytes || body.withheld || body.fields.sha256 !== required.get(entry.name))) {
352
352
  failures.push(`Required intermediate output ${entry.name} is unreadable, withheld or changed since preparation. Restore its exact producer bytes and retry.`);
353
353
  continue;
@@ -74,6 +74,7 @@
74
74
  * that cannot show an artifact must not have the CLI told it will.
75
75
  */
76
76
 
77
+ import { REFERENCE_CAPTURE_MANIFEST, REFERENCE_CAPTURE_FILE } from './referenceCaptureProtocol.mjs';
77
78
  import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync } from 'node:fs';
78
79
  import { createHash } from 'node:crypto';
79
80
  import { join } from 'node:path';
@@ -305,7 +306,7 @@ export const artifactStoredName = (name) => name.split('/').map(safeArtifactName
305
306
  * re-sent exactly as it was first built (scrubbed once, never re-read from a
306
307
  * disk the agent may have changed since).
307
308
  */
308
- export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s, secretIn = () => null) {
309
+ export function buildArtifactUpload(entry, { sessionId, agentId, turnId, referenceCapture = false }, scrub = (s) => s, secretIn = () => null) {
309
310
  const type = artifactTypeFor(entry.name);
310
311
  const fields = {
311
312
  ...(sessionId ? { sessionId } : { agentId }),
@@ -313,6 +314,9 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
313
314
  name: artifactStoredName(entry.name),
314
315
  bytes: String(entry.size),
315
316
  };
317
+ // Reserved evidence names are writable only by the measured browser
318
+ // consumer. Model-authored JSON cannot become a page-access measurement.
319
+ if (REFERENCE_CAPTURE_FILE.test(entry.name) && !referenceCapture) return { withheld: 'measured-reference-capture-only', fields };
316
320
  if (!type) return { fields, bytes: null }; // reported by name only
317
321
  const maxBytes = artifactMaxBytesFor(entry.name);
318
322
  if (entry.size > maxBytes) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
@@ -328,7 +332,10 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
328
332
  const secret = secretIn(bytes);
329
333
  if (secret) return { withheld: secret, fields };
330
334
  } else {
335
+ const original = bytes;
331
336
  bytes = Buffer.from(String(scrub(bytes.toString('utf8'))), 'utf8');
337
+ if (REFERENCE_CAPTURE_MANIFEST.test(entry.name) && !bytes.equals(original))
338
+ return { withheld: 'reference-capture-redaction', fields };
332
339
  // A redaction marker can be longer than the value it replaced.
333
340
  if (bytes.byteLength > maxBytes) {
334
341
  return { fields: { ...fields, bytes: String(bytes.byteLength), tooLarge: '1' }, bytes: null };
@@ -198,6 +198,7 @@ export function handleStreamLine(line, options) {
198
198
  for (const b of ev.message.content) {
199
199
  if (b.type === 'tool_result') onToolEvent?.(null, {}, {
200
200
  id: b.tool_use_id, result: true, isError: b.is_error === true,
201
+ toolSuccess: b.is_error !== true,
201
202
  ...(agentId ? { agentId } : {}),
202
203
  output: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.slice(0, 8).filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
203
204
  detail: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
@@ -232,6 +233,12 @@ export function handleStreamLine(line, options) {
232
233
  // alias, and this is the CLI naming what it chose. See turnModel.mjs.
233
234
  const resolved = turnModelOf(ev.model);
234
235
  if (resolved) onModel?.(resolved);
236
+ if (Array.isArray(ev.tools)) for (const tool of ['WebSearch', 'WebFetch']) {
237
+ onToolEvent?.('WebsiteAccess', { method: 'capability', tool }, {
238
+ id: `website-capability:${tool}`, result: true,
239
+ website: { access: 'unknown', availability: ev.tools.includes(tool) ? 'advertised' : 'unknown' },
240
+ });
241
+ }
235
242
  if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
236
243
  onInit?.({
237
244
  skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
@@ -109,7 +109,7 @@ const table = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
109
109
  * config.toml could not be read), and
110
110
  * `unmounted` `[{ name, reason, source: 'project' | 'machine', partial? }]`.
111
111
  */
112
- export function codexFencedMount(usable, expand, machineEnv, turnServers = {}) {
112
+ export function codexFencedMount(usable, expand, machineEnv, turnServers = {}, { includeMachine = true } = {}) {
113
113
  const args = [];
114
114
  const childEnv = {};
115
115
  const mounted = [];
@@ -192,7 +192,7 @@ export function codexFencedMount(usable, expand, machineEnv, turnServers = {}) {
192
192
  }
193
193
 
194
194
  const projectNames = new Set([...usable.map((t) => t.name), ...ownNames]);
195
- const personal = machineCodexServers(machineEnv);
195
+ const personal = includeMachine ? machineCodexServers(machineEnv) : [];
196
196
  for (const [name, server] of personal ?? []) {
197
197
  if (projectNames.has(name) || server.enabled === false) continue;
198
198
  if (typeof server.command !== 'string' && typeof server.url !== 'string') continue;
@@ -11,6 +11,7 @@ import { CODEX_CHILD_THREAD_RE } from './conversationIds.mjs';
11
11
  import { followJsonl } from './jsonlTail.mjs';
12
12
  import { codexCollabUpdates, codexSpawnResult, statusOf } from './codexSubagentEvents.mjs';
13
13
  import { NATIVE_AGENT_TOOLS } from './nativeAgentTools.mjs';
14
+ import { isSealedMessage } from './orchestratorNative.mjs';
14
15
  import { SUBAGENT_CAP } from './subagentBounds.mjs';
15
16
 
16
17
  const CODEX = NATIVE_AGENT_TOOLS.codex;
@@ -84,7 +85,7 @@ export function createCodexSubagentReader({ since = Date.now(), env = process.en
84
85
  if (!COLLAB_CALLS.includes(name)) return;
85
86
  let args;
86
87
  try { args = JSON.parse(p.arguments); } catch { return; }
87
- calls.set(p.call_id, { name, args });
88
+ calls.set(p.call_id, { name, args, namespace: p.namespace });
88
89
  if (calls.size > 128) calls.delete(calls.keys().next().value);
89
90
  const id = targetId(args.target ?? args.id);
90
91
  if (id && name !== 'spawn_agent') {
@@ -122,7 +123,10 @@ export function createCodexSubagentReader({ since = Date.now(), env = process.en
122
123
  const title = args.task_name ?? args.description ?? output.agent_nickname ?? '';
123
124
  if (args.task_name) rememberTarget(args.task_name, id);
124
125
  if (typeof path === 'string') rememberTarget(path, id);
125
- report({ id, kind: args.agent_type ?? '', title, brief: args.message ?? '', status: 'running', now: '' });
126
+ // A V2 message is ciphertext (orchestratorNative.mjs `isSealedMessage`):
127
+ // its words are unknown, so the child carries no brief rather than that.
128
+ const sealed = isSealedMessage(call.namespace ? `${call.namespace}.${call.name}` : call.name, args.message);
129
+ report({ id, kind: args.agent_type ?? '', title, ...(sealed ? {} : { brief: args.message ?? '' }), status: 'running', now: '' });
126
130
  return;
127
131
  }
128
132
  if (owner !== root && ev.type === 'response_item' && p.type === 'custom_tool_call') {
@@ -21,17 +21,22 @@ export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLE
21
21
  // A kept library item (0.97.0) is its sibling, `GET /fleet/library/:id` —
22
22
  // the same credential, the same shape, the same caps.
23
23
  const libraryBase = fleetEndpoint('library', fleetUrl);
24
- return async (id, { library = false, preview = false, fileIndex = null } = {}) => {
24
+ // THE CAP IS THE FILE'S (2026-10-25): the sync holds a kept model under
25
+ // models/ or designs/ to 32 MiB (`knowledgeMaxFor`) and hands that cap here;
26
+ // this default alone turned every 20–32 MiB GLB into the over-cap sentinel,
27
+ // a failed hash on every poll, so a map the Library kept never reached an
28
+ // agent.
29
+ return async (id, { library = false, preview = false, fileIndex = null, maxBytes: cap = maxBytes } = {}) => {
25
30
  const suffix = preview ? '/preview' : Number.isSafeInteger(fileIndex) && fileIndex >= 0 ? `/file/${fileIndex}` : '';
26
31
  const res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}${suffix}`, {
27
32
  headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
28
33
  signal: AbortSignal.timeout(60_000),
29
34
  });
30
35
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
31
- if (Number(res.headers.get('content-length') ?? '0') > maxBytes) {
36
+ if (Number(res.headers.get('content-length') ?? '0') > cap) {
32
37
  // Returned rather than thrown: over the cap is a REFUSAL, permanent for
33
38
  // this rev, not a failure the backoff should keep retrying.
34
- return Buffer.alloc(maxBytes + 1);
39
+ return Buffer.alloc(cap + 1);
35
40
  }
36
41
  return Buffer.from(await res.arrayBuffer());
37
42
  };
@@ -217,7 +217,7 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
217
217
  sha256: f.sha256,
218
218
  bytes: f.bytes,
219
219
  maxBytes: knowledgeMaxFor(f.name, maxBytes),
220
- fetch: () => fetchFile(f.id),
220
+ fetch: () => fetchFile(f.id, { maxBytes: knowledgeMaxFor(f.name, maxBytes) }),
221
221
  });
222
222
  // Stale or failed clears `ok` and is retried (a stale file is better than
223
223
  // a missing one until the retry lands); a refusal is listed as one.
@@ -299,7 +299,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
299
299
  sha256: it.sha256,
300
300
  bytes: it.bytes,
301
301
  maxBytes: itemMax,
302
- fetch: () => fetchFile(it.id, { library: true }),
302
+ fetch: () => fetchFile(it.id, { library: true, maxBytes: itemMax }),
303
303
  beforeWrite: () => ensureLibraryParents(dir, it.path),
304
304
  });
305
305
  if (recordSyncState(result, entryState, it.path) === 'refused') {
@@ -317,7 +317,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
317
317
  sha256: it.preview.sha256,
318
318
  maxBytes: 2 * 1024 * 1024,
319
319
  oversizeFails: true,
320
- fetch: () => fetchFile(it.id, { library: true, preview: true }),
320
+ fetch: () => fetchFile(it.id, { library: true, preview: true, maxBytes: 2 * 1024 * 1024 }),
321
321
  beforeWrite: () => ensureLibraryParents(dir, it.previewPath),
322
322
  });
323
323
  recordSyncState(result, previewState, it.previewPath);
@@ -335,7 +335,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
335
335
  sha256: String(f.sha256),
336
336
  bytes: f.bytes,
337
337
  maxBytes: knowledgeMaxFor(f.path, maxBytes),
338
- fetch: () => fetchFile(it.id, { library: true, fileIndex: f.index }),
338
+ fetch: () => fetchFile(it.id, { library: true, fileIndex: f.index, maxBytes: knowledgeMaxFor(f.path, maxBytes) }),
339
339
  beforeWrite: () => ensureLibraryParents(dir, f.path),
340
340
  });
341
341
  if (recordSyncState(result, fileState, f.path) === 'refused') wanted[sub].delete(rel);