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.
package/README.md CHANGED
@@ -200,6 +200,370 @@ On Claude Code and Codex build turns, `card_ui_review.inspect_screenshot` can su
200
200
 
201
201
  The agent can fix in-scope findings and rerun up to three attempts per turn, each capped at two minutes including startup. Every attempt stops its owned browser and preview, including on cancellation; teardown also tracks their process groups. Reports and screenshots use `.flowviant/artifacts/`, and the existing delivery Verification retains routes, viewports, actions/outcomes, scrubbed diagnostics and source identity. Tracked/nonignored file bytes and HEAD are remeasured at delivery; later changes mark evidence stale. Ignored build/runtime files are outside that source measurement. The project check, fresh read-only pre-review, human approval and merge remain separate.
202
202
 
203
+ ## Isolated review environments
204
+
205
+ Code agents can provision disposable databases natively. This is opt-in: commit
206
+ `.flowviant/environment.json` on the reviewed base branch. Projects without a
207
+ recipe keep their existing setup. This layer works independently of the model
208
+ and whether the project has a browser preview.
209
+
210
+ For PostgreSQL, use a **dedicated development/review server** with `psql` on the
211
+ daemon's PATH. The administrative role needs permission to create/drop databases,
212
+ create restricted login roles and assign database ownership. Flowviant creates
213
+ an empty database from `template0` and a distinct role for each worktree. It
214
+ does not clone the contents of a shared development or production database.
215
+
216
+ ```json
217
+ {
218
+ "version": 1,
219
+ "database": {
220
+ "provider": "postgres",
221
+ "adminUrlEnv": "REVIEW_DATABASE_ADMIN_URL",
222
+ "bindings": ["DATABASE_URL", "DIRECT_DATABASE_URL"]
223
+ },
224
+ "migrate": ["npm", "run", "db:migrate"],
225
+ "seed": ["npm", "run", "db:seed"],
226
+ "envFiles": [".env.local"],
227
+ "env": {
228
+ "NODE_ENV": "development",
229
+ "CACHE_PREFIX": "review_${ENVIRONMENT_ID}"
230
+ },
231
+ "connectors": ["database"],
232
+ "ttlHours": 24
233
+ }
234
+ ```
235
+
236
+ Use this project's actual migration/fixture scripts and connector names. Commands
237
+ are argv arrays, run without a shell. The migration runner and fixture seeder
238
+ must tolerate retries after partial failure. Each listed project MCP connector
239
+ must reference one of the database bindings, for example `${DATABASE_URL}` in
240
+ its environment configuration. Flowviant expands it from the same scoped values
241
+ the app receives. Unlisted/personal connectors are not rewritten or isolated.
242
+
243
+ Machine-local values belong in the gitignored
244
+ `.flowviant/environment.local.json`, with mode `0600`, or the admin variable can
245
+ be supplied by the daemon's environment. For example:
246
+
247
+ ```json
248
+ {
249
+ "env": {
250
+ "REVIEW_DATABASE_ADMIN_URL": "postgresql://review_admin:LOCAL_PASSWORD@127.0.0.1:5432/postgres",
251
+ "PAYMENTS_API_KEY": "LOCAL_SANDBOX_KEY"
252
+ }
253
+ }
254
+ ```
255
+
256
+ This local file stays in the checkout. Its selected runtime values, excluding
257
+ provider administrator credentials, join the isolated database bindings. Generated
258
+ `.env`/`.dev.vars` files and local receipts are owner-only and excluded from Git.
259
+ Existing/tracked/user-edited env files and symlink destinations are preserved;
260
+ choose another path if one conflicts. Use `envFiles: []` for projects that do not
261
+ load dotenv files; the provided command wrapper injects values directly, including
262
+ complex multiline credentials. A recipe disables automatic copying of the
263
+ checkout's ignored `.env*`, including production profiles.
264
+
265
+ The first configured build allocates the database, runs the existing setup hook
266
+ or lockfile install once, migrates and seeds. Later cards reuse that worktree's
267
+ database, rerun migrations and retain successfully seeded data. The agent gets
268
+ an exact private helper command for `run -- <argv>` and `refresh`. The wrapper
269
+ selects the repository's own bindings even when a multi-repository place has
270
+ several environments. `refresh --seed` explicitly reruns fixtures. The local UI
271
+ self-review and final/combined-merge checks use scoped values too; combined
272
+ worktrees have their own databases and migrate their integrated source before
273
+ validation. These setup receipts do not count as passing application tests.
274
+
275
+ The daemon keeps local allocation intent before contacting a provider, so retries
276
+ reuse one environment id. Active turns/checks hold a heartbeat lease. Its sweep
277
+ releases inactive environments after `ttlHours` (1–168, default 24) or worktree
278
+ retirement; at most two receipts per repository per sweep. Shutdown retains them
279
+ for restart. Cleanup checks database/role ownership before deleting anything;
280
+ failures retain the receipt for retry. A reviewed recipe change rebuilds disposable
281
+ data after the previous environment is released. Moving a worktree retains its id.
282
+ The daemon must be running for this sweep; a remote provider adapter should also
283
+ set/renew its own expiration as protection while the machine is offline.
284
+
285
+ To use a database provider's branches, replace the `database` object with:
286
+
287
+ ```json
288
+ {
289
+ "provider": "command",
290
+ "command": ["node", ".flowviant/database.mjs"],
291
+ "credentials": ["DATABASE_PROVIDER_API_KEY"],
292
+ "source": "SANITIZED_DEVELOPMENT_BRANCH_ID",
293
+ "bindings": ["DATABASE_URL", "DIRECT_DATABASE_URL"]
294
+ }
295
+ ```
296
+
297
+ This is the native provider-adapter lifecycle, not an AI-generated provisioner
298
+ on every card. The reviewed adapter receives `allocate` or `release` as its last
299
+ argument, with the environment id, original test-data source, known resource id
300
+ and expiration in `FLOWVIANT_ENVIRONMENT_ID`, `FLOWVIANT_ENVIRONMENT_SOURCE`,
301
+ `FLOWVIANT_ENVIRONMENT_RESOURCE_ID` and `FLOWVIANT_ENVIRONMENT_EXPIRES_AT`.
302
+ Stdin also carries JSON `{id, resourceId, source}`. Only the named provider
303
+ credentials and minimal toolchain environment are passed. Allocate must look up
304
+ the original id before creating, renew its expiration and verify ownership;
305
+ a create-success/lost-response retry must never create another branch. It returns
306
+ JSON `{owner, resourceId, connectionString}`. Release must verify ownership and
307
+ reconcile by id even if allocation never returned a resource id, then return
308
+ `{owner}`. Both operations must be idempotent. Output is private and never included
309
+ in error messages. Repository adapter files named with `./` or `.flowviant/`
310
+ must still match the saved reviewed blobs, including during cleanup; keep that
311
+ adapter available until its resources are released. No commercial branch SDK
312
+ or provider credentials are installed automatically.
313
+
314
+ An allocate response may also return `bindings`, keyed only by declared database
315
+ variable names, when the provider needs distinct pooled and direct URLs for the
316
+ same branch. Other bindings use `connectionString`. The adapter must verify that
317
+ all of these connections target its owned environment.
318
+
319
+ Database isolation does not configure auth tenants, Redis, queues, object storage,
320
+ email or payment sandboxes. Select their development credentials explicitly and
321
+ namespace them with the environment id where supported. Apps and connector
322
+ processes still run as the user's UID: this is data separation, not a security
323
+ sandbox against repository code reading other local files. An inherited role's
324
+ access to unrelated databases is the PostgreSQL server's policy; use a dedicated
325
+ review server. See PostgreSQL's [database creation documentation](https://www.postgresql.org/docs/current/sql-createdatabase.html).
326
+
327
+ ## Runtime review adapters
328
+
329
+ Commit `.flowviant/review.json` on the project's reviewed base branch to expose
330
+ repeatable review workflows to code agents. One `card_runtime` interface supports
331
+ command checks, browser apps, Linux desktops, Roblox Studio and native/engine bridges. Projects
332
+ without targets mount no additional tools or processes. The agent's existing
333
+ shell and browser workflows remain available.
334
+
335
+ ```json
336
+ {
337
+ "version": 1,
338
+ "targets": {
339
+ "checks": {
340
+ "adapter": "command",
341
+ "command": ["npm", "test"],
342
+ "timeoutSeconds": 120
343
+ },
344
+ "web": {
345
+ "adapter": "browser",
346
+ "command": ["npm", "run", "dev", "--", "--port", "${PORT}"],
347
+ "port": 0,
348
+ "fixture": "Disposable seeded review database and test account"
349
+ },
350
+ "desktop": {
351
+ "adapter": "desktop",
352
+ "platforms": ["linux"],
353
+ "command": ["npm", "run", "desktop:dev"],
354
+ "fixture": "Disposable local workspace and sandbox service bindings"
355
+ }
356
+ }
357
+ }
358
+ ```
359
+
360
+ Use the repository's real commands. A Roblox repository can declare command
361
+ targets for its configured Luau analyzer, standalone logic tests or Rojo build;
362
+ these results do not claim a Studio playtest. The adapters do not install tools
363
+ or infer gameplay from a sync server's listening port. `cwd` defaults to the
364
+ repository root and must resolve inside that worktree. Commands are argv arrays,
365
+ without shell expansion. Timeouts are 1–600 seconds. Browser `port: 0` allocates
366
+ a loopback port and substitutes only `${PORT}`; a fixed port is also supported.
367
+ The listener is attributed to the target worktree before Chrome opens it.
368
+ The runtime MCP server gives Codex a longer tool-call timeout in its private
369
+ build config so the CLI's default does not truncate a configured command.
370
+
371
+ `discover` reports targets and actual prerequisites without launching apps.
372
+ `run` executes a named target; command results retain exit status, bounded logs
373
+ and source identity. A large log stays in the evidence artifact while only its
374
+ tail enters the model context. Browser/desktop/engine targets use the existing
375
+ Live app frame, private sign-in and take/return/stop control. `observe` retains
376
+ the actual screenshot, `act` supplies bounded input, and `handoff` holds the turn
377
+ without model polling. Screenshots and interactions are observations, not passing
378
+ tests. Source changes invalidate earlier command evidence. The shell helper
379
+ supplied to runners without MCP omits image base64 from text output; the model
380
+ opens the retained screenshot with its own image tool if available.
381
+
382
+ Only an interactive target launch creates a browser/display or stream. One
383
+ interactive surface occupies the frame per held turn. Native screenshots run at
384
+ a bounded low cadence, with fresh captures after input; the stream invokes no
385
+ model per frame. Adapter-owned app/process groups stop on explicit runtime Stop,
386
+ target replacement, timeout, abort and turn teardown. The browser frame's Stop
387
+ closes its browser; its supervised server also closes when the runtime tool or
388
+ turn closes. A person's Stop prevents reopening an interactive surface in that
389
+ turn. Commands can run alongside an existing surface without replacing its
390
+ owned server. Artifacts use the existing delivery/storage path.
391
+
392
+ Command checks inherit the normal unattended build environment without the
393
+ machine credential. Interactive apps/helpers start from the minimal toolchain
394
+ environment plus scoped review-environment bindings. A target's optional `env`
395
+ array selects additional variables by name, e.g. `["SANDBOX_API_KEY"]`; values
396
+ stay local and scoped database bindings take precedence. Provider administrator
397
+ credentials remain excluded. Personal/unlisted connectors and other external
398
+ services keep their explicit configuration.
399
+
400
+ ### Linux virtual desktops
401
+
402
+ The first native backend requires Xvfb, xauth, Openbox, xdotool and ImageMagick's
403
+ `import` executable. On Debian/Ubuntu these are supplied by the `xvfb`, `xauth`,
404
+ `openbox`, `xdotool` and `imagemagick` packages. Install them in the daemon's VM
405
+ image; missing tools are reported on the target. The runner never installs them
406
+ automatically or falls back to the user's display. Chrome headless itself does
407
+ not need these desktop packages or a visible desktop session.
408
+
409
+ The backend atomically allocates a private X display, disables its TCP listener
410
+ and creates an owner-only Xauthority cookie. It starts a window manager without
411
+ session autostart, then the declared app, waits for a visible client window and
412
+ focuses it. The app has a fresh home/config/runtime directory and private X11
413
+ display; inherited Wayland/WSLg and personal D-Bus sessions are not used. If an
414
+ app needs D-Bus, use a repository-supported `dbus-run-session -- ...` command.
415
+ Capture/input helpers receive only that display's authority. Handoff drains
416
+ capture work before resuming public frames. Natural app exit ends the surface;
417
+ crashes/timeouts report their actual failure. Teardown kills owned groups and
418
+ removes the temporary home and authority files.
419
+
420
+ The Linux backend runs behind the same stdio bridge used by other host helpers.
421
+ It owns a separate process group and watches its parent pipe: a daemon killed
422
+ without graceful shutdown closes that pipe and the helper reaps its native
423
+ descendants. Normal bridge close first allows cleanup on EOF, with a bounded
424
+ force-stop grace for a stalled helper.
425
+
426
+ V1 supports left clicks, text, the shared bounded navigation keys and vertical
427
+ scrolling at 1440×900. It does not yet provide drag, key holds, sound or native
428
+ accessibility inspection. Apps needing Wayland-only APIs or GPU acceleration
429
+ need an appropriate host/backend; an Xvfb image is not proof those capabilities
430
+ exist. The session isolates display/cookies and avoids accidental personal
431
+ desktop access, but still runs as the operator's UID. Use a dedicated VM/container
432
+ for execution containment and scoped service credentials for data isolation.
433
+
434
+ ### Native and engine bridges
435
+
436
+ A target with `adapter: "bridge"` uses the same UI and control ownership. Its
437
+ `command` starts a reviewed host helper over private newline-delimited JSON-RPC
438
+ 2.0 stdio; `platforms` can restrict where it runs. `configuration` is a bounded
439
+ object passed to the helper alongside the actual `worktree`. Repository helper
440
+ paths spelled `./...` or `.flowviant/...` must match their reviewed base blobs;
441
+ the helper launches from the checkout, with the target worktree in its scope.
442
+
443
+ The protocol methods are `initialize`, `open`, `snapshot`, `act` and `privacy`.
444
+ Initialize receives `{protocol:1, worktree, width:1440, height:900, configuration}`
445
+ and returns `{protocol:1, isolation:"owned-session", privateHandoff:true}` or
446
+ `isolation:"consented-window"`. It must bind that owned session or an explicitly
447
+ consented window. Open receives `{epoch:0}`. Snapshot receives the current epoch
448
+ and returns `{epoch,width:1440,height:900,data:"JPEG_BASE64",observation:"..."}`.
449
+ Act receives `{epoch,input}` with the shared bounded input verbs. Frame
450
+ notifications use method `frame` and params `{epoch,width,height,data}`. Frames
451
+ are bounded to one million base64 characters; no debugging socket is exposed.
452
+ An `ended` notification with the current epoch reports normal application exit;
453
+ `failure` with `{epoch,message}` reports an actual backend error. Helpers must
454
+ release their owned children when stdin closes and when terminated.
455
+ Privacy receives `{private,epoch}` and must drain old captures before replying
456
+ `{epoch,drained:true}`. The broker discards every old-epoch frame. Wrong ownership,
457
+ missing private fences, malformed messages/images and timeouts close the helper.
458
+
459
+ This contract is the extension point for Windows/macOS desktop capture and specialized engines;
460
+ general Windows/macOS desktop capture helpers are not included in this release. Windows capture would
461
+ use an explicitly selected window through Windows Graphics Capture; macOS would
462
+ use ScreenCaptureKit and native input/accessibility permissions. Linux Wayland
463
+ hosts would use their consented portal/PipeWire path. Roblox Studio requires a
464
+ Windows or Mac host; Linux can still edit and check its filesystem sources.
465
+ Engine-specific play/start/assert tools can use project MCP or command targets;
466
+ declaring a bridge alone does not implement those engine semantics. The built-in
467
+ Roblox adapter below uses Studio's own MCP rather than OS desktop capture.
468
+
469
+ ### Roblox Studio through Windows + WSL
470
+
471
+ Use `adapter: "roblox"` for a Windows Studio fixture reached through a reviewed
472
+ stdio launcher in WSL. Studio must already be installed, signed in, and have its
473
+ built-in MCP server enabled. The daemon needs native Linux headless Chromium and
474
+ `flock`; it creates no personal browser window or Windows daemon. No model service
475
+ is added. [Roblox's MCP guide](https://create.roblox.com/docs/studio/mcp) describes
476
+ the built-in server and its available tools.
477
+
478
+ ```json
479
+ {
480
+ "version": 1,
481
+ "targets": {
482
+ "studio": {
483
+ "adapter": "roblox",
484
+ "command": ["./scripts/roblox-studio-mcp"],
485
+ "env": ["ROBLOX_STUDIO_MCP", "WSL_INTEROP", "WSL_DISTRO_NAME"],
486
+ "fixture": "Disposable local place with test services and worktree scripts",
487
+ "configuration": {
488
+ "studioName": "${WORKTREE}.rbxl",
489
+ "startPlay": true,
490
+ "scripts": [
491
+ {
492
+ "file": "src/server/Main.server.luau",
493
+ "path": ["ServerScriptService", "Main"]
494
+ }
495
+ ]
496
+ }
497
+ }
498
+ }
499
+ }
500
+ ```
501
+
502
+ Commit the executable launcher on the reviewed base. For an existing Windows
503
+ StudioMCP executable, a small launcher can use the daemon's selected
504
+ `ROBLOX_STUDIO_MCP` environment variable (a WSL `/mnt/c/.../StudioMCP.exe` path):
505
+
506
+ ```bash
507
+ #!/usr/bin/env bash
508
+ set -euo pipefail
509
+ exec "${ROBLOX_STUDIO_MCP:?Set the installed StudioMCP.exe path on this machine}"
510
+ ```
511
+
512
+ Reuse a working launcher if already configured. Keep stdin/stdout attached to the
513
+ MCP process, without shell banners or `start`; do not launch Studio itself from
514
+ this wrapper. The adapter validates the reviewed launcher bytes before running
515
+ it from the checkout. Machine-specific paths stay in the selected environment.
516
+
517
+ Build/sync a disposable place from the task worktree using the project's actual
518
+ Rojo or Studio workflow, then open it in Windows Studio. `studioName` must equal
519
+ the **exact name returned by `list_roblox_studios`**; `${WORKTREE}` expands to the
520
+ task worktree's folder name. No first-instance or place-id fallback is used.
521
+ In that fixture set the `FlowviantReviewWorktree` attribute on ReplicatedStorage
522
+ to the same folder name, for example in Studio's Command Bar:
523
+
524
+ ```lua
525
+ game:GetService("ReplicatedStorage"):SetAttribute("FlowviantReviewWorktree", "a-TASK-WORKTREE-ID")
526
+ ```
527
+
528
+ The source mappings use worktree-relative Lua/Luau filenames and explicit Studio
529
+ instance path segments. At most forty scripts, each at most 128 KiB, are compared
530
+ before input or play starts; CRLF/LF differences are normalized. Missing,
531
+ ambiguous, escaping or stale mappings and a wrong worktree marker refuse that
532
+ review. Unmapped scripts, assets and other fixtures remain unverified. A marker
533
+ is an explicit fixture binding, not a sandbox or proof of automatic Rojo sync.
534
+
535
+ `run` requires an unambiguous Edit state before starting play and waits for a
536
+ Client player/camera. A pre-existing play session is left alone; `startPlay:false`
537
+ attaches to an already running fixture and never stops that session. A local OS
538
+ lock prevents simultaneous turns using the same fixture name in that daemon
539
+ environment. This does not coordinate other WSL distros or independent MCP clients.
540
+ Owned play receives a temporary `FlowviantReviewSession` Client attribute; the
541
+ adapter rechecks it before input and cleanup, leaving a replacement play session
542
+ alone. This marker is runtime state, not a saved/published place edit.
543
+
544
+ Viewport captures, click/text/navigation-key input, Take control and Return use
545
+ the existing Live app frame. PNG/JPEG images are normalized in a network-disabled
546
+ headless canvas, letterboxed to 1440×900, with click coordinates mapped to the
547
+ measured player viewport. Mismatched aspect ratios are refused. The two-second
548
+ capture cadence makes no model calls. Screenshots and input are observations;
549
+ they do not assert gameplay passed. The first version uses the shared bounded
550
+ input vocabulary, without WASD/key holds, mouse drag or full Studio editor control.
551
+
552
+ Use privately fences old captures; console history is omitted after any private
553
+ control. Studio's Windows login/MFA dialogs are outside the viewport and must be
554
+ completed on the Windows host. Stop review leaves the Studio editor open. The
555
+ helper watches parent EOF and on normal Stop, abort or daemon death attempts to
556
+ stop only play it started, with bounded cleanup time before its processes are
557
+ forced down. A disconnected/unresponsive MCP server can prevent that final stop;
558
+ the editor and any remaining play session stay on the Windows host.
559
+
560
+ Studio must already have disposable/namespaced DataStores and sandbox service
561
+ bindings. Flowviant's database environment does not rewrite Roblox DataStore,
562
+ Open Cloud, HTTP or game-universe configuration. This adapter does not install
563
+ Studio, publish a place, upload assets, invoke Roblox's AI subagents, import
564
+ personal auth or silently select a live game. Without Studio MCP, Lua changes
565
+ still produce the usual code diff and configured command-check evidence.
566
+
203
567
  ## Modes
204
568
 
205
569
  | Env | What runs |
package/bin/cli.mjs CHANGED
@@ -80,6 +80,12 @@
80
80
  // answered here before anything else loads: no config, no update check, no
81
81
  // output but the hook's. An unknown hook exits 2, which Claude Code reads as a
82
82
  // refusal — the read guard's own rule for a hook that cannot judge.
83
+ if (process.argv[2] === '__environment') {
84
+ await (await import('./lib/reviewEnvironmentMain.mjs')).reviewEnvironmentMain();
85
+ await new Promise(resolve => process.stdout.write('', resolve));
86
+ process.exit(process.exitCode ?? 0);
87
+ }
88
+
83
89
  if (process.argv[2] === '__capture-mcp') {
84
90
  await (await import('./lib/captureAuthority.mjs')).captureMcpMain();
85
91
  process.exit(process.exitCode ?? 0);
@@ -98,6 +104,23 @@ if (process.argv[2] === '__managed-browser-mcp') {
98
104
  process.exit(0);
99
105
  }
100
106
 
107
+ if (['__runtime-review', '__runtime-review-mcp'].includes(process.argv[2])) {
108
+ await (await import('./lib/runtimeReview.mjs')).runtimeReviewMain(process.argv[2] === '__runtime-review-mcp');
109
+ await new Promise(resolve => process.stdout.write('', resolve));
110
+ process.exit(process.exitCode ?? 0);
111
+ }
112
+
113
+ if (process.argv[2] === '__linux-desktop-bridge') {
114
+ await (await import('./lib/linuxDesktopBridge.mjs')).linuxDesktopBridgeMain();
115
+ process.exit(process.exitCode ?? 0);
116
+ }
117
+
118
+ if (process.argv[2] === '__roblox-studio-bridge') {
119
+ await (await import('./lib/robloxStudioBridge.mjs')).robloxStudioBridgeMain();
120
+ await new Promise(resolve => process.stdout.write('', resolve));
121
+ process.exit(process.exitCode ?? 0);
122
+ }
123
+
101
124
  if (process.argv[2] === '__video-render-mcp') {
102
125
  await (await import('./lib/videoRender.mjs')).videoRenderMain();
103
126
  process.exit(process.exitCode ?? 0);
@@ -0,0 +1,44 @@
1
+ /** Bounded, tracked setup children. Secrets travel in env/stdin, never argv. */
2
+ import { spawn } from 'node:child_process';
3
+ import { killGroup } from './processGroup.mjs';
4
+ import { unattendedEnv } from './noWindowEnv.mjs';
5
+ import { workLanesStopped } from './standDownGate.mjs';
6
+ import { scrub } from './uplinkScrub.mjs';
7
+
8
+ export function environmentCommand(argv, { cwd, env, input = '', signal, onSpawn = () => {}, onClose = () => {}, timeoutMs = 600_000, label = 'Environment command', captureOutput = true, includeDiagnostics = false } = {}) {
9
+ signal?.throwIfAborted();
10
+ if (workLanesStopped()) throw new Error('The daemon is stopping. Environment setup has not started.');
11
+ return new Promise((resolve, reject) => {
12
+ let child, timer, settled = false, output = '', diagnostics = '';
13
+ const diagnostic = chunk => { diagnostics = scrub(diagnostics + chunk.toString()).slice(-2000); };
14
+ const failed = message => new Error(message + (includeDiagnostics && diagnostics ? '\n' + diagnostics : ''));
15
+ const abort = () => { killGroup(child); finish(signal?.reason ?? new Error(`${label} was interrupted.`)); };
16
+ const finish = (error) => {
17
+ if (settled) return;
18
+ settled = true; clearTimeout(timer);
19
+ signal?.removeEventListener('abort', abort);
20
+ if (child) onClose(child);
21
+ error ? reject(error) : resolve(output);
22
+ };
23
+ try {
24
+ child = spawn(argv[0], argv.slice(1), { cwd, env: unattendedEnv({ ...env, GIT_TERMINAL_PROMPT: '0' }), detached: true, stdio: ['pipe', 'pipe', 'pipe'] });
25
+ child.once('error', () => finish(new Error(`${label} could not start (${argv[0]}). Check that it is installed.`)));
26
+ child.once('close', code => finish(code === 0 ? null : failed(`${label} failed (exit ${code ?? 'signal'}).`)));
27
+ // Provider operations leave diagnostics off: output can contain newly
28
+ // minted credentials even on failure. Runtime hooks retain scrubbed tails.
29
+ if (includeDiagnostics) child.stderr.on('data', diagnostic);
30
+ else child.stderr.resume();
31
+ child.stdout.on('data', chunk => {
32
+ if (!captureOutput) { if (includeDiagnostics) diagnostic(chunk); return; }
33
+ output += chunk;
34
+ if (Buffer.byteLength(output) > 256 * 1024) { killGroup(child); finish(new Error(`${label} returned too much output.`)); }
35
+ });
36
+ child.stdin.on('error', () => {});
37
+ child.stdin.end(input);
38
+ onSpawn(child);
39
+ timer = setTimeout(() => { killGroup(child); finish(failed(`${label} timed out.`)); }, timeoutMs);
40
+ signal?.addEventListener('abort', abort, { once: true });
41
+ if (signal?.aborted) abort();
42
+ } catch (error) { if (child) killGroup(child); finish(error); }
43
+ });
44
+ }
package/bin/lib/fleet.mjs CHANGED
@@ -276,6 +276,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
276
276
  retirePreviews,
277
277
  shutdownPreviews,
278
278
  retireWorkSessions,
279
+ reconcileEnvironments,
279
280
  reportWorktrees,
280
281
  reconcileIdleArtifacts,
281
282
  reconcileLocalCleanup,
@@ -597,6 +598,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
597
598
  // winner is working in — absence would mean "somebody else won" instead of
598
599
  // "the tab closed".
599
600
  retireWorkSessions(roster.activeWorkSessions, roster.sessionsHeldElsewhere);
601
+ reconcileEnvironments();
600
602
  // Diffs somebody has open and is waiting on. Project-scoped rather than
601
603
  // per-session: `git show` runs from the repo ROOT, which can see a closed
602
604
  // tab's branch and a shipped commit on main alike.
@@ -0,0 +1,62 @@
1
+ /** Linux host helper: parent EOF owns the lifetime of its whole process group. */
2
+ import { createInterface } from 'node:readline';
3
+ import { openBridgePage } from './reviewBridge.mjs';
4
+ import { openDesktopPage } from './reviewDesktop.mjs';
5
+ import { heldToolArgv } from './heldTool.mjs';
6
+ import { killGroup } from './processGroup.mjs';
7
+ import { procStat } from './procStat.mjs';
8
+ import { reviewDiagnostic } from './uiReviewBrowser.mjs';
9
+
10
+ export function openLinuxDesktop({ command, cwd, env, timeoutMs, signal, onChild, onFrame, onChange }) {
11
+ return openBridgePage({ command: [process.execPath, ...heldToolArgv('__linux-desktop-bridge')], cwd, env, worktree: cwd,
12
+ configuration: { command, cwd, timeoutMs }, timeoutMs, signal, onChild, onFrame, onChange });
13
+ }
14
+
15
+ export async function linuxDesktopBridgeMain() {
16
+ // Never group-kill a caller's terminal if somebody manually invokes this
17
+ // private verb. The parent starts the helper detached as its group leader.
18
+ if (process.platform !== 'linux' || procStat(process.pid)?.pgrp !== process.pid) throw new Error('The desktop helper needs its own Linux process group.');
19
+ const controller = new AbortController();
20
+ let page, configuration, epoch = 0, closing = false;
21
+ const send = value => { if (!closing) process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n'); };
22
+ const shutdown = () => {
23
+ if (closing) return;
24
+ closing = true; controller.abort(); page?.close();
25
+ // All native descendants inherit this group; this also reaps descendants
26
+ // of an app launcher after a killed daemon closes the private stdin pipe.
27
+ setTimeout(() => killGroup(process.pid), 50);
28
+ };
29
+ process.stdin.once('end', shutdown); process.stdin.once('error', shutdown);
30
+ process.stdout.once('error', shutdown);
31
+ process.once('SIGTERM', shutdown); process.once('SIGINT', shutdown);
32
+ let bytes = 0;
33
+ try {
34
+ for await (const line of createInterface({ input: process.stdin })) {
35
+ if (closing) break;
36
+ let request;
37
+ try {
38
+ bytes = Buffer.byteLength(line); if (bytes > 128 * 1024) throw new Error('Desktop helper request exceeds its byte bound.');
39
+ request = JSON.parse(line);
40
+ const { id, method, params } = request;
41
+ let result = {};
42
+ if (method === 'initialize') {
43
+ configuration = params.configuration;
44
+ if (params.protocol !== 1 || !configuration || !Array.isArray(configuration.command) || configuration.cwd !== params.worktree) throw new Error('Invalid scoped desktop initialization.');
45
+ result = { protocol: 1, isolation: 'owned-session', privateHandoff: true };
46
+ } else if (method === 'open' && configuration && !page) {
47
+ page = await openDesktopPage({ ...configuration, env: process.env, signal: controller.signal, detachedChildren: false,
48
+ onFrame: frame => send({ method: 'frame', params: { ...frame, epoch } }),
49
+ onChange: patch => send({ method: patch.ended ? 'ended' : 'failure', params: { epoch, message: patch.error } }) });
50
+ } else if (method === 'snapshot' && page) result = { ...await page.snapshot(), width: 1440, height: 900, epoch };
51
+ else if (method === 'act' && page) await page.act(params.input);
52
+ else if (method === 'privacy' && page) { await page.setPrivate(params.private); epoch = params.epoch; result = { epoch, drained: true }; }
53
+ else throw new Error('Unsupported desktop helper operation.');
54
+ send({ id, result });
55
+ } catch (error) { send({ id: request?.id, error: { message: reviewDiagnostic(error.message) } }); }
56
+ }
57
+ } finally {
58
+ shutdown();
59
+ // Do not return to cli.mjs's process.exit before the group cleanup runs.
60
+ await new Promise(resolve => setTimeout(resolve, 100));
61
+ }
62
+ }