flowviant 0.125.18 → 0.125.21

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 (48) hide show
  1. package/README.md +11 -1
  2. package/bin/cli.mjs +13 -0
  3. package/bin/lib/artifactCache.mjs +336 -0
  4. package/bin/lib/artifactContracts.mjs +87 -2
  5. package/bin/lib/artifactIteration.mjs +3 -2
  6. package/bin/lib/artifactPolicy.mjs +5 -1
  7. package/bin/lib/artifactReporter.mjs +91 -9
  8. package/bin/lib/artifacts.mjs +12 -6
  9. package/bin/lib/claudeStream.mjs +7 -0
  10. package/bin/lib/codexFencedMount.mjs +2 -2
  11. package/bin/lib/fleet.mjs +9 -0
  12. package/bin/lib/intermediateArtifacts.mjs +40 -8
  13. package/bin/lib/knowledgeLibrary.mjs +2 -1
  14. package/bin/lib/localCleanupReport.mjs +34 -0
  15. package/bin/lib/localCleanupState.mjs +68 -0
  16. package/bin/lib/logReplay.mjs +6 -0
  17. package/bin/lib/managedLocalCleanup.mjs +158 -0
  18. package/bin/lib/pinnedCapture.mjs +19 -8
  19. package/bin/lib/postureTools.mjs +2 -2
  20. package/bin/lib/publicReferenceBrowser.mjs +180 -0
  21. package/bin/lib/publicReferenceNetwork.mjs +131 -0
  22. package/bin/lib/publicReferenceProtocol.mjs +55 -0
  23. package/bin/lib/publicReferenceRuntime.mjs +65 -0
  24. package/bin/lib/publicReferenceTool.mjs +89 -0
  25. package/bin/lib/referenceCaptureProtocol.mjs +12 -0
  26. package/bin/lib/referenceCaptures.mjs +140 -0
  27. package/bin/lib/resultInputs.mjs +4 -2
  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/uiReview.mjs +305 -0
  32. package/bin/lib/uiReviewBrowser.mjs +212 -0
  33. package/bin/lib/uiReviewContract.mjs +51 -0
  34. package/bin/lib/uiReviewTurn.mjs +15 -0
  35. package/bin/lib/websiteAccess.mjs +68 -0
  36. package/bin/lib/websiteTrace.mjs +36 -0
  37. package/bin/lib/work.mjs +46 -3
  38. package/bin/lib/workAgentPlans.mjs +3 -0
  39. package/bin/lib/workAgentPrecheck.mjs +16 -15
  40. package/bin/lib/workAgentReview.mjs +2 -2
  41. package/bin/lib/workAgentTools.mjs +3 -2
  42. package/bin/lib/workAgentTurnExecution.mjs +69 -4
  43. package/bin/lib/workAgentTurns.mjs +3 -1
  44. package/bin/lib/workLocalCleanup.mjs +125 -0
  45. package/bin/lib/workMergeBatch.mjs +19 -4
  46. package/bin/lib/workSessionGroups.mjs +20 -1
  47. package/bin/lib/workToolLog.mjs +24 -3
  48. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ /** Bounded website metadata only. Neither assistant prose nor page bodies are
2
+ * evidence of a loaded page. Browser integrations supply explicit observations
3
+ * through result metadata, never by embedding claims in a tool input. */
4
+ import { scrubCut } from './uplinkScrub.mjs';
5
+ import { REFERENCE_DIAGNOSTICS } from './publicReferenceProtocol.mjs';
6
+
7
+ export function websiteUrl(value, scrub = s => s) {
8
+ if (typeof value !== 'string' || value.length > 8_192) return undefined;
9
+ try {
10
+ // Scrub before URL serialization can escape a known secret in its path.
11
+ const url = new URL(scrub(value));
12
+ if (!['http:', 'https:'].includes(url.protocol)) return undefined;
13
+ url.username = ''; url.password = ''; url.search = ''; url.hash = '';
14
+ return scrub(url.href).slice(0, 500);
15
+ } catch { return undefined; }
16
+ }
17
+
18
+ export function websiteRequest(name, input, scrub) {
19
+ const method = name === 'WebSearch' ? 'search' : name === 'WebFetch' ? 'fetch' :
20
+ name === 'WebsiteAccess' ? (['search', 'fetch', 'browser', 'tool', 'capability'].includes(input.method) ? input.method : 'tool') :
21
+ /^(?:mcp__|[^.]+\.)/.test(name ?? '') && websiteUrl(input?.url) ? 'tool' : null;
22
+ if (!method) return null;
23
+ const requestedUrl = websiteUrl(input?.url, scrub);
24
+ return {
25
+ method, tool: scrubCut(name === 'WebsiteAccess' ? input.tool ?? 'website' : name, 120, scrub),
26
+ ...(requestedUrl ? { requestedUrl } : {}), access: 'unknown',
27
+ };
28
+ }
29
+
30
+ /** Only fixed, recognized runtime errors can leave the machine. Never retain
31
+ * arbitrary tool output, even when is_error is set by the native runtime. */
32
+ export function observeWebsite(e, meta, scrub) {
33
+ const w = e.website;
34
+ const output = scrubCut(meta.output ?? '', 16_384, scrub);
35
+ const lines = output.split('\n', 80);
36
+ const diagnostic = lines.find(line => Object.values(REFERENCE_DIAGNOSTICS).includes(line) || /^(?:Error: )?(?:MCP tool call requires approval, but approval policy is never|Tool (?:WebFetch|WebSearch) is not available|Browser executable not found|Browser sandbox unavailable|Request timed out|fetch failed|Network request failed|Not accessible via this tool)\.?$/i.test(line) || line.startsWith('flowviant: this turn is read-only, so '));
37
+ const failed = meta.isError === true || (Number.isSafeInteger(meta.exitCode) && meta.exitCode !== 0);
38
+ w.access = failed ? 'failed' : 'unknown';
39
+ if (failed && diagnostic) {
40
+ e.errorMessage = diagnostic.slice(0, 500);
41
+ if (diagnostic.length > 500) e.errorTruncated = true;
42
+ if (/^flowviant:/.test(diagnostic)) {
43
+ w.access = 'permission_denied'; w.permissionSource = 'flowviant';
44
+ } else if (/requires approval/.test(diagnostic)) {
45
+ w.access = 'permission_denied'; w.permissionSource = 'runtime';
46
+ } else if (/not available|not found|sandbox unavailable/i.test(diagnostic)) w.access = 'unavailable';
47
+ }
48
+ // Explicit daemon-owned integration result, not a tool-input or prose field.
49
+ // This is the emission seam used by the isolated browser/capture consumers.
50
+ const observed = meta.website;
51
+ if (meta.result) w.observedAt = new Date().toISOString();
52
+ if (meta.result && observed && typeof observed === 'object') {
53
+ if (['unknown', 'loaded', 'search_results', 'failed', 'unavailable', 'permission_denied', 'challenge', 'partial'].includes(observed.access)) w.access = observed.access;
54
+ const finalUrl = websiteUrl(observed.finalUrl, scrub);
55
+ if (finalUrl) w.finalUrl = finalUrl;
56
+ if (Number.isInteger(observed.httpStatus) && observed.httpStatus >= 100 && observed.httpStatus <= 599) w.httpStatus = observed.httpStatus;
57
+ if (['flowviant', 'runtime', 'unknown'].includes(observed.permissionSource)) w.permissionSource = observed.permissionSource;
58
+ if (['advertised', 'unavailable', 'unknown'].includes(observed.availability)) w.availability = observed.availability;
59
+ if (typeof observed.observedAt === 'string') w.observedAt = scrubCut(observed.observedAt, 40, scrub);
60
+ }
61
+ // Search completion is evidence of a search, never of opening its hits.
62
+ if (w.method === 'search' && w.access === 'unknown' && !failed && meta.result && meta.toolSuccess === true) w.access = 'search_results';
63
+ if (w.method === 'search' && w.access === 'loaded') w.access = 'unknown';
64
+ if (!meta.result) w.access = 'unknown';
65
+ e.outcome = !meta.result ? 'unknown' : ['failed', 'unavailable', 'permission_denied'].includes(w.access) ? 'failed' :
66
+ failed ? 'failed' : meta.toolSuccess === true || ['loaded', 'search_results', 'challenge', 'partial'].includes(w.access) ? 'success' : 'unknown';
67
+ if (e.outcome === 'failed') e.errorCategory = w.access === 'permission_denied' ? 'denied' : w.access === 'unavailable' ? 'unavailable_tool' : 'tool_error';
68
+ }
@@ -0,0 +1,36 @@
1
+ import { createToolLog } from './workToolLog.mjs';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ /** Reuse the turn collector for agent research too. Trace entries are snapshots:
5
+ * later results must not mutate a request already queued or sent. */
6
+ export function createWebsiteTrace(cwd, trace, attributed) {
7
+ const { pushToolEvent } = createToolLog(cwd);
8
+ const inventory = new Map();
9
+ const emit = (event, owner) => {
10
+ const send = id => trace.tool(event, id);
11
+ if (owner && attributed) attributed(owner, send); else send(owner);
12
+ };
13
+ return (name, input, meta = {}) => {
14
+ const e = pushToolEvent(name, input, meta);
15
+ if (!e?.website) return false;
16
+ const event = structuredClone(e);
17
+ if (event.id) {
18
+ // The trace scopes/caps ids. Hash the complete native identity first so
19
+ // long child prefixes cannot hide the call id at that boundary.
20
+ const id = createHash('sha256').update(JSON.stringify([meta.agentId ?? null, event.id])).digest('hex');
21
+ event.id = trace.agentId(`web:${id}`);
22
+ }
23
+ // Startup inventory belongs beside a reference attempt, not in every
24
+ // coding turn. An actual unavailable/denied capability result still emits
25
+ // immediately, even if no request can follow it.
26
+ if (event.website.method === 'capability' && !event.website.requestedUrl && event.website.access === 'unknown') {
27
+ inventory.set(event.website.tool, { event, owner: meta.agentId });
28
+ if (inventory.size > 12) inventory.delete(inventory.keys().next().value);
29
+ return true;
30
+ }
31
+ for (const pending of inventory.values()) emit(pending.event, pending.owner);
32
+ inventory.clear();
33
+ emit(event, meta.agentId);
34
+ return true;
35
+ };
36
+ }
package/bin/lib/work.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { reportLocalCleanup } from './localCleanupReport.mjs';
1
2
  /**
2
3
  * Work sessions — the Workbench tabs and the agent lanes, daemon side: THE
3
4
  * MANAGER that builds every lane and hands each one exactly what it uses.
@@ -27,6 +28,7 @@
27
28
  * whole — `workBusy` and the returned surface. A pure rule or constant
28
29
  * (REPO_PLACE, the resume-id guards, brainFor, the turn lock) is IMPORTED by
29
30
  * every lane that reads it, never carried through here.
31
+ * workLocalCleanup.mjs released managed session and agent cleanup
30
32
  * workPlaces.mjs where a session works, its worktree, its markers
31
33
  * workSessionGroups.mjs which process groups each tab started
32
34
  * workChildren.mjs the live CLI children, teardown, the admission
@@ -60,6 +62,10 @@
60
62
  * repositoryPlaces + agentRepositories: multi-repo places; the live brief: workAgentBrief, workAgentBriefRunner)
61
63
  */
62
64
 
65
+ import { dirname, join } from 'node:path';
66
+ import { placeTurnLockedByLivePid } from './turnLock.mjs';
67
+ import { createWorkLocalCleanup } from './workLocalCleanup.mjs';
68
+ import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
63
69
  import { trackedRepositorySetup } from './repositorySetup.mjs';
64
70
  import { createRepositoryPlaces } from './repositoryPlaces.mjs';
65
71
  import { createAgentRepositories } from './agentRepositories.mjs';
@@ -91,7 +97,7 @@ import { createWorkSessionRuntime } from './workSessionRuntime.mjs';
91
97
  import { createAdoptCarry } from './workAdoptCarry.mjs';
92
98
  import { createWorkSessionTurns } from './workSessionTurns.mjs';
93
99
  import { createWorkRetire } from './workRetire.mjs';
94
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
100
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, PROJECT_ID } from './config.mjs';
95
101
  import { gitNet as gitNetIn, excludeInWorktree } from './git.mjs';
96
102
  import { createProjectLandedObserver } from './projectLanded.mjs';
97
103
  import { createPlaceLock } from './placeLock.mjs';
@@ -110,6 +116,7 @@ export function createWorkManager({
110
116
  getBaseRef,
111
117
  getMcpUrl,
112
118
  getLeaseTtl, exportModel,
119
+ getCleanupPermission = () => false,
113
120
  /** "The repo picture changed — look again." See the caller in fleet.mjs. */
114
121
  onRepoChanged = () => {},
115
122
  /**
@@ -156,6 +163,7 @@ export function createWorkManager({
156
163
  */
157
164
  const artifacts = createArtifactReporter({
158
165
  fleetUrl: FLEET_URL,
166
+ projectIdentity: () => PROJECT_ID,
159
167
  token: FLEET_TOKEN,
160
168
  userAgent: USER_AGENT,
161
169
  scrub: envScrub,
@@ -211,7 +219,7 @@ export function createWorkManager({
211
219
 
212
220
  const { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt } =
213
221
  createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged });
214
- const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses } =
222
+ const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses, cleanupProcessesIdle } =
215
223
  createWorkSessionGroups({ repoRoot });
216
224
  const { workChildren, groupKillChildren, shutdownWork, liveTurnCount, liveTurns, admit } =
217
225
  createWorkChildren({ extraLiveTurns });
@@ -226,6 +234,39 @@ export function createWorkManager({
226
234
  getArtifactsAccepted,
227
235
  log: (line) => warn(line),
228
236
  });
237
+ const { reconcileLocalCleanup, cleanupBusy } = createWorkLocalCleanup({
238
+ placeLocks, inPlace, permission: getCleanupPermission,
239
+ onResult: reportLocalCleanup, onIncomplete: reportLocalCleanup,
240
+ eligible: place => {
241
+ if (shipping.size || cleanupProcessesIdle(place) !== true) return false;
242
+ const wt = placeDir(place), lock = wt && sessionMetaPath(wt, 'flowviant-turn.lock');
243
+ return Boolean(lock) && !placeTurnLockedByLivePid(lock);
244
+ },
245
+ targetsFor: place => {
246
+ if (!PROJECT_ID) return [];
247
+ const agentId = place.slice(2), siblings = repositoryPlaces?.entries(place);
248
+ const roots = siblings?.length ? siblings.filter(r => r.writable).map(r => r.wt) : [placeDir(place)];
249
+ return roots.flatMap(wt => {
250
+ const statePath = wt && sessionMetaPath(wt, 'flowviant-local-cleanup', agentId);
251
+ if (!statePath) return [];
252
+ return [{ placeDir: wt, agentId, projectId: PROJECT_ID, statePath,
253
+ operations: ({ guard, recoveryMemo, preparedOnly }) => {
254
+ const output = artifacts.cacheFor({ placeDir: wt, agentId, cleanupGuard: guard, recoveryMemo, preparedOnly });
255
+ if (!output) return [];
256
+ const managed = createManagedLocalCleanup({ placeDir: wt, projectId: PROJECT_ID, agentId,
257
+ path: sessionMetaPath(wt, 'flowviant-managed-local', agentId),
258
+ codexHome: join(dirname(statePath), `flowviant-agent-codex-home-${agentId}`),
259
+ outputCache: output, getUses: () => guard() ? [] : ['lifecycle-unknown-or-active'],
260
+ });
261
+ return [...output.records().map(ref => ({ ref: { ...ref, kind: 'artifact' },
262
+ prepare: () => output.prepareEviction(ref), run: () => output.evict(ref) })),
263
+ ...managed.records().map(ref => ({ ref,
264
+ prepare: () => managed.prepare(ref), run: () => managed.reclaimOne(ref) }))];
265
+ } }];
266
+ });
267
+ },
268
+ log: line => warn(line),
269
+ });
229
270
  const { reportSessionWorktree, reportPlaceWorktrees, burstListeners, reportWorktrees } =
230
271
  createWorkPlaceReports({
231
272
  repositoryPlaces,
@@ -387,6 +428,7 @@ export function createWorkManager({
387
428
  publishAgentBranch, stopAgentPublishing,
388
429
  admit,
389
430
  workChildren,
431
+ groupKillChildren,
390
432
  agentPublished,
391
433
  agentRemoteAt,
392
434
  fetchAgentFiles,
@@ -455,7 +497,7 @@ export function createWorkManager({
455
497
  workChildren.size > 0 ||
456
498
  workAnswering.size > 0 ||
457
499
  pendingWorkReports.size > 0 ||
458
- pendingShipReports.size > 0;
500
+ pendingShipReports.size > 0 || cleanupBusy();
459
501
 
460
502
  return {
461
503
  flushWorkReports,
@@ -473,6 +515,7 @@ export function createWorkManager({
473
515
  retireWorkSessions,
474
516
  reportWorktrees,
475
517
  reconcileIdleArtifacts,
518
+ reconcileLocalCleanup,
476
519
  shutdownWork,
477
520
  workBusy,
478
521
  runningAgentCount: () => agentTurns.size,
@@ -1,3 +1,4 @@
1
+ import { fetchResultInputs } from './resultInputs.mjs';
1
2
  import { planWork, reportPlanOutcome } from './workActivity.mjs';
2
3
  /** Agent plan claims, read-only planning turns, and proposal reports. */
3
4
  import { PLAN_RECOVERY_SYSTEM, parsePlanDiagnosis, watchPlanAttempt } from './workPlanRecovery.mjs';
@@ -287,6 +288,7 @@ export function createWorkAgentPlans({
287
288
  */
288
289
  const liveAgents = (Array.isArray(job.liveAgents) ? job.liveAgents : []).map(a =>
289
290
  liveAgentContext(a, measureAgentFootprint({ placeId: a?.placeId, baseDir, baseRef })));
291
+ await fetchResultInputs(repoRoot, job.resultInputs);
290
292
  const knowledgeDir = knowledgeDirFor(repoRoot);
291
293
  discovery.toolLog.baseline = measureRepositoryBaseline({ cwd: repoRoot, baseRef, knowledgeDir });
292
294
  discovery.toolLog.captureBaseline = repositoryBaselineOf(job.captureBaseline);
@@ -351,6 +353,7 @@ export function createWorkAgentPlans({
351
353
  });
352
354
  };
353
355
  let context = AGENT_PLAN_KICKOFF({ tasks, liveAgents, agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3 });
356
+ if (job.resultContext) context += '\n\n' + job.resultContext;
354
357
  if (job.recovery?.start) context += '\nSTART REPAIR: This is a supporting diagnosis for an already approved Start. Return exactly ONE group with the same ordered taskIds, attachment target (or absence) and CLI. Preserve the approval; do not start an unapproved sibling. Diagnose admission/pickup errors and repair planning evidence/context. Ask only a genuine person-only question in the diagnostic JSON. Approval and source revision (data): ' + JSON.stringify(job.recovery.start);
355
358
  const system = withProjectContext(SYSTEM_PLAN, { knowledgeDir }) + '\n\n' + discoveryGuide(rt, 'planning') + '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(discovery.toolLog.baseline, discovery.toolLog.captureBaseline, discovery.toolLog.reconciliation);
356
359
  if (job.recovery) {
@@ -12,10 +12,12 @@ import { gitOrNull } from './git.mjs';
12
12
  import { parsePrecheck } from './agentPlan.mjs';
13
13
  import { readStash } from './agentCards.mjs';
14
14
  import { c, note } from './ui.mjs';
15
- import { runTurn } from './runTurn.mjs';
15
+ import { runTurn as defaultRunTurn } from './runTurn.mjs';
16
16
  import { SYSTEM_PRECHECK, AGENT_PRECHECK_KICKOFF } from './prompts.mjs';
17
17
  import { scrub as envScrub } from './uplinkScrub.mjs';
18
- import { pickRuntimeFor } from './runtimeDetection.mjs';
18
+ import { detectRuntimes as defaultDetectRuntimes } from './runtimeDetection.mjs';
19
+ import { canRun, runtimeById } from './runtimes.mjs';
20
+ import { brainFor } from './workBrain.mjs';
19
21
  import { runCappedTurn } from './workCappedTurn.mjs';
20
22
  import { taskIdsFromMessage } from './worktreeDiff.mjs';
21
23
  import { limitLine } from './cliLimit.mjs';
@@ -23,12 +25,12 @@ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
23
25
  import { postFleetJson } from './fleetPost.mjs';
24
26
  import { agentBrowserHome } from './noWindowEnv.mjs';
25
27
 
26
- export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workChildren }) {
28
+ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workChildren, runTurn = defaultRunTurn, detectRuntimes = defaultDetectRuntimes }) {
27
29
  const AGENT_PRECHECK_URL = fleetEndpoint('agent-precheck', FLEET_URL);
28
30
 
29
31
  // ── THE AI PRE-REVIEW ──────────────────────────────────────────────────────
30
32
  //
31
- // A FRESH Claude reads the branch before the human does. The owner asked for
33
+ // A fresh conversation reads the branch before the human does. The owner asked for
32
34
  // it in these words: "before having the user manually check, can we have the
33
35
  // daemon … spawn an agent to review the work so basically we get an ai to look
34
36
  // at the review before a human looks at it for a double check."
@@ -135,15 +137,14 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
135
137
  return { text, taskIds: [...taskIds] };
136
138
  };
137
139
 
138
- const runPrecheck = async (agentId, wt, agentName, repository) => {
139
- /**
140
- * WHICH CLI READS. `pickRuntimeFor('consult')` — the same picker the scratch
141
- * planner uses, and for the same reason: this prompt was written against
142
- * Claude, and a machine with no read-only-capable runtime simply does not
143
- * produce a precheck. Nothing is posted and nothing is said on the row.
144
- */
145
- const rt = pickRuntimeFor('consult');
146
- if (!rt) return;
140
+ const runPrecheck = async (agentId, wt, agentName, repository, source) => {
141
+ // A fresh read-only conversation, on this agent's CLI/model/effort.
142
+ // Missing provenance or a missing CLI skips this optional reading; it
143
+ // never licenses a machine-wide provider substitution.
144
+ if (!source) return;
145
+ const rt = source.runtime || 'claude';
146
+ if (!canRun(runtimeById(rt), 'consult') || !detectRuntimes().some(row => row.id === rt && row.installed)) return;
147
+ const brain = brainFor(source, rt);
147
148
  /**
148
149
  * THE PRESSURE GUARD, at the spawn, exactly as every other unattended lane
149
150
  * asks it. `churn` and never `interactive`: nobody is watching this, and a
@@ -232,6 +233,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
232
233
  profile: 'consult',
233
234
  cwd: wt,
234
235
  runtime: rt,
236
+ ...brain,
235
237
  // The agent's own browser profile, in the worktree this reader stands
236
238
  // in; no display either way (noWindowEnv.mjs, 2026-09-29).
237
239
  browserHome: agentBrowserHome(sessionMetaPath, wt, agentId),
@@ -242,8 +244,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
242
244
  work: { kind: 'pre-review', id: agentId, name: agentName },
243
245
  // The CLI's own count for this reading. Set, never accumulated: one
244
246
  // turn is one `result` event, and the adding-up is the server's.
245
- // Tagged with the CLI, so a Claude pre-review of a Codex agent is
246
- // charged as Claude's and not folded into the agent's own figure.
247
+ // Tagged with the same CLI as the associated agent.
247
248
  onUsage: (u) => {
248
249
  usage = { ...u, runtime: rt };
249
250
  },
@@ -29,7 +29,7 @@ export function createWorkAgentReview({ runCheck, runPrecheck }) {
29
29
  * INSIDE the place's writer lock on a path whose callers settle real work —
30
30
  * `runAgentMerge` in particular reports a claimed merge after this returns.
31
31
  */
32
- const runReviewEntry = async (agentId, wt, agentName, repository) => {
32
+ const runReviewEntry = async (agentId, wt, agentName, repository, brain) => {
33
33
  let result = { status: 'unknown' };
34
34
  try {
35
35
  result = await runCheck(agentId, wt, repository) ?? result;
@@ -37,7 +37,7 @@ export function createWorkAgentReview({ runCheck, runPrecheck }) {
37
37
  /* the row keeps its previous check answer, which is null the first time */
38
38
  }
39
39
  try {
40
- await runPrecheck(agentId, wt, agentName, repository);
40
+ await runPrecheck(agentId, wt, agentName, repository, brain);
41
41
  } catch {
42
42
  /* no pre-review is posted, and absence renders nothing */
43
43
  }
@@ -31,7 +31,7 @@ import { ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
31
31
  * agent calls them: a multi-repository card's add_repository, and an
32
32
  * orchestrated card's routing. Never a project server's, whatever the base
33
33
  * `.mcp.json` names. */
34
- export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER });
34
+ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review' });
35
35
 
36
36
  /**
37
37
  * The card turn's tools, prepared, and what it is told of them.
@@ -44,7 +44,7 @@ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositorie
44
44
  * from here), the note, the mount's part of the CLI's `mcpEnv` and whether it
45
45
  * needs one, and the keys the runner takes the mount as.
46
46
  */
47
- export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
47
+ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
48
48
  // A turn reads the base ref afresh. Changes the agent has made to its
49
49
  // own worktree cannot silently change the next turn's tools.
50
50
  const source = sources.find((r) => r.wt === wt);
@@ -56,6 +56,7 @@ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources =
56
56
  const projectTools = prepareAgentTools(snapshot, rt, env, wt, { codexHome, fenced: posture !== 'build', turnServers: {
57
57
  ...(repositoryTool ? { [CARD_TURN_SERVERS.repositories]: repositoryTool.config } : {}),
58
58
  ...(orchestratorConfig ? { [CARD_TURN_SERVERS.orchestrator]: orchestratorConfig } : {}),
59
+ ...(posture === 'build' && uiReviewConfig ? { [CARD_TURN_SERVERS.uiReview]: uiReviewConfig } : {}),
59
60
  } });
60
61
  try {
61
62
  const mountsProjectMcp = TURN_PROFILES[posture]?.projectMcp === true && runnerToolCapabilities(rt).mcp;
@@ -1,3 +1,6 @@
1
+ import { reportLocalCleanup } from './localCleanupReport.mjs';
2
+ import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
3
+ import { createCaptureRetention } from './referenceCaptures.mjs';
1
4
  import { imageRevisionInstructions } from './artifactIteration.mjs';
2
5
  import { agentChildScope } from './agentQueueContext.mjs';
3
6
  import { measureTestFindings } from './testFindings.mjs';
@@ -33,6 +36,7 @@ import { addUsage } from './turnUsage.mjs';
33
36
  import { stashCard } from './agentCards.mjs';
34
37
  import { c } from './ui.mjs';
35
38
  import { runTurn } from './runTurn.mjs';
39
+ import { liveGroups, processesSupported } from './processes.mjs';
36
40
  import { killGroup } from './processGroup.mjs';
37
41
  import { AGENT_TASK_SPEC } from './prompts.mjs';
38
42
  import { agentKickoff, agentFollowupKickoff, agentTurnSystem } from './workAgentTurnShape.mjs';
@@ -42,6 +46,7 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
42
46
  import { canRun, runtimeById } from './runtimes.mjs';
43
47
  import { toolEventOf, CLAUDE_TOOL_PROSE_KINDS, scrubNativeText } from './runtimeEvents.mjs';
44
48
  import { makeTraceRelay } from './trace.mjs';
49
+ import { createWebsiteTrace } from './websiteTrace.mjs';
45
50
  import { prepareCardTools } from './workAgentTools.mjs';
46
51
  import { runTurnResumingOnce } from './resumeLost.mjs';
47
52
  import { runAgentWithInternalWaits } from './agentInternalWait.mjs';
@@ -61,6 +66,8 @@ import { knownCodexModels } from './runtimeOfferings.mjs';
61
66
  import { codexSubagentsSafe } from './runtimeCodex.mjs';
62
67
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
63
68
  import { createIntermediateArtifacts } from './intermediateArtifacts.mjs';
69
+ import { createUiReview } from './uiReview.mjs';
70
+ import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
64
71
 
65
72
  export function createAgentTurnExecution({
66
73
  admit,
@@ -80,6 +87,7 @@ export function createAgentTurnExecution({
80
87
  runReviewEntry,
81
88
  runMergeCheck,
82
89
  workChildren,
90
+ groupKillChildren,
83
91
  agentChildren,
84
92
  postAgentTrace,
85
93
  postAgentActivity,
@@ -89,6 +97,7 @@ export function createAgentTurnExecution({
89
97
  makeLiveMessages = createLiveMessages,
90
98
  runTurnImpl = runTurn,
91
99
  exportModel,
100
+ makeUiReview = createUiReview,
92
101
  }) {
93
102
  const reportDelivery = createDeliveryReporter();
94
103
  /** How long the final flush may hold the settle. Bounded because the settle
@@ -247,7 +256,7 @@ export function createAgentTurnExecution({
247
256
  return;
248
257
  }
249
258
  /**
250
- * …AND ITS SCRATCH BESIDE IT, EMPTY (0.115.0): a fenced shell's temp files
259
+ * …AND ITS SCRATCH BESIDE IT (0.115.0): a fenced shell's temp files
251
260
  * (a here-document, `mktemp`) go under `.flowviant/tmp`, which Codex's
252
261
  * design fence grants because `/tmp` is read-only in there
253
262
  * (`ensureFenceScratch`). Made for every file kind — one rule, no kind
@@ -356,6 +365,9 @@ export function createAgentTurnExecution({
356
365
  * CLAUDE_TOOL_PROSE_KINDS.
357
366
  */
358
367
  const doubledKinds = runtimeById(rt)?.parse ? null : CLAUDE_TOOL_PROSE_KINDS;
368
+ const websiteTrace = createWebsiteTrace(wt, trace, (id, emit) => {
369
+ if (orchestrator) orchestrator.attributed(id, emit); else emit(id);
370
+ });
359
371
 
360
372
  /**
361
373
  * WHICH BRAIN THIS CONTAINER WAS PINNED TO — the same `brainFor` the tab
@@ -389,7 +401,14 @@ export function createAgentTurnExecution({
389
401
  const spawnedChildren = new Set();
390
402
  /** The CLI, unless a person already asked to stop this turn (Interrupt
391
403
  * now): then nothing starts — no fresh retry, no continuation. */
392
- const runCli = (args) => (run.interrupted ? Promise.resolve('') : runTurnImpl(args));
404
+ let uiReview = null;
405
+ let uiReviewNote = '';
406
+ let uiReviewEvidence = '';
407
+ const runCli = (args) => {
408
+ if (run.interrupted) return Promise.resolve('');
409
+ args = withUiReview(args, uiReviewNote, uiReview?.env);
410
+ return runTurnImpl(args);
411
+ };
393
412
  /**
394
413
  * WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
395
414
  *
@@ -427,10 +446,28 @@ export function createAgentTurnExecution({
427
446
  artifactsBefore.imageRevision = job.imageRevision;
428
447
  artifactInstructions += '\n\n' + imageRevisionInstructions(job.imageRevision, job.resultInputs);
429
448
  }
449
+ const cleanupPath = sessionMetaPath(wt, 'flowviant-managed-local', agentId);
450
+ const localCleanup = PROJECT_ID && cleanupPath ? createManagedLocalCleanup({
451
+ placeDir: wt, projectId: PROJECT_ID, agentId, path: cleanupPath,
452
+ codexHome: agentResume.codexHome,
453
+ outputCache: artifacts.cacheFor?.({ placeDir: wt, agentId }) ?? null,
454
+ }) : null;
430
455
  let projectTools = null;
431
456
  let orchestrator = null;
432
457
  let intermediateDelivery = null;
433
458
  try {
459
+ if (posture === 'build' && job.kind !== 'merge_resolve') {
460
+ try {
461
+ uiReview = await makeUiReview({ wt, signal: setupController.signal, onChild: ch => {
462
+ workChildren.set(ch, agentId); groupKillChildren?.add(ch);
463
+ return () => { workChildren.delete(ch); groupKillChildren?.delete(ch); };
464
+ } });
465
+ uiReviewNote = uiReview.instructions;
466
+ } catch (error) {
467
+ uiReviewNote = `Local UI self-review is unavailable: ${envScrub(error.message)}. Report browser, image and interaction checks as unrun with this reason when relevant; do not install tools or weaken isolation.`;
468
+ uiReviewEvidence = uiReviewNote;
469
+ }
470
+ }
434
471
  if (orchestrating) {
435
472
  orchestrator = await createOrchestrator({ turnId, runtime: rt, trace, admit,
436
473
  assignmentContext: () => agentChildScope(job),
@@ -441,8 +478,9 @@ export function createAgentTurnExecution({
441
478
  notify: message => liveInput ? run.live?.inject(message) === true : false,
442
479
  taken: id => run.live?.onTaken(id) });
443
480
  }
444
- const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome });
481
+ const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome });
445
482
  projectTools = tools.projectTools;
483
+ localCleanup?.begin(turnId);
446
484
  const { toolsNote } = tools;
447
485
  const agentTurnArgs = {
448
486
  ...((liveInput || orchestrator) ? { liveMessages: (run.live = makeLiveMessages({
@@ -458,6 +496,9 @@ export function createAgentTurnExecution({
458
496
  })) } : {}),
459
497
  // The kickoff and the system prompt, composed by the agent lane's
460
498
  // shape (workAgentTurnShape.mjs) from what this run measured.
499
+ signal: setupController.signal,
500
+ onReferenceCapture: createCaptureRetention({ report: (files, { placeDir }) => artifacts.report({ placeDir, before: new Map(), agentId, turnId, requireStored: true, referenceCapture: true,
501
+ includeNames: new Set(files.map(f => f.name)), requiredOutputs: files.map(({ name, sha256 }) => ({ name, sha256 })) }) }),
461
502
  researchHandoff: job.researchHandoff,
462
503
  resultInputs: job.resultInputs,
463
504
  intermediateFiles,
@@ -573,6 +614,7 @@ export function createAgentTurnExecution({
573
614
  // the builder itself (bounded window BEFORE its caps; see
574
615
  // toolEventOf). A tool it does not know pushes nothing.
575
616
  onToolEvent: (name, input, meta) => {
617
+ if (websiteTrace(name, input, meta)) return;
576
618
  if (meta?.agentId) {
577
619
  if (!meta.result) {
578
620
  const event = toolEventOf(name, input, wt, envScrub);
@@ -688,6 +730,14 @@ export function createAgentTurnExecution({
688
730
  // answer is scrubbed; then it can leave the machine's secret set.
689
731
  out = envScrub(out);
690
732
  if (lastAnswer) lastAnswer = envScrub(lastAnswer);
733
+ // The builder has finished its fix/recheck loop. Close only resources
734
+ // owned by its review tool, then bind evidence to the final source
735
+ // before the existing artifact upload and delivery settlement.
736
+ try { await uiReview?.close(); }
737
+ finally {
738
+ try { uiReviewEvidence = uiReview?.finalize() ?? uiReviewEvidence; }
739
+ catch (error) { uiReviewEvidence = `Local UI review final source/evidence measurement unavailable: ${envScrub(error.message)}. Earlier checks do not establish the final source.`; }
740
+ }
691
741
  await run.live?.stop();
692
742
  await orchestrator?.close(run.interrupted);
693
743
  await brief?.stop();
@@ -730,6 +780,7 @@ export function createAgentTurnExecution({
730
780
  // The ran marker and the thread this turn spoke under, written now
731
781
  // that the turn is over (workAgentResume.mjs).
732
782
  agentResume.persist();
783
+ localCleanup?.capture();
733
784
  /**
734
785
  * AN ACTION THAT CHANGES WHAT THE MACHINE WOULD MEASURE MUST CAUSE A
735
786
  * NEW MEASUREMENT — the rule every session settle keeps, and the one
@@ -793,7 +844,21 @@ export function createAgentTurnExecution({
793
844
  wt,
794
845
  modelExports: intermediateDelivery?.modelExports,
795
846
  });
847
+ appendUiReviewEvidence(body, uiReviewEvidence);
796
848
  if (body.outcome === 'delivered') intermediateDelivery?.commit();
849
+ // This place's writer lock is still held. Measure the exited children
850
+ // and their groups before releasing any file requirements. Dirty repository files are unrelated to managed scratch eligibility.
851
+ if (localCleanup) {
852
+ const cleanupResults = localCleanup.capture(); // binds newly uploaded exact image counterparts
853
+ const groups = new Set([...spawnedChildren].map(ch => ch.pid).filter(pid => Number.isInteger(pid) && pid > 1));
854
+ const drained = !run.interrupted && processesSupported() && groups.size > 0 &&
855
+ [...spawnedChildren].every(ch => ch.exitCode != null || ch.signalCode != null) && liveGroups(groups).size === 0;
856
+ cleanupResults.push(...localCleanup.completeTurn({ drained,
857
+ handoffComplete: body.outcome === 'delivered', uploadsComplete: artifacts.pendingCount?.() === 0 }));
858
+ cleanupResults.push(...await localCleanup.reclaim({ drained }));
859
+ for (const result of cleanupResults) reportLocalCleanup({ ...result,
860
+ projectId: PROJECT_ID, agentId, placeDir: wt, place, turnId });
861
+ }
797
862
  if (res) setRuntimeLimit(PROJECT_ID, rt, null);
798
863
  if (body.outcome === 'delivered' && job.task?.taskType === 'tests' && res?.testFindings) {
799
864
  body.testFindings = measureTestFindings(res.testFindings, repositoryRun?.sourcesNow() ?? [{ wt, baseRef: baseRef(), id: job.repos?.length === 1 ? job.repos[0] : null }], envScrub);
@@ -831,7 +896,7 @@ export function createAgentTurnExecution({
831
896
  // pre-review HERE, in the worktree we are already standing in and still
832
897
  // hold the lock on — see `runReviewEntry`. Only after the settle that may
833
898
  // say delivered: the three `nothing` settles never opened it.
834
- if (final && reply?.review === true) await runReviewEntry(agentId, wt, job.agentName);
899
+ if (final && reply?.review === true) await runReviewEntry(agentId, wt, job.agentName, undefined, job);
835
900
  } finally { await repositoryRun?.tool?.close(); }
836
901
  };
837
902
 
@@ -53,6 +53,7 @@ export function createWorkAgentTurns({
53
53
  stopAgentPublishing = () => {},
54
54
  admit,
55
55
  workChildren,
56
+ groupKillChildren,
56
57
  agentPublished,
57
58
  agentRemoteAt,
58
59
  /** The turn's files, fetched into its worktree (workAttachments.mjs, 0.112.0). */
@@ -138,6 +139,7 @@ export function createWorkAgentTurns({
138
139
  runMergeCheck,
139
140
  exportModel,
140
141
  workChildren,
142
+ groupKillChildren,
141
143
  agentChildren,
142
144
  postAgentTrace,
143
145
  postAgentActivity,
@@ -322,7 +324,7 @@ export function createWorkAgentTurns({
322
324
  const wt = typeof held.body.worktree === 'string' ? held.body.worktree : null;
323
325
  if (reply?.review === true && isSafePathSegment(place) && wt && existsSync(wt)) {
324
326
  await inPlace(place, place.startsWith(AGENT_PLACE_PREFIX), () =>
325
- runReviewEntry(String(job.agentId || ''), wt, job.agentName)
327
+ runReviewEntry(String(job.agentId || ''), wt, job.agentName, undefined, job)
326
328
  );
327
329
  }
328
330
  })()