flowviant 0.125.29 → 0.125.30

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,185 @@
1
+ /** One model-independent review tool over reusable execution/surface adapters. */
2
+ import { createServer } from 'node:net';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { writeFileSync } from 'node:fs';
5
+ import { basename, join } from 'node:path';
6
+ import { listenHeldTool, heldToolArgv } from './heldTool.mjs';
7
+ import { serveMcpStdio } from './mcpStdio.mjs';
8
+ import { readReviewTargets, reviewCwd, commandOnPath } from './reviewTargets.mjs';
9
+ import { startReviewProcess } from './reviewProcess.mjs';
10
+ import { desktopSupport } from './reviewDesktop.mjs';
11
+ import { openLinuxDesktop } from './linuxDesktopBridge.mjs';
12
+ import { openBridgePage } from './reviewBridge.mjs';
13
+ import { openRobloxStudio } from './robloxStudioBridge.mjs';
14
+ import { reviewedProviderCommand } from './reviewEnvironmentConfig.mjs';
15
+ import { environmentOverlay, readWorktreeEnvironment } from './reviewEnvironment.mjs';
16
+ import { childEnv } from './childEnv.mjs';
17
+ import { measureListeners } from './listeners.mjs';
18
+ import { resolveBrowser } from './shot.mjs';
19
+ import { reviewSourceState, sameReviewSource } from './uiReview.mjs';
20
+ import { reviewDiagnostic } from './uiReviewBrowser.mjs';
21
+ import { ensureArtifactDir, ARTIFACT_DIR } from './artifacts.mjs';
22
+ import { learnExtraSecrets, forgetExtraSecrets, scrub } from './uplinkScrub.mjs';
23
+
24
+ export const RUNTIME_REVIEW_SERVER = 'card_runtime';
25
+ const TOOLS = [
26
+ { name: 'discover', description: 'List reviewed command, browser, desktop, Roblox Studio and engine review targets and measured prerequisites. Discovery starts no application.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
27
+ { name: 'run', description: 'Run a named reviewed target. Command checks return logs and source-bound evidence; interactive targets open the SAME Live app frame and human control handoff. Never treats a build or sync server as gameplay.', inputSchema: { type: 'object', properties: { target: { type: 'string' }, repository: { type: 'string' } }, required: ['target'], additionalProperties: false } },
28
+ { name: 'observe', description: 'Capture and retain the current shared runtime surface. Screenshot capture is an observation, not a passing test.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
29
+ { name: 'act', description: 'Interact with the current surface using bounded click, type, key, scroll or browser navigation verbs. Human control pauses model interactions.', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['click', 'type', 'key', 'scroll', 'navigate', 'snapshot'] }, x: { type: 'number' }, y: { type: 'number' }, text: { type: 'string' }, key: { type: 'string' }, path: { type: 'string' }, selector: { type: 'string' }, deltaY: { type: 'number' } }, required: ['type'], additionalProperties: false } },
30
+ { name: 'handoff', description: 'Hold this turn for human private sign-in or native app testing. Waits without model polling and resumes the same conversation.', inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['signin', 'control'] }, reason: { type: 'string', maxLength: 300 } }, required: ['kind', 'reason'], additionalProperties: false } },
31
+ { name: 'stop', description: 'Stop the runtime processes owned by this tool. A person’s Stop prevents reopening for the turn.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
32
+ ];
33
+ const text = value => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
34
+ const availablePort = () => new Promise((resolve, reject) => {
35
+ const server = createServer(); server.once('error', reject);
36
+ server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
37
+ });
38
+
39
+ export async function createRuntimeReview({ wt, repoRoot, baseRef, sources = [], browser, signal, onChild,
40
+ openDesktop = openLinuxDesktop, openBridge = openBridgePage, openRoblox = openRobloxStudio, desktopProbe = desktopSupport, browserProbe = resolveBrowser }) {
41
+ const catalog = (sources.length ? sources : [{ wt, repoRoot, baseRef }]).map(source => ({ ...readReviewTargets(source.repoRoot, source.baseRef),
42
+ wt: source.wt, name: source.name ?? basename(source.repoRoot), id: source.id }));
43
+ if (catalog.every(source => Object.keys(source.targets).length === 0)) return null;
44
+ const controller = new AbortController(), reports = [], commands = new Set();
45
+ let closed = false, busy = false, current = null, app = null;
46
+ const abort = () => { void close(); };
47
+ const environmentFor = source => readWorktreeEnvironment(source.wt)?.env ?? process.env;
48
+ function support(target, env) {
49
+ if (target.platforms && !target.platforms.includes(process.platform)) return { available: false, reason: `This target requires ${target.platforms.join(' or ')}; this machine is ${process.platform}.` };
50
+ if (target.adapter === 'browser') {
51
+ const found = browserProbe();
52
+ return browser && found && !found.viaWindows ? { available: true } : { available: false, reason: 'No native managed Chromium browser is available on this machine.' };
53
+ }
54
+ if (target.adapter === 'desktop') return browser ? desktopProbe(env) : { available: false, reason: 'The interactive surface transport is unavailable on this turn.' };
55
+ if (target.adapter === 'roblox') {
56
+ const found = browserProbe();
57
+ return browser && process.platform === 'linux' && commandOnPath('flock', env) && found && !found.viaWindows ? { available: true } : { available: false, reason: 'Studio viewport review needs a Linux/WSL daemon, flock and native headless Chromium. Studio/MCP runs on the Windows host.' };
58
+ }
59
+ if (target.adapter === 'bridge' && !browser) return { available: false, reason: 'The interactive surface transport is unavailable on this turn.' };
60
+ return { available: true };
61
+ }
62
+ const stopApp = async () => { const previous = app; app = null; await previous?.close(); };
63
+ async function close() {
64
+ if (closed) return;
65
+ closed = true; signal?.removeEventListener('abort', abort); controller.abort();
66
+ if (current) await browser?.close();
67
+ await Promise.all([stopApp(), ...[...commands].map(command => command.close())]);
68
+ held.server.close(); held.server.closeAllConnections(); forgetExtraSecrets(secretKey);
69
+ }
70
+ const record = (source, target, name, before, result) => {
71
+ const after = reviewSourceState(source.wt);
72
+ const report = { target: name, repository: source.name, adapter: target.adapter, ...result,
73
+ before, after, sourceStable: sameReviewSource(before, after) };
74
+ reports.push({ source, report });
75
+ if (ensureArtifactDir(wt)) {
76
+ const path = `runtime-review-${randomUUID()}.md`;
77
+ const body = `# Runtime review: ${source.name}/${name}\n\n${scrub(JSON.stringify(report, null, 2))}\n`;
78
+ writeFileSync(join(wt, ARTIFACT_DIR, path), body, { flag: 'wx', mode: 0o600 });
79
+ report.artifact = `${ARTIFACT_DIR}/${path}`;
80
+ }
81
+ return report;
82
+ };
83
+ const call = async (name, input = {}) => {
84
+ if (closed || controller.signal.aborted) throw new Error('This review turn has ended.');
85
+ if (name === 'discover') return text({ repositories: catalog.map(source => ({ repository: source.name, targets: Object.entries(source.targets).map(([target, config]) => ({ target, adapter: config.adapter,
86
+ ...support(config, environmentFor(source)), command: config.command })) })),
87
+ instruction: 'Use the repository’s existing workflows when no recipe is configured. A missing runtime leaves those checks unverified; code editing can continue. A Roblox/Rojo sync server is not gameplay.' });
88
+ if (busy) throw new Error('Another runtime review operation is finishing.');
89
+ busy = true;
90
+ try {
91
+ if (name === 'observe') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.act({ type: 'snapshot' }); }
92
+ if (name === 'act') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.act(input); }
93
+ if (name === 'handoff') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.handoff(input); }
94
+ if (name === 'stop') { browser?.stopSurface(); await stopApp(); current = null; return text({ stopped: true }); }
95
+ if (name !== 'run') throw new Error('Unknown runtime tool.');
96
+ const source = input.repository == null ? catalog.find(source => source.wt === wt) : catalog.find(source => source.name === input.repository || source.id === input.repository);
97
+ const target = source && Object.hasOwn(source.targets, input.target) ? source.targets[input.target] : null;
98
+ if (!target) throw new Error('That reviewed target does not exist. Call discover and use a listed repository/target.');
99
+ const cwd = reviewCwd(source.wt, target.cwd), env = environmentFor(source), measured = support(target, env);
100
+ if (!measured.available) throw new Error(measured.reason);
101
+ if (!commandOnPath(target.command[0], env) && !target.command[0].startsWith('./')) throw new Error(`Review command ${target.command[0]} is not installed on this machine.`);
102
+ const scoped = readWorktreeEnvironment(source.wt);
103
+ const interactiveEnv = { ...childEnv({ cwd }), ...Object.fromEntries((target.env ?? []).filter(key => env[key] != null).map(key => [key, env[key]])), ...(scoped ? environmentOverlay(scoped) : {}) };
104
+ const before = reviewSourceState(source.wt), options = { cwd, env: target.adapter === 'command' ? env : interactiveEnv, onChild, signal: controller.signal, timeoutMs: target.timeoutSeconds * 1000 };
105
+ if (target.adapter === 'command') {
106
+ const running = startReviewProcess(target.command, options);
107
+ commands.add(running);
108
+ let result;
109
+ try { result = await running.done; } finally { commands.delete(running); }
110
+ const report = record(source, target, input.target, before, { status: !result.error && result.code === 0 ? 'passed' : 'failed', exitCode: result.code, output: result.output, ...(result.error ? { error: result.error } : {}) });
111
+ return text({ ...report, output: result.output.slice(-4000), ...(result.output.length > 4000 ? { outputTruncated: true } : {}) });
112
+ }
113
+ browser.assertControl();
114
+ const key = `${source.wt}:${input.target}`;
115
+ if (target.adapter === 'browser') {
116
+ try {
117
+ if (current?.key !== key || !app?.running()) {
118
+ await stopApp();
119
+ const port = target.port || await availablePort();
120
+ if (measureListeners(source.wt).rows.some(row => row.port === port)) throw new Error('That worktree port is already running. Use card_browser for the existing server, or choose a different review port.');
121
+ app = startReviewProcess(target.command.map(arg => arg.replaceAll('${PORT}', String(port))), options);
122
+ current = { key, port };
123
+ for (let n = 0; n < 100; n++) {
124
+ if (controller.signal.aborted) throw new Error('The review turn ended during startup.');
125
+ if (!app.running()) throw new Error(app.result()?.error ?? `The review app exited. ${app.output()}`);
126
+ if (measureListeners(source.wt).rows.some(row => row.port === port)) break;
127
+ if (n === 99) throw new Error('No listener was measured for this review target.');
128
+ await new Promise(resolve => setTimeout(resolve, 100));
129
+ }
130
+ }
131
+ return await browser.open({ port: current.port, hostname: target.hostname, scheme: target.scheme, path: target.path ?? '/', fixture: target.fixture }, source.wt);
132
+ }
133
+ catch (error) { await stopApp(); current = null; throw error; }
134
+ }
135
+ const result = await browser.openSurface({ key, target: input.target, surface: target.adapter === 'desktop' ? 'desktop' : 'engine',
136
+ open: async surface => {
137
+ await stopApp(); current = { key };
138
+ if (target.adapter === 'desktop') return openDesktop({ ...options, ...surface, command: target.command, support: measured });
139
+ const command = reviewedProviderCommand({ root: source.root, commit: source.commit, recipe: { database: { command: target.command } } });
140
+ if (target.adapter === 'roblox') return openRoblox({ ...options, ...surface, command, cwd: source.root, worktree: source.wt, configuration: target.configuration });
141
+ return openBridge({ ...options, ...surface, command, cwd: source.root, worktree: source.wt, configuration: target.configuration });
142
+ } });
143
+ record(source, target, input.target, before, { status: 'observed', note: 'Interactive runtime opened. Captures and input do not establish passing gameplay tests.' });
144
+ return result;
145
+ } catch (error) { throw new Error(reviewDiagnostic(error.message)); }
146
+ finally { busy = false; }
147
+ };
148
+ const held = await listenHeldTool({ envPrefix: 'FLOWVIANT_RUNTIME_REVIEW', verb: '__runtime-review-mcp', requestTimeoutMs: 615_000, isClosed: () => closed,
149
+ respond: async (req, res) => {
150
+ try {
151
+ const chunks = []; let size = 0;
152
+ for await (const bytes of req) { size += bytes.length; if (size > 16_000) throw new Error('Runtime request exceeds its byte bound.'); chunks.push(bytes); }
153
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
154
+ if (!TOOLS.some(tool => tool.name === input.name)) throw new Error('Unknown runtime tool.');
155
+ const result = await call(input.name, input.input);
156
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result));
157
+ } catch (error) { res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ isError: true, content: [{ type: 'text', text: reviewDiagnostic(error.message) }] })); }
158
+ } });
159
+ const secretKey = `runtime-review:${held.capability.slice(0, 12)}`;
160
+ learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_RUNTIME_REVIEW_CAPABILITY', value: held.capability }]);
161
+ signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) await close();
162
+ return { env: held.env, config: { command: process.execPath, args: held.args, env: held.env, toolTimeoutSec: 650 }, close,
163
+ instructions: `Use card_runtime.discover to find reviewed command, browser, desktop or engine targets. Run a named target with card_runtime.run; never treat code compilation or a sync server as a visual/gameplay test. Desktop targets use a private virtual display, never the personal desktop. Native/engine screenshots use the SAME Live app frame and handoff controls as browser review. Human control pauses model observations/input; call handoff before asking for sign-in or manual testing and finish this invocation without polling. Missing capabilities are skipped checks, not passing tests or reasons to stop code work. Keep external services on scoped test bindings. Observe and retain actual captures before delivery. For a runner without MCP, this turn also provides the private shell helper argv ${JSON.stringify([process.execPath, ...heldToolArgv('__runtime-review')])}; pass a JSON request file {name,input}.`,
164
+ finalize() {
165
+ return reports.map(({ source, report }) => `Runtime review ${report.repository}/${report.target}: ${report.status}${report.status === 'observed' ? ' (not a passing gameplay test)' : ''}. ${report.sourceStable && sameReviewSource(report.after, reviewSourceState(source.wt)) ? 'Source matches final tracked/nonignored state.' : 'STALE: source changed or could not be measured; this does not verify final source.'}${report.artifact ? ` Evidence: ${report.artifact}.` : ''}`).join('\n');
166
+ },
167
+ };
168
+ }
169
+
170
+ async function requestRuntime(input) {
171
+ const response = await fetch(process.env.FLOWVIANT_RUNTIME_REVIEW_URL, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_RUNTIME_REVIEW_CAPABILITY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal: AbortSignal.timeout(615_000) });
172
+ if (!response.ok) throw new Error('The held runtime turn has ended.');
173
+ return response.json();
174
+ }
175
+ export async function runtimeReviewMain(mcp = false) {
176
+ if (mcp) return serveMcpStdio({ serverInfo: { name: RUNTIME_REVIEW_SERVER, version: '1.0.0' }, tools: TOOLS, rawResult: true, call: (name, input) => requestRuntime({ name, input }) });
177
+ try {
178
+ const { readFileSync } = await import('node:fs');
179
+ const result = await requestRuntime(JSON.parse(readFileSync(process.argv[3], 'utf8')));
180
+ // Shell tools are text transports. Never flood a model context with image
181
+ // base64; observe retains a local artifact that its image tool can inspect.
182
+ const content = result.content?.map(block => block.type === 'image' ? { type: 'text', text: 'Image shown in Live app. Use observe to retain it, then open the reported artifact with an available image tool. Capture alone does not establish visual inspection.' } : block);
183
+ process.stdout.write(JSON.stringify({ ...result, ...(content ? { content } : {}) }) + '\n'); process.exitCode = result.isError ? 1 : 0;
184
+ } catch (error) { process.stderr.write(error.message + '\n'); process.exitCode = 1; }
185
+ }
@@ -12,6 +12,7 @@ import { procStat } from './procStat.mjs';
12
12
  import { killGroup } from './processGroup.mjs';
