flowviant 0.125.20 → 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.
@@ -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
@@ -428,6 +428,7 @@ export function createWorkManager({
428
428
  publishAgentBranch, stopAgentPublishing,
429
429
  admit,
430
430
  workChildren,
431
+ groupKillChildren,
431
432
  agentPublished,
432
433
  agentRemoteAt,
433
434
  fetchAgentFiles,
@@ -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,5 +1,6 @@
1
1
  import { reportLocalCleanup } from './localCleanupReport.mjs';
2
2
  import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
3
+ import { createCaptureRetention } from './referenceCaptures.mjs';
3
4
  import { imageRevisionInstructions } from './artifactIteration.mjs';
4
5
  import { agentChildScope } from './agentQueueContext.mjs';
5
6
  import { measureTestFindings } from './testFindings.mjs';
@@ -45,6 +46,7 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
45
46
  import { canRun, runtimeById } from './runtimes.mjs';
46
47
  import { toolEventOf, CLAUDE_TOOL_PROSE_KINDS, scrubNativeText } from './runtimeEvents.mjs';
47
48
  import { makeTraceRelay } from './trace.mjs';
49
+ import { createWebsiteTrace } from './websiteTrace.mjs';
48
50
  import { prepareCardTools } from './workAgentTools.mjs';
49
51
  import { runTurnResumingOnce } from './resumeLost.mjs';
50
52
  import { runAgentWithInternalWaits } from './agentInternalWait.mjs';
@@ -64,6 +66,8 @@ import { knownCodexModels } from './runtimeOfferings.mjs';
64
66
  import { codexSubagentsSafe } from './runtimeCodex.mjs';
65
67
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
66
68
  import { createIntermediateArtifacts } from './intermediateArtifacts.mjs';
69
+ import { createUiReview } from './uiReview.mjs';
70
+ import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
67
71
 
68
72
  export function createAgentTurnExecution({
69
73
  admit,
@@ -83,6 +87,7 @@ export function createAgentTurnExecution({
83
87
  runReviewEntry,
84
88
  runMergeCheck,
85
89
  workChildren,
90
+ groupKillChildren,
86
91
  agentChildren,
87
92
  postAgentTrace,
88
93
  postAgentActivity,
@@ -92,6 +97,7 @@ export function createAgentTurnExecution({
92
97
  makeLiveMessages = createLiveMessages,
93
98
  runTurnImpl = runTurn,
94
99
  exportModel,
100
+ makeUiReview = createUiReview,
95
101
  }) {
96
102
  const reportDelivery = createDeliveryReporter();
97
103
  /** How long the final flush may hold the settle. Bounded because the settle
@@ -359,6 +365,9 @@ export function createAgentTurnExecution({
359
365
  * CLAUDE_TOOL_PROSE_KINDS.
360
366
  */
361
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
+ });
362
371
 
363
372
  /**
364
373
  * WHICH BRAIN THIS CONTAINER WAS PINNED TO — the same `brainFor` the tab
@@ -392,7 +401,14 @@ export function createAgentTurnExecution({
392
401
  const spawnedChildren = new Set();
393
402
  /** The CLI, unless a person already asked to stop this turn (Interrupt
394
403
  * now): then nothing starts — no fresh retry, no continuation. */
395
- 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
+ };
396
412
  /**
397
413
  * WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
398
414
  *
@@ -440,6 +456,18 @@ export function createAgentTurnExecution({
440
456
  let orchestrator = null;
441
457
  let intermediateDelivery = null;
442
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
+ }
443
471
  if (orchestrating) {
444
472
  orchestrator = await createOrchestrator({ turnId, runtime: rt, trace, admit,
445
473
  assignmentContext: () => agentChildScope(job),
@@ -450,7 +478,7 @@ export function createAgentTurnExecution({
450
478
  notify: message => liveInput ? run.live?.inject(message) === true : false,
451
479
  taken: id => run.live?.onTaken(id) });
452
480
  }
453
- 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 });
454
482
  projectTools = tools.projectTools;
455
483
  localCleanup?.begin(turnId);
456
484
  const { toolsNote } = tools;
@@ -468,6 +496,9 @@ export function createAgentTurnExecution({
468
496
  })) } : {}),
469
497
  // The kickoff and the system prompt, composed by the agent lane's
470
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 })) }) }),
471
502
  researchHandoff: job.researchHandoff,
472
503
  resultInputs: job.resultInputs,
473
504
  intermediateFiles,
@@ -583,6 +614,7 @@ export function createAgentTurnExecution({
583
614
  // the builder itself (bounded window BEFORE its caps; see
584
615
  // toolEventOf). A tool it does not know pushes nothing.
585
616
  onToolEvent: (name, input, meta) => {
617
+ if (websiteTrace(name, input, meta)) return;
586
618
  if (meta?.agentId) {
587
619
  if (!meta.result) {
588
620
  const event = toolEventOf(name, input, wt, envScrub);
@@ -698,6 +730,14 @@ export function createAgentTurnExecution({
698
730
  // answer is scrubbed; then it can leave the machine's secret set.
699
731
  out = envScrub(out);
700
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
+ }
701
741
  await run.live?.stop();
702
742
  await orchestrator?.close(run.interrupted);
703
743
  await brief?.stop();
@@ -804,6 +844,7 @@ export function createAgentTurnExecution({
804
844
  wt,
805
845
  modelExports: intermediateDelivery?.modelExports,
806
846
  });
847
+ appendUiReviewEvidence(body, uiReviewEvidence);
807
848
  if (body.outcome === 'delivered') intermediateDelivery?.commit();
808
849
  // This place's writer lock is still held. Measure the exited children
809
850
  // and their groups before releasing any file requirements. Dirty repository files are unrelated to managed scratch eligibility.
@@ -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,
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { toolEventOf } from './runtimeEvents.mjs';
11
11
  import { scrubCut, scrub as envScrub } from './uplinkScrub.mjs';
12
+ import { observeWebsite } from './websiteAccess.mjs';
12
13
 
13
14
  /**
14
15
  * THE TURN'S TOOL LOG — the structured relay behind the transcript's
@@ -47,7 +48,15 @@ export function createToolLog(cwd) {
47
48
  e.outcome = !meta.result ? 'unknown' : failed ? 'failed' :
48
49
  meta.runtime === 'codex' && code === undefined ? 'unknown' : 'success';
49
50
  if (code !== undefined) e.exitCode = code;
50
- if (failed) {
51
+ if (e.website) {
52
+ observeWebsite(e, meta, envScrub);
53
+ if (e.errorTruncated) toolLog.state = 'partial';
54
+ }
55
+ if (e.outcome === 'failed') {
56
+ if (e.website) {
57
+ if (!toolLog.firstFailure) { toolLog.firstFailure = structuredClone(e); toolLog.recovery = []; }
58
+ return;
59
+ }
51
60
  const output = scrubCut(meta.output ?? '', 16_384);
52
61
  const guardAt = output.indexOf('flowviant: this turn is read-only, so ');
53
62
  const guard = guardAt >= 0 ? output.slice(guardAt).split('\n', 1)[0] : undefined;
@@ -67,8 +76,19 @@ export function createToolLog(cwd) {
67
76
  };
68
77
  const pushToolEvent = (name, input, meta = {}) => {
69
78
  const id = typeof meta.id === 'string' && meta.id && meta.id.length <= 160 ? meta.id : undefined;
70
- const existing = id && correlated.get(id);
79
+ const key = id && JSON.stringify([meta.agentId ?? null, id]);
80
+ const existing = key && correlated.get(key);
71
81
  if (existing) {
82
+ // Codex may only name the web action/URL on completion. Enrich that
83
+ // same native request instead of keeping its provisional search label.
84
+ if (existing.website && name) {
85
+ const request = toolEventOf(name, input, cwd, envScrub)?.website;
86
+ if (request) {
87
+ existing.website.method = request.method;
88
+ existing.website.tool = request.tool;
89
+ if (request.requestedUrl) existing.website.requestedUrl = request.requestedUrl;
90
+ }
91
+ }
72
92
  if (meta.result) observe(existing, meta);
73
93
  refresh();
74
94
  return existing;
@@ -87,7 +107,7 @@ export function createToolLog(cwd) {
87
107
  if (typeof meta.agentId === 'string' && meta.agentId) e.agentId = scrubCut(meta.agentId, 160);
88
108
  if (e.agentId && typeof meta.agentTitle === 'string' && meta.agentTitle) e.agentTitle = scrubCut(meta.agentTitle, 120);
89
109
  if (e.t !== 'plan') {
90
- if (id) { e.id = scrubCut(id, 160); correlated.set(id, e); }
110
+ if (id) { e.id = scrubCut(id, 160); correlated.set(key, e); }
91
111
  if (name === 'Read') for (const key of ['offset', 'limit']) {
92
112
  if (Number.isSafeInteger(input?.[key]) && input[key] >= 0) e[key] = input[key];
93
113
  }
@@ -115,6 +135,7 @@ export function createToolLog(cwd) {
115
135
  last.errorCategory === e.errorCategory &&
116
136
  last.offset === e.offset && last.limit === e.limit &&
117
137
  last.t === e.t &&
138
+ !e.website && !last.website &&
118
139
  last.p === e.p &&
119
140
  last.q === e.q &&
120
141
  last.c === e.c;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.20",
3
+ "version": "0.125.21",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {