flowviant 0.116.0 → 0.124.0

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 (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -0,0 +1,182 @@
1
+ /** Explicit iteration relationships, plus measured local deletions. No model runs here. */
2
+ import { constants, openSync, closeSync, fstatSync, readFileSync, lstatSync, unlinkSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
3
+ import { join, dirname, resolve, sep } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { flowviantParentState, realDirState } from './ownedDirs.mjs';
6
+ import { ARTIFACT_DIR, artifactStoredName, walkArtifactDir } from './artifacts.mjs';
7
+
8
+ const MANIFEST = '.iteration.json';
9
+ const safeName = (name) => typeof name === 'string' && name.length <= 500 &&
10
+ name.split('/').every((p) => p && p !== '.' && p !== '..' && !p.startsWith('.') && !p.includes('\\'));
11
+
12
+ const manifestText = (placeDir) => {
13
+ if (!walkArtifactDir(placeDir, () => {})) return null;
14
+ let fd;
15
+ try {
16
+ fd = openSync(join(placeDir, ARTIFACT_DIR, MANIFEST), constants.O_RDONLY | constants.O_NOFOLLOW);
17
+ const st = fstatSync(fd);
18
+ if (!st.isFile() || st.size > 32 * 1024) return null;
19
+ return { text: readFileSync(fd, 'utf8'), stamp: `${st.size}:${st.mtimeMs}` };
20
+ } catch { return null; }
21
+ finally { if (fd !== undefined) closeSync(fd); }
22
+ };
23
+ const signature = (value) => value === null ? null : `${value.stamp}:${createHash('sha256').update(value.text).digest('hex')}`;
24
+ export const iterationSignature = (placeDir) => signature(manifestText(placeDir));
25
+
26
+ export function iterationChanges(placeDir, before) {
27
+ const text = manifestText(placeDir);
28
+ if (text === null || signature(text) === before?.iterationSignature) return { remove: [], replace: {} };
29
+ let value;
30
+ try { value = JSON.parse(text.text); } catch { throw new Error('Artifact iteration declaration is not valid JSON.'); }
31
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Artifact iteration declaration must be an object.');
32
+ const remove = value.remove ?? [];
33
+ const replace = value.replace ?? {};
34
+ if (!Array.isArray(remove) || remove.length > 40 || !remove.every(safeName) ||
35
+ !replace || typeof replace !== 'object' || Array.isArray(replace) || Object.keys(replace).length > 40 ||
36
+ !Object.entries(replace).every(([from, to]) => safeName(from) && safeName(to) && from !== to)) {
37
+ throw new Error('Artifact iteration needs remove names and explicit old-to-new replacement names.');
38
+ }
39
+ const removed = new Set([...remove, ...Object.keys(replace)]);
40
+ if (Object.values(replace).some((to) => removed.has(to))) throw new Error('A replacement cannot also be discarded.');
41
+ return { remove, replace };
42
+ }
43
+
44
+ /** Stored spellings of the regular files a place holds; null when its directory cannot be walked. */
45
+ export function presentArtifacts(placeDir) {
46
+ const live = new Set();
47
+ return walkArtifactDir(placeDir, (e) => { if (e.kind === 'file') live.add(artifactStoredName(e.name)); }) ? live : null;
48
+ }
49
+
50
+ /**
51
+ * WHAT THIS PLACE HAS HELD, per owner (2026-10-11 follow-up). Local absence
52
+ * proves a discard only for a name this directory held: a handed-over or
53
+ * recreated worktree starts without its owner's delivery, which was stored
54
+ * from somewhere else. The record lives in the worktree's private git dir
55
+ * (the work manager's `seenPath`), so it dies with the worktree. Missing is
56
+ * null — a place never measured — and an unreadable record proves nothing.
57
+ */
58
+ const SEEN_MAX_BYTES = 256 * 1024;
59
+ const SEEN_MAX_NAMES = 2000;
60
+ export function readSeenArtifacts(path) {
61
+ if (!path) return new Set();
62
+ let fd;
63
+ try {
64
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
65
+ const st = fstatSync(fd);
66
+ if (!st.isFile() || st.size > SEEN_MAX_BYTES) return new Set();
67
+ const value = JSON.parse(readFileSync(fd, 'utf8'));
68
+ return new Set(Array.isArray(value) ? value.filter(safeName) : []);
69
+ } catch (e) {
70
+ return e?.code === 'ENOENT' ? null : new Set();
71
+ } finally { if (fd !== undefined) closeSync(fd); }
72
+ }
73
+ export function writeSeenArtifacts(path, names) {
74
+ if (!path) return;
75
+ const tmp = `${path}.${process.pid}.tmp`;
76
+ try {
77
+ writeFileSync(tmp, JSON.stringify([...new Set(names)].filter(safeName).slice(0, SEEN_MAX_NAMES)));
78
+ renameSync(tmp, path);
79
+ } catch {
80
+ // An unwritten record leaves the place unmeasured, never a false discard.
81
+ try { rmSync(tmp, { force: true }); } catch { /* best-effort */ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The owner's stored names this place counts as its own. A place measured
87
+ * before the record existed counts only rows stored AFTER it was cut
88
+ * (`bornAt`, the worktree's creation on the server's clock — `serverBornAt`):
89
+ * a moved agent's new worktree, or one
90
+ * recreated after retirement, never held what was stored before it existed —
91
+ * even when it has since regenerated one of those names. Among the rows it
92
+ * could have written, it held them all only while it still holds part of them:
93
+ * the delivery whose discards an older daemon never relayed. An unknown birth
94
+ * or an undated row proves nothing; an empty place proves nothing; and the
95
+ * record written now keeps a later same-name upload from proving it either.
96
+ */
97
+ export function seenArtifacts(record, stored, present, bornAt = null) {
98
+ const names = new Set(stored.map((a) => a.name));
99
+ let seen = record;
100
+ if (!seen) {
101
+ const since = Number.isFinite(bornAt)
102
+ ? stored.filter((a) => storedAt(a) >= bornAt).map((a) => a.name) : [];
103
+ seen = since.some((n) => present.has(n)) ? new Set(since) : new Set();
104
+ }
105
+ return new Set([...seen, ...present].filter((n) => names.has(n) || present.has(n)));
106
+ }
107
+ /**
108
+ * THE PLACE'S BIRTH ON THE SERVER'S CLOCK, never earlier than it was. Rows are
109
+ * dated by the server and the birth by this machine, whose clock can run hours
110
+ * behind (a WSL clock after the host slept), which would make an older
111
+ * delivery read as stored after the cut. The inventory answer's `Date` was
112
+ * stamped after `sentAt` and floors to the second, so the true offset is below
113
+ * `Date + 1s - sentAt`: that bound can only exclude a row. No header, no bound,
114
+ * and so no legacy evidence.
115
+ */
116
+ export function serverBornAt(bornAt, serverDate, sentAt) {
117
+ const server = Date.parse(serverDate ?? '');
118
+ return Number.isFinite(bornAt) && Number.isFinite(server) && Number.isFinite(sentAt)
119
+ ? bornAt + (server + 1000 - sentAt) : null;
120
+ }
121
+ /** A row's stored time in ms, or NaN — an older server sends none. */
122
+ const storedAt = (a) => typeof a.createdAt === 'string' || typeof a.createdAt === 'number' ? new Date(a.createdAt).getTime() : NaN;
123
+
124
+ /** A missing leaf is measured only through real, readable parent directories. */
125
+ export function deletedArtifacts(placeDir, before) {
126
+ // Only names this place held are candidates: this turn's snapshot and the
127
+ // place's record (`before.seen`). Normalize live names before probing: a
128
+ // draft+1.md is stored as draft_1.md and is not an absent retained file.
129
+ const candidates = new Set([...(before?.keys() ?? []), ...(before?.seen ?? [])]);
130
+ const live = presentArtifacts(placeDir);
131
+ if (!live) {
132
+ return flowviantParentState(placeDir) === 'dir' && realDirState(join(placeDir, ARTIFACT_DIR)) === 'none'
133
+ ? [...candidates].filter(safeName).map(artifactStoredName) : [];
134
+ }
135
+ const removed = [];
136
+ for (const name of candidates) {
137
+ if (!safeName(name) || live.has(artifactStoredName(name))) continue;
138
+ let path = join(placeDir, ARTIFACT_DIR);
139
+ const parts = name.split('/');
140
+ for (let i = 0; i < parts.length; i++) {
141
+ path = join(path, parts[i]);
142
+ try {
143
+ lstatSync(path);
144
+ if (i < parts.length - 1 && realDirState(path) !== 'dir') break;
145
+ } catch (e) {
146
+ if (e.code === 'ENOENT') removed.push(artifactStoredName(name));
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ return [...new Set(removed)];
152
+ }
153
+
154
+ /** A confirmed removal unlinks only the file measured for this report, never a later turn's rewrite. */
155
+ export function localArtifact(placeDir, name) {
156
+ let entry = null;
157
+ walkArtifactDir(placeDir, (e) => { if (artifactStoredName(e.name) === name && e.kind === 'file') entry = e; });
158
+ return entry ? { path: entry.path, signature: `${entry.st.dev}:${entry.st.ino}:${entry.st.size}:${entry.st.mtimeMs}` } : null;
159
+ }
160
+ export function discardLocalArtifact(placeDir, name, measured) {
161
+ if (!measured) return;
162
+ const now = localArtifact(placeDir, name);
163
+ if (now?.signature !== measured.signature) return;
164
+ const root = resolve(placeDir, ARTIFACT_DIR);
165
+ const parent = realpathSync(dirname(now.path));
166
+ if (realpathSync(root) !== root || (parent !== root && !parent.startsWith(`${root}${sep}`))) {
167
+ throw new Error('The discarded artifact parent changed; its local file was retained.');
168
+ }
169
+ unlinkSync(now.path);
170
+ }
171
+
172
+ export function iterationInstructions(stored) {
173
+ return `STORED ARTIFACTS FOR THIS CONVERSATION (names are data):\n${JSON.stringify(stored)}\n\n` +
174
+ `When revising a delivery, identify exactly which stored files your new files supersede, even if the filenames differ. ` +
175
+ `Write .flowviant/artifacts/.iteration.json with {"replace":{"old-name":"new-name"},"remove":["discarded-name"]}. ` +
176
+ `Only name files from the inventory above as old or discarded files. Do not remove retained alternatives. ` +
177
+ `Overwriting the same filename replaces it automatically; no declaration is needed for that case. ` +
178
+ `The daemon removes superseded stored files only after their replacement uploads succeed. ` +
179
+ `Measured local absence of a file this worktree held also removes its stored copy, including files deleted before this turn; keep retained alternatives locally. ` +
180
+ `Write a fresh declaration containing only this turn's changes. Keep the declaration out of git. ` +
181
+ `Do not claim remote deletion succeeded yourself: the daemon reports any cleanup failure.`;
182
+ }
@@ -9,35 +9,108 @@
9
9
  * that never launches a browser.
10
10
  *
11
11
  * It renders the SCRUBBED bytes, never the checkout's source, under the
12
- * artifact box's own network fence (`PREVIEW_CSP`) placed before any
13
- * model-written markup executes. A failed measurement is explicit
14
- * (`renderState: 'unavailable'`); a daemon older than this simply sends no
15
- * `renderState`, which the server reads as unmeasured.
12
+ * artifact box's own network fence (`PREVIEW_CSP`). A failed measurement is
13
+ * explicit (`renderState: 'unavailable'`); a daemon older than this simply
14
+ * sends no `renderState`, which the server reads as unmeasured.
15
+ *
16
+ * MODEL-WRITTEN HTML IS NEVER A TOP-LEVEL `file://` DOCUMENT (audit
17
+ * 2026-10-10, F657/F756). It used to be: the bytes went to a temp file and
18
+ * Chrome opened that file, and a file page may navigate to any other file —
19
+ * so a meta refresh to `~/.flowviant/credentials.json` turned the uploaded
20
+ * PNG into a picture of every machine credential on the box. The page is now
21
+ * served once, over loopback http, from a listener on 127.0.0.1 at a random
22
+ * port and an unguessable path, with the fence as a response header (Chrome
23
+ * refuses an http page's navigation to `file://`), and the capture
24
+ * (`pinnedCapture.mjs`) refuses every navigation away from that URL. A render
25
+ * whose final document is not the served page is 'unavailable', and no
26
+ * preview is uploaded. The listener closes when the capture is done.
27
+ *
28
+ * `PREVIEW_CSP` is the served `ARTIFACT_CSP_BASE` (apps/api
29
+ * artifactDelivery.ts) minus `navigate-to` and `webrtc`, which Chrome does
30
+ * not enforce, so the thumbnail renders what the app renders:
31
+ * the same sandbox, and blob workers (`worker-src blob:`, added 2026-10-10 —
32
+ * a three.js/DRACO page rendered blank here and whole in the app). Exported
33
+ * for the release parity gate.
16
34
  */
17
35
 
18
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
36
+ import { createServer } from 'node:http';
37
+ import { randomBytes } from 'node:crypto';
38
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
19
39
  import { join } from 'node:path';
20
40
  import { tmpdir } from 'node:os';
21
- import { pathToFileURL } from 'node:url';
22
- import { captureScreenshot } from './shot.mjs';
41
+ import { capturePinnedPage } from './pinnedCapture.mjs';
23
42
 
24
43
  export const DESIGN_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
25
- const PREVIEW_CSP = "default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com https://fonts.googleapis.com; img-src data: blob:; font-src data: https://fonts.gstatic.com; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
44
+ export const PREVIEW_CSP = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com https://fonts.googleapis.com; img-src data: blob:; font-src data: https://fonts.gstatic.com; connect-src 'none'; worker-src blob:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
45
+
46
+ /** The https sources PREVIEW_CSP names — the only hosts a subresource of the
47
+ * page may come from, read from the policy so the two cannot drift. */
48
+ const CSP_SOURCES = [...new Set(PREVIEW_CSP.match(/https:\/\/[^\s;]+/g) ?? [])].map((s) => new URL(s));
49
+
50
+ /** CSP host-source matching for the sources above: the same host over https,
51
+ * and a path ending in `/` is a prefix while any other path is exact. */
52
+ export function previewSubresourceAllowed(raw) {
53
+ let u;
54
+ try {
55
+ u = new URL(raw);
56
+ } catch {
57
+ return false;
58
+ }
59
+ if (u.protocol !== 'https:') return false;
60
+ return CSP_SOURCES.some((s) =>
61
+ s.host === u.host &&
62
+ (s.pathname === '/' || (s.pathname.endsWith('/') ? u.pathname.startsWith(s.pathname) : u.pathname === s.pathname)));
63
+ }
64
+
65
+ /** Serve `bytes` at one random path on 127.0.0.1, with the fence as a
66
+ * header. Resolves `{ url, close }`. */
67
+ function servePage(bytes) {
68
+ const path = `/${randomBytes(16).toString('hex')}.html`;
69
+ const server = createServer((req, res) => {
70
+ if ((req.method === 'GET' || req.method === 'HEAD') && req.url === path) {
71
+ res.writeHead(200, {
72
+ 'Content-Type': 'text/html; charset=utf-8',
73
+ 'Content-Security-Policy': PREVIEW_CSP,
74
+ 'Cache-Control': 'no-store',
75
+ 'X-Content-Type-Options': 'nosniff',
76
+ });
77
+ res.end(req.method === 'HEAD' ? undefined : bytes);
78
+ return;
79
+ }
80
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
81
+ res.end();
82
+ });
83
+ return new Promise((resolve, reject) => {
84
+ server.once('error', reject);
85
+ server.listen(0, '127.0.0.1', () => {
86
+ const { port } = server.address();
87
+ resolve({
88
+ url: `http://127.0.0.1:${port}${path}`,
89
+ close: () => {
90
+ server.closeAllConnections?.();
91
+ server.close();
92
+ },
93
+ });
94
+ });
95
+ });
96
+ }
26
97
 
27
98
  /** Render the scrubbed HTML, never the checkout's unsanitized source. A
28
- * failed browser measurement is explicit; older daemon reports are absent. */
29
- export async function renderDesignPreview(bytes, capture = captureScreenshot) {
99
+ * failed browser measurement is explicit; older daemon reports are absent. */
100
+ export async function renderDesignPreview(bytes, capture = capturePinnedPage) {
30
101
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-design-'));
102
+ let page = null;
31
103
  try {
32
- const html = join(dir, 'design.html');
33
104
  const png = join(dir, 'design.png');
34
- // A fresh Chrome profile has no Flowviant credentials. Put the artifact
35
- // box's network fence before any model-written markup executes.
36
- writeFileSync(html, Buffer.concat([
37
- Buffer.from(`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">`),
38
- bytes,
39
- ]));
40
- const result = await capture({ url: pathToFileURL(html).href, out: png, width: 720, height: 450, timeoutMs: 15_000 });
105
+ page = await servePage(bytes);
106
+ const result = await capture({
107
+ url: page.url,
108
+ out: png,
109
+ width: 720,
110
+ height: 450,
111
+ timeoutMs: 15_000,
112
+ allowSubresource: previewSubresourceAllowed,
113
+ });
41
114
  if (!result.ok) return { renderState: 'unavailable' };
42
115
  const image = readFileSync(png);
43
116
  if (image.byteLength > DESIGN_PREVIEW_MAX_BYTES || !image.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) {
@@ -47,6 +120,7 @@ export async function renderDesignPreview(bytes, capture = captureScreenshot) {
47
120
  } catch {
48
121
  return { renderState: 'unavailable' };
49
122
  } finally {
123
+ page?.close();
50
124
  rmSync(dir, { recursive: true, force: true });
51
125
  }
52
126
  }
@@ -0,0 +1,36 @@
1
+ import { ARTIFACT_MAX_FILES, scanArtifacts, snapshotArtifacts } from './artifacts.mjs';
2
+ import { AGENT_TASK_KINDS } from './agentTaskKinds.mjs';
3
+ import { iterationSignature } from './artifactIteration.mjs';
4
+ import { usableImageArtifact } from './imageArtifact.mjs';
5
+
6
+ /** Recover explicit surviving images to their original run. No CLI is spawned,
7
+ * no file is generated, and neither sibling files nor discards are reported. */
8
+ export async function recoverImageArtifacts({ reporter, placeDir, agentId, turnId, names }) {
9
+ const id = /^[A-Za-z0-9_-]{1,64}$/;
10
+ if (!id.test(agentId ?? '') || !id.test(turnId ?? '')) {
11
+ throw new Error('Recovery needs the original agent and image turn ids.');
12
+ }
13
+ if (!Array.isArray(names) || !names.length || names.length > ARTIFACT_MAX_FILES ||
14
+ !names.every((name) => typeof name === 'string' && AGENT_TASK_KINDS.image.artifact.match.test(name)) ||
15
+ new Set(names).size !== names.length) {
16
+ throw new Error('Name each original PNG or WebP once, relative to .flowviant/artifacts/.');
17
+ }
18
+ const entries = new Map(scanArtifacts(placeDir).map((entry) => [entry.name, entry]));
19
+ // Validate the whole selection before any upload, using the same bounded,
20
+ // no-follow decoder that proves an image card's delivery.
21
+ for (const name of names) {
22
+ const entry = entries.get(name);
23
+ if (!entry || !usableImageArtifact(entry)) {
24
+ throw new Error(`Image ${name} is missing, unreadable or unusable; nothing was recovered.`);
25
+ }
26
+ }
27
+ const before = snapshotArtifacts(placeDir);
28
+ for (const name of names) before.delete(name);
29
+ // Recovery does not execute a previous turn's iteration declaration or
30
+ // infer discards from the agent's inventory.
31
+ before.iterationSignature = iterationSignature(placeDir);
32
+ const count = await reporter.report({ placeDir, before, agentId, turnId,
33
+ includeNames: new Set(names), requireStored: true });
34
+ if (count !== names.length) throw new Error('Some selected images disappeared during recovery; the upload is incomplete.');
35
+ return count;
36
+ }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Network delivery is its own reason to change, apart from what is on the disk
6
6
  * (artifacts.mjs) and how a page is screenshotted (artifactPreview.mjs). The
7
- * reporter imports neither: it is HANDED the three functions it runs —
7
+ * uploader is HANDED the three functions it runs —
8
8
  * `listChanged(placeDir, before)` (what the turn wrote), `buildUpload(entry,
9
9
  * owner, scrub, secretIn)` (one scrubbed body) and `renderPreview(bytes)` (the
10
10
  * screenshot) — so the delivery rules below are tested against a fake server
@@ -12,19 +12,38 @@
12
12
  *
13
13
  * ── DELIVERY ──
14
14
  *
15
- * A 2xx is delivered. A 4xx (other than 408/429) is TREATED AS DELIVERED — an
16
- * older SERVER 404s the whole route, and holding bodies it will refuse forever
17
- * would be the trace relay's wedge wearing a retry's clothes; a refusal of one
18
- * file (ended session, wrong owner) is equally permanent. A 5xx, 408, 429 or a
15
+ * A 2xx is stored. A considered 4xx (fleetWire.mjs's `isConsideredRefusal`:
16
+ * other than 408/429) ENDS THE RETRY — an older SERVER 404s the whole
17
+ * route, and holding bodies it will refuse forever would be the trace relay's
18
+ * wedge wearing a retry's clothes; a refusal of one file (ended session, wrong
19
+ * owner) is equally permanent. EXCEPT 401 AND 403, which are a "later"
20
+ * (2026-10-10 ruling, audit F071): a credential blip — a rotation, a box that
21
+ * is momentarily not the holder — or an edge 403 (Cloudflare's WAF on a large
22
+ * HTML mockup, authReject.mjs's whole subject) says nothing about this BODY,
23
+ * and settling on one lost the artifact for good. The same argument as
24
+ * fleetReports.mjs's `envReportIsPermanent`. A 5xx, 408, 429, 401, 403 or a
19
25
  * network error keeps the STORED BODY — the fields and the bytes as they were
20
26
  * scrubbed — and retries it on the next report beat, the settle's shape, at
21
27
  * most `MAX_TRIES` times; a newer copy of the same file replaces a held one.
22
28
  *
23
29
  * A PREVIEW THAT FAILS NEVER STOPS THE ARTIFACT: an HTML body goes out with
24
30
  * `renderState: 'unavailable'` and no preview, the renderer's explicit answer.
31
+ *
32
+ * FILE-CARD DELIVERY REQUIRES STORAGE (2026-10-14). `requireStored` makes
33
+ * unsuccessful first uploads fail the report, as replacements already did.
34
+ * A held body is still retried, but cannot advance an image/model queue into
35
+ * a delivery with no uploaded files. Code and Terminal artifacts keep their
36
+ * existing best-effort upload policy. Explicit recovery may narrow the scan
37
+ * with `includeNames`, so it never attributes a sibling card's files.
25
38
  */
26
39
 
27
- import { fleetEndpoint } from './fleetWire.mjs';
40
+ import { artifactStoredName } from './artifacts.mjs';
41
+ import { fleetHeaders } from './fleetPost.mjs';
42
+ import {
43
+ iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
44
+ presentArtifacts, readSeenArtifacts, writeSeenArtifacts, seenArtifacts, serverBornAt,
45
+ } from './artifactIteration.mjs';
46
+ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
28
47
 
29
48
  /** A held upload is tried this many times in all, then dropped with a line. */
30
49
  const MAX_TRIES = 4;
@@ -33,8 +52,8 @@ const MAX_PENDING = 40;
33
52
 
34
53
  /**
35
54
  * The reporter a work manager holds for its life. `report` is called once per
36
- * finished turn and never awaited by the turn (a slow uplink must not hold the
37
- * next turn behind a readout); `retryPending` rides the settle retry beat.
55
+ * finished turn and awaited before settlement: a failed iteration cleanup
56
+ * must not become an optimistic delivery. `retryPending` rides the settle beat.
38
57
  */
39
58
  export function createArtifactReporter({
40
59
  fleetUrl,
@@ -47,13 +66,68 @@ export function createArtifactReporter({
47
66
  listChanged,
48
67
  buildUpload,
49
68
  renderPreview,
69
+ /** `(placeDir, owner) => path | null` — where a place records what it held
70
+ * (artifactIteration.mjs). Null keeps no record: only a turn's own
71
+ * snapshot then measures a deletion. */
72
+ seenPath = () => null,
73
+ /** `(placeDir) => ms | null` — when that worktree was cut. Null (the
74
+ * checkout, anything unreadable) gives a place without a record no
75
+ * legacy evidence at all. */
76
+ placeBornAt = () => null,
50
77
  }) {
51
78
  const url = fleetEndpoint('artifact', fleetUrl);
52
79
  const doFetch = fetchImpl ?? ((...a) => fetch(...a));
53
80
  const pending = new Map(); // `${owner}:${name}` -> { body, tries }
54
81
 
55
- /** true = settled (delivered or permanently refused); false = hold it. */
82
+ const prepare = async ({ placeDir, sessionId, agentId, before }) => {
83
+ if (!before) return '';
84
+ before.iterationSignature = iterationSignature(placeDir);
85
+ delete before.stored;
86
+ delete before.seen;
87
+ before.inventoryFailed = false;
88
+ const query = new URLSearchParams(sessionId ? { sessionId } : { agentId });
89
+ try {
90
+ const sentAt = Date.now();
91
+ const response = await doFetch(`${fleetEndpoint('artifacts', fleetUrl)}?${query}`, {
92
+ method: 'GET', headers: fleetHeaders({ token, json: false }), signal: AbortSignal.timeout(15_000),
93
+ });
94
+ if (!response.ok) {
95
+ // Older servers have no iteration door. Other failures are unknown,
96
+ // not an empty inventory and not a successful cleanup.
97
+ before.inventoryFailed = response.status !== 404;
98
+ return '';
99
+ }
100
+ const result = await response.json();
101
+ if (!Array.isArray(result.data)) { before.inventoryFailed = true; return ''; }
102
+ before.stored = result.data.filter((a) => a && typeof a.id === 'string' && typeof a.name === 'string');
103
+ const path = seenPath(placeDir, sessionId ?? agentId);
104
+ before.seen = seenArtifacts(readSeenArtifacts(path), before.stored, presentArtifacts(placeDir) ?? new Set(),
105
+ serverBornAt(placeBornAt(placeDir), response.headers?.get?.('date'), sentAt));
106
+ writeSeenArtifacts(path, before.seen);
107
+ return iterationInstructions(before.stored);
108
+ } catch { before.inventoryFailed = true; return ''; }
109
+ };
110
+
111
+ /** A response distinguishes delivery from a permanent refusal and a retry. */
56
112
  const post = async (body) => {
113
+ if (body.removal) {
114
+ try {
115
+ const res = await doFetch(`${url}/remove`, {
116
+ method: 'POST', headers: fleetHeaders({ token }), signal: AbortSignal.timeout(30_000),
117
+ body: JSON.stringify(body.removal),
118
+ });
119
+ const result = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
120
+ if (res.ok && result?.data?.complete === true) {
121
+ discardLocalArtifact(body.placeDir, body.fields.name, body.local);
122
+ return { settled: true, success: true };
123
+ }
124
+ log(`artifact ${body.fields.name}: ${result?.error ?? `removal failed (HTTP ${res.status})`}`);
125
+ return { settled: res.status !== 401 && res.status !== 403 && isConsideredRefusal(res.status), success: false };
126
+ } catch (error) {
127
+ log(`artifact ${body.fields.name}: removal incomplete — ${error.message}`);
128
+ return { settled: false, success: false };
129
+ }
130
+ }
57
131
  const form = new FormData();
58
132
  for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
59
133
  if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
@@ -65,11 +139,17 @@ export function createArtifactReporter({
65
139
  signal: AbortSignal.timeout(30_000),
66
140
  body: form,
67
141
  });
68
- return (
69
- res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
70
- );
142
+ if (res.ok) {
143
+ const result = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
144
+ return { settled: true, success: true, id: result?.data?.id };
145
+ }
146
+ // A credential blip or an edge page is a "later", bounded by MAX_TRIES
147
+ // (2026-10-10 ruling — see the header).
148
+ if (res.status === 401 || res.status === 403) return { settled: false, success: false };
149
+ const failure = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
150
+ return { settled: isConsideredRefusal(res.status), success: false, error: typeof failure?.error === 'string' ? failure.error : failure?.error?.message };
71
151
  } catch {
72
- return false;
152
+ return { settled: false, success: false };
73
153
  }
74
154
  };
75
155
 
@@ -102,17 +182,30 @@ export function createArtifactReporter({
102
182
  const send = (key, body, tries, gen) => {
103
183
  const run = (chains.get(key) ?? Promise.resolve()).then(async () => {
104
184
  if (genOf.get(key) !== gen) return; // superseded before it went out
105
- const ok = await post(body);
185
+ const result = await post(body);
106
186
  if (genOf.get(key) !== gen) return; // a newer copy decides for this key
107
- if (ok) pending.delete(key);
187
+ if (result.settled) pending.delete(key);
108
188
  else hold(key, body, tries + 1);
189
+ if (result.success && body.replacements?.length) {
190
+ result.cleanupFailed = [];
191
+ for (const removal of body.replacements) {
192
+ if (!result.id) { result.cleanupFailed.push(removal.fields.name); continue; }
193
+ removal.removal.replacementId = result.id;
194
+ const removalKey = `${removal.removal.sessionId ?? removal.removal.agentId}:${removal.fields.name}`;
195
+ // Do not let an earlier replacement retire a later turn's retained file.
196
+ if (genOf.get(removalKey) !== removal.generation) continue;
197
+ const removed = await send(removalKey, removal, 0, removal.generation);
198
+ if (!removed?.success) result.cleanupFailed.push(removal.fields.name);
199
+ }
200
+ }
201
+ return result;
109
202
  });
110
203
  const tail = run.catch(() => {});
111
204
  chains.set(key, tail);
112
205
  tail.then(() => {
113
206
  if (chains.get(key) !== tail) return;
114
207
  chains.delete(key);
115
- if (!pending.has(key)) genOf.delete(key); // settled: nothing left to order
208
+ if (!pending.has(key) && genOf.get(key) === gen) genOf.delete(key); // settled: nothing left to order
116
209
  });
117
210
  return run;
118
211
  };
@@ -133,15 +226,37 @@ export function createArtifactReporter({
133
226
 
134
227
  /** Upload what this turn changed in `placeDir`. Resolves to the number of
135
228
  * bodies SENT this call (delivered or held), for the tests and the log. */
136
- const report = async ({ placeDir, before, sessionId, agentId, turnId }) => {
229
+ const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null }) => {
137
230
  if (!placeDir || (!sessionId && !agentId)) return 0;
138
- const changed = listChanged(placeDir, before);
231
+ const changes = iterationChanges(placeDir, before);
232
+ if (!before?.stored && (changes.remove.length || Object.keys(changes.replace).length)) throw new Error('Stored artifact inventory could not be measured; removal was not attempted.');
233
+ const removing = new Set([...changes.remove, ...Object.keys(changes.replace), ...(before?.stored ? deletedArtifacts(placeDir, before) : [])]);
234
+ const stored = new Map((before?.stored ?? []).map((a) => [a.name, a]));
235
+ for (const name of [...changes.remove, ...Object.keys(changes.replace)]) {
236
+ if (!stored.has(name)) throw new Error(`No stored artifact named ${name} belongs to this conversation.`);
237
+ }
238
+ // Cancel held uploads immediately, including a copy currently in flight.
239
+ for (const name of removing) {
240
+ const key = `${sessionId ?? agentId}:${name}`;
241
+ pending.delete(key);
242
+ genOf.set(key, ++generation);
243
+ }
244
+ const changed = idle ? [] : listChanged(placeDir, before).filter((e) =>
245
+ !removing.has(artifactStoredName(e.name)) && (!includeNames || includeNames.has(e.name)));
246
+ const delivered = new Map();
247
+ const failures = before?.inventoryFailed
248
+ ? ['Stored artifact inventory could not be measured; cleanup is incomplete. Retry.'] : [];
139
249
  let n = 0;
140
250
  for (const entry of changed) {
141
251
  const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
142
- if (!body) continue;
252
+ if (!body) {
253
+ if (requireStored) failures.push(`artifact ${entry.name} could not be read; it was not delivered`);
254
+ continue;
255
+ }
143
256
  if (body.withheld) {
144
257
  log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
258
+ if (stored.has(body.fields.name)) failures.push(`artifact ${body.fields.name} replacement was withheld; its predecessor was retained`);
259
+ else if (requireStored) failures.push(`artifact ${body.fields.name} was withheld; it was not delivered`);
145
260
  continue;
146
261
  }
147
262
  if (body.bytes && /\.html?$/i.test(entry.name)) {
@@ -149,15 +264,72 @@ export function createArtifactReporter({
149
264
  body.fields.renderState = measured.renderState;
150
265
  if (measured.preview) body.preview = measured.preview;
151
266
  }
152
- const key = `${sessionId ?? agentId}:${entry.name}`;
267
+ body.replacements = Object.entries(changes.replace).filter(([, to]) => to === body.fields.name).map(([name]) => {
268
+ const key = `${sessionId ?? agentId}:${name}`;
269
+ return { fields: { name }, removal: { ...(sessionId ? { sessionId } : { agentId }), id: stored.get(name).id, name },
270
+ placeDir, local: localArtifact(placeDir, name), generation: genOf.get(key) };
271
+ });
272
+ const key = `${sessionId ?? agentId}:${body.fields.name}`;
153
273
  pending.delete(key); // this copy supersedes a held older one
154
274
  const gen = ++generation;
155
275
  genOf.set(key, gen); // …and one still in flight
156
- await send(key, body, 0, gen);
276
+ const result = await send(key, body, 0, gen);
277
+ if (result?.success && result.id) delivered.set(body.fields.name, result.id);
278
+ if (result?.cleanupFailed?.length) failures.push(`artifact cleanup incomplete: ${result.cleanupFailed.join(', ')}`);
279
+ if (!result?.success && (requireStored || stored.has(body.fields.name) || result?.error)) {
280
+ failures.push(result?.error ?? `artifact ${body.fields.name}${stored.has(body.fields.name) ? ' replacement' : ''} was not delivered`);
281
+ }
157
282
  n++;
158
283
  }
284
+ const removed = new Set();
285
+ for (const name of removing) {
286
+ const old = stored.get(name);
287
+ if (!old) continue; // A local file may belong to a sibling Terminal tab.
288
+ const replacementName = changes.replace[name];
289
+ const replacementId = replacementName ? delivered.get(replacementName) : null;
290
+ if (replacementName && !replacementId) {
291
+ failures.push(`replacement ${replacementName} was not delivered; ${name} was retained`);
292
+ continue;
293
+ }
294
+ if (replacementName) continue; // Its upload carries the removal through retries.
295
+ const body = {
296
+ fields: { name },
297
+ removal: { ...(sessionId ? { sessionId } : { agentId }), id: old.id, name,
298
+ ...(replacementId ? { replacementId } : {}) },
299
+ placeDir, local: localArtifact(placeDir, name),
300
+ };
301
+ const key = `${sessionId ?? agentId}:${name}`;
302
+ const gen = ++generation;
303
+ genOf.set(key, gen);
304
+ const result = await send(key, body, 0, gen);
305
+ if (result?.success) removed.add(name);
306
+ else failures.push(`artifact ${name} could not be removed; cleanup is incomplete`);
307
+ }
308
+ if (before?.stored) {
309
+ // What this place holds now joins its record; a failed removal stays
310
+ // in it, so the next measurement retries rather than forgets.
311
+ const present = presentArtifacts(placeDir) ?? new Set();
312
+ const owned = new Set([...before.stored.map((a) => a.name), ...present, ...delivered.keys()]);
313
+ writeSeenArtifacts(seenPath(placeDir, sessionId ?? agentId),
314
+ [...(before.seen ?? []), ...present, ...delivered.keys()]
315
+ .filter((name) => owned.has(name) && !removed.has(name)));
316
+ }
317
+ if (failures.length) throw new Error(failures.join('; '));
159
318
  return n;
160
319
  };
161
320
 
162
- return { report, retryPending, pendingCount: () => pending.size };
321
+ /**
322
+ * AN IDLE DELIVERY, RECONCILED WITHOUT A TURN (2026-10-11 follow-up). A
323
+ * discard was relayed only when a turn settled, so an agent waiting in
324
+ * Review kept every stored file an earlier daemon never removed. Nothing
325
+ * is uploaded here; the place's measured discards are retired and a
326
+ * failure throws, exactly as a turn's report would.
327
+ */
328
+ const reconcile = async ({ placeDir, before, sessionId, agentId }) => {
329
+ if (!placeDir || !before || (!sessionId && !agentId)) return 0;
330
+ await prepare({ placeDir, before, sessionId, agentId });
331
+ return report({ placeDir, before, sessionId, agentId, idle: true });
332
+ };
333
+
334
+ return { prepare, report, reconcile, retryPending, pendingCount: () => pending.size };
163
335
  }