13
13
  import { unattendedEnv } from './noWindowEnv.mjs';
14
14
  import { childEnv } from './childEnv.mjs';
15
+ import { environmentOverlay, readWorktreeEnvironment } from './reviewEnvironment.mjs';
15
16
  import { gitRaw } from './git.mjs';
16
17
  import { serveMcpStdio } from './mcpStdio.mjs';
17
18
  import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
@@ -172,8 +173,10 @@ export async function createUiReview({ wt, signal, capture = captureLocalReview,
172
173
  record.folder = `${ARTIFACT_DIR}/${name}`;
173
174
  record.artifact = `${record.folder}/report.json`;
174
175
  scratch = mkdtempSync(join(tmpdir(), 'flowviant-ui-preview-'));
175
- const env = unattendedEnv(childEnv({ cwd }), { browserHome: scratch });
176
- // No inherited personal home, SDK credentials or browser sessions.
176
+ const environment = readWorktreeEnvironment(wt);
177
+ const env = unattendedEnv({ ...childEnv({ cwd }), ...(environment ? environmentOverlay(environment) : {}) }, { browserHome: scratch });
178
+ // Only explicit scoped bindings join the minimal preview environment;
179
+ // no inherited personal home, SDK credentials or browser sessions.
177
180
  env.HOME = scratch; env.USERPROFILE = scratch; env.XDG_CONFIG_HOME = scratch;
178
181
  env.XDG_DATA_HOME = scratch; env.XDG_CACHE_HOME = scratch;
179
182
  let output = '';
package/bin/lib/work.mjs CHANGED
@@ -67,6 +67,8 @@ import { placeTurnLockedByLivePid } from './turnLock.mjs';
67
67
  import { createWorkLocalCleanup } from './workLocalCleanup.mjs';
68
68
  import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
69
69
  import { trackedRepositorySetup } from './repositorySetup.mjs';
70
+ import { sweepWorktreeEnvironments } from './reviewEnvironment.mjs';
71
+ import { workLanesStopped } from './standDownGate.mjs';
70
72
  import { createRepositoryPlaces } from './repositoryPlaces.mjs';
71
73
  import { createAgentRepositories } from './agentRepositories.mjs';
72
74
  import { createWorkProcesses } from './workProcesses.mjs';
@@ -395,6 +397,7 @@ export function createWorkManager({
395
397
  // The check and the pre-review are built with only what each one uses; the
396
398
  // review entry beat is handed the two and owns only their order.
397
399
  const { runCheck } = createWorkAgentCheck({
400
+ baseRef,
398
401
  repoRoot,
399
402
  postBestEffort,
400
403
  workChildren,
@@ -461,6 +464,23 @@ export function createWorkManager({
461
464
  sweepMergedSessionBranch,
462
465
  });
463
466
 
467
+ let environmentSweep = null, lastEnvironmentSweep = 0;
468
+ const reconcileEnvironments = () => {
469
+ if (environmentSweep || workLanesStopped() || Date.now() - lastEnvironmentSweep < 60_000) return;
470
+ lastEnvironmentSweep = Date.now();
471
+ environmentSweep = (async () => {
472
+ const roots = [...new Set(repositories?.list().map(r => r.repoRoot).filter(Boolean) ?? [repoRoot])];
473
+ for (const root of roots) {
474
+ if (workLanesStopped()) break;
475
+ const result = await sweepWorktreeEnvironments(root, {
476
+ onSpawn: ch => { workChildren.set(ch, null); groupKillChildren.add(ch); },
477
+ onClose: ch => { workChildren.delete(ch); groupKillChildren.delete(ch); },
478
+ });
479
+ for (const failure of result.errors) warn(envScrub(`Environment ${failure.id}: ${failure.error}`));
480
+ }
481
+ })().catch(error => warn(envScrub(error.message))).finally(() => { environmentSweep = null; });
482
+ };
483
+
464
484
  const { processKillJobs } = createWorkProcesses({
465
485
  placeDir,
466
486
  reportPlaceWorktrees,
@@ -498,7 +518,7 @@ export function createWorkManager({
498
518
  workChildren.size > 0 ||
499
519
  workAnswering.size > 0 ||
500
520
  pendingWorkReports.size > 0 ||
501
- pendingShipReports.size > 0 || cleanupBusy();
521
+ pendingShipReports.size > 0 || cleanupBusy() || environmentSweep !== null;
502
522
 
503
523
  return {
504
524
  flushWorkReports,
@@ -514,6 +534,7 @@ export function createWorkManager({
514
534
  retirePreviews,
515
535
  shutdownPreviews,
516
536
  retireWorkSessions,
537
+ reconcileEnvironments,
517
538
  reportWorktrees,
518
539
  reconcileIdleArtifacts,
519
540
  reconcileLocalCleanup,
@@ -21,6 +21,9 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
21
21
  import { agentBrowserHome, openBrowserHome, unattendedEnv } from './noWindowEnv.mjs';
22
22
  import { fleetEndpoint } from './fleetWire.mjs';
23
23
  import { workLanesStopped } from './standDownGate.mjs';
24
+ import { setupRepository } from './repositorySetup.mjs';
25
+ import { readEnvironmentRecipe } from './reviewEnvironmentConfig.mjs';
26
+ import { holdWorktreeEnvironment } from './reviewEnvironment.mjs';
24
27
 
25
28
  /**
26
29
  * CLEAR THE ARTIFACTS DIRECTORY OF ANYTHING THAT IS NOT AN ARTIFACT, BEFORE
@@ -135,6 +138,7 @@ export function readConfiguredCheck(root) {
135
138
 
136
139
  export function createWorkAgentCheck({
137
140
  repoRoot,
141
+ baseRef = () => 'HEAD',
138
142
  postBestEffort,
139
143
  workChildren,
140
144
  groupKillChildren,
@@ -184,6 +188,24 @@ export function createWorkAgentCheck({
184
188
  await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
185
189
  return { status: 'none' };
186
190
  }
191
+ let environment = null, releaseEnvironment = () => {};
192
+ try {
193
+ const root = repository?.repoRoot ?? repoRoot;
194
+ const snapshot = readEnvironmentRecipe(root, repository?.baseRef ?? baseRef());
195
+ if (snapshot) {
196
+ // A combined merge has a different worktree/database from its input
197
+ // agents. Migrate its CURRENT integrated source before its checks.
198
+ environment = await setupRepository(root, wt, { snapshot,
199
+ onSpawn: ch => { workChildren.set(ch, agentId); groupKillChildren.add(ch); },
200
+ onClose: ch => { workChildren.delete(ch); groupKillChildren.delete(ch); } });
201
+ releaseEnvironment = holdWorktreeEnvironment(environment);
202
+ }
203
+ } catch (error) {
204
+ const text = envScrub(error.message);
205
+ await postCheck({ agentId, status: 'failed', output: text, ...(headSha ? { headSha } : {}) });
206
+ return { status: 'failed', text };
207
+ }
208
+ try {
187
209
  const cleared = clearNonArtifacts(wt);
188
210
  if (cleared.length)
189
211
  warn(
@@ -241,7 +263,7 @@ export function createWorkAgentCheck({
241
263
  shell: true,
242
264
  detached: true,
243
265
  stdio: ['ignore', 'pipe', 'pipe'],
244
- env: checkEnv(process.env, browser.dir),
266
+ env: checkEnv(environment?.env ?? process.env, browser.dir),
245
267
  });
246
268
  /**
247
269
  * …AND IT IS TRACKED, so a stop or a takeover takes it with them.
@@ -330,6 +352,7 @@ export function createWorkAgentCheck({
330
352
  ...(headSha ? { headSha } : {}),
331
353
  });
332
354
  return out;
355
+ } finally { releaseEnvironment(); }
333
356
  };
334
357
 
335
358
  return { runCheck };
@@ -28,6 +28,7 @@ import { machineMcpServers } from './claudeMachineMcp.mjs';
28
28
  import { ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
29
29
  import { VIDEO_SERVER } from './videoRender.mjs';
30
30
  import { BROWSER_SERVER } from './managedBrowser.mjs';
31
+ import { RUNTIME_REVIEW_SERVER } from './runtimeReview.mjs';
31
32
 
32
33
  /** The servers the daemon itself mounts on a card turn, by the names the
33
34
  * agent calls them: a multi-repository card's add_repository, an
@@ -35,7 +36,7 @@ import { BROWSER_SERVER } from './managedBrowser.mjs';
35
36
  * render_video (2026-10-26), and a code turn's managed browser (2026-10-30).
36
37
  * Never a project server's, whatever the base
37
38
  * `.mcp.json` names. */
38
- export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER, browser: BROWSER_SERVER });
39
+ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER, browser: BROWSER_SERVER, runtime: RUNTIME_REVIEW_SERVER });
39
40
 
40
41
  /**
41
42
  * The card turn's tools, prepared, and what it is told of them.
@@ -48,13 +49,13 @@ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositorie
48
49
  * from here), the note, the mount's part of the CLI's `mcpEnv` and whether it
49
50
  * needs one, and the keys the runner takes the mount as.
50
51
  */
51
- export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, browserConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
52
+ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, browserConfig = null, runtimeConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
52
53
  // A turn reads the base ref afresh. Changes the agent has made to its
53
54
  // own worktree cannot silently change the next turn's tools.
54
55
  const source = sources.find((r) => r.wt === wt);
55
- const snapshot = readBaseTools(source?.repoRoot ?? repoRoot, source?.baseRef ?? baseRef());
56
+ const snapshot = readBaseTools(source?.repoRoot ?? repoRoot, source?.baseRef ?? baseRef(), env);
56
57
  for (const other of sources.filter((r) => r !== source)) {
57
- const base = readBaseTools(other.repoRoot, other.baseRef);
58
+ const base = readBaseTools(other.repoRoot, other.baseRef, env);
58
59
  snapshot.instructions.push(...[...base.instructions, ...base.skillFiles].map((file) => ({ ...file, path: `repositories/${other.folder}/${file.path}` })));
59
60
  }
60
61
  const projectTools = prepareAgentTools(snapshot, rt, env, wt, { codexHome, fenced: posture !== 'build', turnServers: {
@@ -62,6 +63,7 @@ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources =
62
63
  ...(orchestratorConfig ? { [CARD_TURN_SERVERS.orchestrator]: orchestratorConfig } : {}),
63
64
  ...(posture === 'build' && uiReviewConfig ? { [CARD_TURN_SERVERS.uiReview]: uiReviewConfig } : {}),
64
65
  ...(posture === 'build' && browserConfig ? { [CARD_TURN_SERVERS.browser]: browserConfig } : {}),
66
+ ...(posture === 'build' && runtimeConfig ? { [CARD_TURN_SERVERS.runtime]: runtimeConfig } : {}),
65
67
  // A video card's render tool rides its fenced posture (videoRender.mjs).
66
68
  ...(videoConfig ? { [CARD_TURN_SERVERS.video]: videoConfig } : {}),
67
69
  } });
@@ -73,6 +73,11 @@ import { createVideoRender } from './videoRender.mjs';
73
73
  import { createManagedBrowser } from './managedBrowser.mjs';
74
74
  import { completeBrowserReview, browserHandoffPrompt } from './browserHandoff.mjs';
75
75
  import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
76
+ import { setupRepository } from './repositorySetup.mjs';
77
+ import { readEnvironmentRecipe } from './reviewEnvironmentConfig.mjs';
78
+ import { environmentInstructions, environmentOverlay, holdWorktreeEnvironment } from './reviewEnvironment.mjs';
79
+ import { environmentCredentialNames } from './reviewDatabase.mjs';
80
+ import { createRuntimeReview } from './runtimeReview.mjs';
76
81
 
77
82
  export function createAgentTurnExecution({
78
83
  admit,
@@ -105,6 +110,7 @@ export function createAgentTurnExecution({
105
110
  makeUiReview = createUiReview,
106
111
  makeVideoRender = createVideoRender,
107
112
  makeManagedBrowser = createManagedBrowser,
113
+ makeRuntimeReview = createRuntimeReview,
108
114
  }) {
109
115
  const reportDelivery = createDeliveryReporter();
110
116
  /** How long the final flush may hold the settle. Bounded because the settle
@@ -126,6 +132,7 @@ export function createAgentTurnExecution({
126
132
  * `interruptAgentTurns`); absent, nothing can interrupt it. */
127
133
  const runAgentTurnInPlace = async (job, { turnId, agentId, place, releaseSlot, postAgentTurn, measured, run = { interrupted: false } }) => {
128
134
  let repositoryRun = null;
135
+ const environmentReleases = [];
129
136
  const setupController = new AbortController();
130
137
  run.abortSetup = () => setupController.abort();
131
138
  if (run.interrupted) setupController.abort();
@@ -417,6 +424,8 @@ export function createAgentTurnExecution({
417
424
  let browserNote = '';
418
425
  let uiReviewNote = '';
419
426
  let uiReviewEvidence = '';
427
+ let environmentNote = '', reviewEnv = null;
428
+ let runtimeReview = null, runtimeNote = '', runtimeEvidence = '';
420
429
  // A video card's render tool (videoRender.mjs), and the words a turn
421
430
  // gets when it could not be held, so it stops rather than guesses.
422
431
  let videoRender = null;
@@ -430,6 +439,8 @@ export function createAgentTurnExecution({
430
439
  args = withUiReview(args, uiReviewNote, uiReview?.env);
431
440
  args = withUiReview(args, videoNote);
432
441
  args = withUiReview(args, browserNote, managedBrowser?.env);
442
+ args = withUiReview(args, environmentNote, reviewEnv);
443
+ args = withUiReview(args, runtimeNote, runtimeReview?.env);
433
444
  args.managedBrowser = Boolean(managedBrowser);
434
445
  return runTurnImpl(args);
435
446
  };
@@ -480,6 +491,22 @@ export function createAgentTurnExecution({
480
491
  let orchestrator = null;
481
492
  let intermediateDelivery = null;
482
493
  try {
494
+ if (posture === 'build') {
495
+ const sources = repositoryRun?.sources ?? [{ wt, repoRoot: worktreeRepo, baseRef: baseRef() }];
496
+ const administrativeNames = new Set();
497
+ for (const source of sources) {
498
+ const snapshot = readEnvironmentRecipe(source.repoRoot, source.baseRef);
499
+ if (!snapshot) continue;
500
+ const environment = await setupRepository(source.repoRoot, source.wt, { snapshot, signal: setupController.signal,
501
+ onSpawn: ch => { workChildren.set(ch, agentId); groupKillChildren?.add(ch); },
502
+ onClose: ch => { workChildren.delete(ch); groupKillChildren?.delete(ch); } });
503
+ environmentReleases.push(holdWorktreeEnvironment(environment));
504
+ environmentNote += '\n' + environmentInstructions(environment);
505
+ for (const name of environmentCredentialNames(snapshot.recipe)) administrativeNames.add(name);
506
+ if (source.wt === wt) reviewEnv = environmentOverlay(environment);
507
+ }
508
+ if (administrativeNames.size) reviewEnv = { ...reviewEnv, ...Object.fromEntries([...administrativeNames].map(name => [name, undefined])) };
509
+ }
483
510
  if (posture === 'build' && job.kind !== 'merge_resolve' && ['claude', 'codex'].includes(rt)) {
484
511
  try {
485
512
  managedBrowser = await makeManagedBrowser({ wt, turnId, signal: setupController.signal, onChild: ch => {
@@ -490,6 +517,14 @@ export function createAgentTurnExecution({
490
517
  } catch (error) { browserNote = `The managed browser could not start: ${envScrub(error.message)}. Report interactive browser checks unavailable.`; }
491
518
  }
492
519
  if (posture === 'build' && job.kind !== 'merge_resolve') {
520
+ try {
521
+ runtimeReview = await makeRuntimeReview({ wt, repoRoot: worktreeRepo, baseRef: baseRef(), sources: repositoryRun?.sources ?? [],
522
+ browser: managedBrowser, signal: setupController.signal, onChild: ch => {
523
+ workChildren.set(ch, agentId); groupKillChildren?.add(ch);
524
+ return () => { workChildren.delete(ch); groupKillChildren?.delete(ch); };
525
+ } });
526
+ runtimeNote = runtimeReview?.instructions ?? '';
527
+ } catch (error) { runtimeNote = `Configured runtime review is unavailable: ${envScrub(error.message)}. Use the repository’s existing checks and report unavailable runtime verification.`; }
493
528
  try {
494
529
  uiReview = await makeUiReview({ wt, signal: setupController.signal, onChild: ch => {
495
530
  workChildren.set(ch, agentId); groupKillChildren?.add(ch);
@@ -515,7 +550,9 @@ export function createAgentTurnExecution({
515
550
  notify: message => liveInput ? run.live?.inject(message) === true : false,
516
551
  taken: id => run.live?.onTaken(id) });
517
552
  }
518
- const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, videoConfig: videoRender?.config ?? null, browserConfig: managedBrowser?.config, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome });
553
+ const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, videoConfig: videoRender?.config ?? null, browserConfig: managedBrowser?.config, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome,
554
+ runtimeConfig: runtimeReview?.config,
555
+ ...(reviewEnv ? { env: { ...process.env, ...reviewEnv } } : {}) });
519
556
  projectTools = tools.projectTools;
520
557
  localCleanup?.begin(turnId);
521
558
  const { toolsNote } = tools;
@@ -794,12 +831,15 @@ export function createAgentTurnExecution({
794
831
  // owned by its review tool, then bind evidence to the final source
795
832
  // before the existing artifact upload and delivery settlement.
796
833
  try { await videoRender?.close(); } catch { /* a closed listener */ }
834
+ try { await runtimeReview?.close(); } catch { /* turn teardown continues */ }
797
835
  try { await managedBrowser?.close(); } catch { /* turn teardown continues */ }
798
836
  try { await uiReview?.close(); }
799
837
  finally {
800
838
  try { uiReviewEvidence = uiReview?.finalize() ?? uiReviewEvidence; }
801
839
  catch (error) { uiReviewEvidence = `Local UI review final source/evidence measurement unavailable: ${envScrub(error.message)}. Earlier checks do not establish the final source.`; }
802
840
  }
841
+ try { runtimeEvidence = runtimeReview?.finalize() ?? ''; }
842
+ catch { runtimeEvidence = 'Runtime review final source could not be measured; earlier results do not verify final source.'; }
803
843
  await run.live?.stop();
804
844
  await orchestrator?.close(run.interrupted);
805
845
  await brief?.stop();
@@ -910,6 +950,7 @@ export function createAgentTurnExecution({
910
950
  modelExports: intermediateDelivery?.modelExports,
911
951
  });
912
952
  appendUiReviewEvidence(body, uiReviewEvidence);
953
+ appendUiReviewEvidence(body, runtimeEvidence);
913
954
  // The agent's own account of what it did not deliver, for the pre-review.
914
955
  if (job.kind === 'task' && job.task && body.outcome === 'delivered' && res?.gaps)
915
956
  stashGaps(sessionMetaPath(wt, 'flowviant-agent-cards', agentId), job.task.id, res.gaps);
@@ -965,7 +1006,10 @@ export function createAgentTurnExecution({
965
1006
  // hold the lock on — see `runReviewEntry`. Only after the settle that may
966
1007
  // say delivered: the three `nothing` settles never opened it.
967
1008
  if (final && reply?.review === true) await runReviewEntry(agentId, wt, job.agentName, undefined, job);
968
- } finally { await repositoryRun?.tool?.close(); }
1009
+ } finally {
1010
+ for (const release of environmentReleases) release();
1011
+ await repositoryRun?.tool?.close();
1012
+ }
969
1013
  };
970
1014
 
971
1015
  return { runAgentTurnInPlace };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.29",
3
+ "version": "0.125.30",
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": {