flowviant 0.97.0 → 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +15 -13
  2. package/bin/cli.mjs +108 -28
  3. package/bin/lib/agentPlan.mjs +159 -37
  4. package/bin/lib/artifacts.mjs +136 -26
  5. package/bin/lib/authReject.mjs +32 -0
  6. package/bin/lib/authproxy.mjs +52 -16
  7. package/bin/lib/childEnv.mjs +33 -1
  8. package/bin/lib/claude.mjs +166 -71
  9. package/bin/lib/config.mjs +36 -9
  10. package/bin/lib/credentials.mjs +29 -5
  11. package/bin/lib/deploy.mjs +364 -30
  12. package/bin/lib/desktopContract.mjs +227 -0
  13. package/bin/lib/env.mjs +130 -13
  14. package/bin/lib/fleet.mjs +121 -30
  15. package/bin/lib/git.mjs +131 -3
  16. package/bin/lib/hooks/readGuard.mjs +24 -0
  17. package/bin/lib/instance.mjs +129 -14
  18. package/bin/lib/knowledge.mjs +180 -60
  19. package/bin/lib/landed.mjs +38 -30
  20. package/bin/lib/launchCommand.mjs +23 -0
  21. package/bin/lib/listeners.mjs +211 -0
  22. package/bin/lib/localSessions.mjs +29 -4
  23. package/bin/lib/login.mjs +36 -12
  24. package/bin/lib/machines.mjs +24 -14
  25. package/bin/lib/mcp-cli.mjs +4 -3
  26. package/bin/lib/preflight.mjs +2 -1
  27. package/bin/lib/preview.mjs +95 -6
  28. package/bin/lib/printable.mjs +42 -0
  29. package/bin/lib/procRegistry.mjs +65 -0
  30. package/bin/lib/processes.mjs +20 -7
  31. package/bin/lib/projectTools.mjs +248 -0
  32. package/bin/lib/prompts.mjs +16 -11
  33. package/bin/lib/resources.mjs +46 -4
  34. package/bin/lib/runtimeOfferings.mjs +75 -0
  35. package/bin/lib/runtimes.mjs +71 -9
  36. package/bin/lib/shipMerge.mjs +8 -4
  37. package/bin/lib/trace.mjs +16 -6
  38. package/bin/lib/update.mjs +114 -25
  39. package/bin/lib/work.mjs +1192 -4584
  40. package/bin/lib/workAgentMerges.mjs +504 -0
  41. package/bin/lib/workAgentPlans.mjs +447 -0
  42. package/bin/lib/workAgentReview.mjs +702 -0
  43. package/bin/lib/workAgentTurns.mjs +1151 -0
  44. package/bin/lib/workDiffs.mjs +107 -0
  45. package/bin/lib/workPreviews.mjs +351 -0
  46. package/bin/lib/workProcesses.mjs +236 -0
  47. package/bin/lib/workPullRequests.mjs +313 -0
  48. package/bin/lib/workShip.mjs +385 -0
  49. package/bin/lib/worktreeDiff.mjs +99 -43
  50. package/package.json +2 -3
package/README.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  Run your own coding CLIs as build agents for [Flowviant](https://flowviant.com) — [Claude Code](https://claude.com/claude-code), Codex or Antigravity, on your own credentials. This daemon holds your sessions, keeps a worktree per tab, and ships branches on your word. Flowviant never sees your Claude, Codex or GitHub logins.
4
4
 
5
- ```bash
6
- npx flowviant@latest login # approve the code in Flowviant → connected
5
+ ```sh
6
+ curl -fsSL https://api.flowviant.com/install.sh | sh
7
+ flowviant # approve the code in Flowviant → connected
7
8
  ```
8
9
 
9
10
  Login keeps going straight into the daemon — there is no second command to run.
@@ -21,40 +22,39 @@ Because it drives the CLIs you're already logged into, **the cost is yours** (yo
21
22
  On the machine that runs the daemon:
22
23
 
23
24
  - **at least one coding CLI** installed and signed in — [Claude Code](https://claude.com/claude-code) (`claude`), Codex (`codex`) or Antigravity (`agy`)
24
- - **git**, and **Node 20+**
25
+ - **git** (Node 20+ is needed only for a Node-based install)
25
26
  - **[GitHub CLI](https://cli.github.com)** (`gh`) — optional; the daemon offers to fetch an isolated copy, and `flowviant gh-auth` signs it in
26
27
  - run it from inside the git repository you want worked
27
28
 
28
29
  ## Connecting
29
30
 
30
- The easy way — device login, like `gh auth login`:
31
+ Download the standalone binary, then start it in your checkout:
31
32
 
32
- ```bash
33
- npx flowviant@latest login
33
+ ```sh
34
+ curl -fsSL https://api.flowviant.com/install.sh | sh
35
+ flowviant
34
36
  ```
35
37
 
36
- It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `npx flowviant@latest` just runs.
38
+ It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `flowviant` just runs. The installer prints a PATH hint if needed; `FLOWVIANT_INSTALL_DIR` changes its destination. It verifies SHA-256 and never uses sudo.
37
39
 
38
40
  Prefer an explicit token? Create a machine credential in the app and pass it directly:
39
41
 
40
42
  ```bash
41
- FLOWVIANT_MACHINE_TOKEN=fva_… npx flowviant@latest # FLOWVIANT_FLEET still works
43
+ FLOWVIANT_MACHINE_TOKEN=fva_… flowviant # FLOWVIANT_FLEET still works
42
44
  ```
43
45
 
44
- Prefer a plain command to `npx`? Install it once and every `npx flowviant@latest` in this README is just `flowviant`:
46
+ With Node already installed, a global install also gives you the same command:
45
47
 
46
48
  ```bash
47
49
  npm i -g flowviant
48
50
  flowviant login
49
51
  ```
50
52
 
51
- Either way is fine and both stay current on their own (below). npx is the zero-install door; a global install is what you want on a box you keep running, so the daemon, `flowviant login` and `flowviant machines` are ordinary commands.
52
-
53
- Launch with `@latest` so each start pulls the newest published version — a bare `npx flowviant` can reuse a stale cache. A running daemon also keeps itself current: from **0.58.0** it restarts itself through `npx flowviant@latest` when a new version ships, so npx launches stay up to date the same way a global install does. (Before 0.58.0 that was only true of a global install — under npx the daemon printed a notice and stayed put, which is how machines ended up sitting several releases back.) It only ever restarts when no turn is running. `FLOWVIANT_NO_UPDATE=1` makes it nag-only; `flowviant update` updates now.
53
+ The binary and global npm installs update when idle. The binary fetches the release manifest and verifies the new executable before replacing itself. The global install refreshes from npm before restarting. `flowviant update` checks on demand. `FLOWVIANT_NO_UPDATE=1` makes updates manual.
54
54
 
55
55
  ## Machines on this box
56
56
 
57
- One box can serve several projects — one daemon per repository directory, each connected with its own `flowviant login` run inside that repo. `flowviant machines` lists every project connected on this box with the id and date it was connected, and under each one every computer that has polled it (the app's Home lists the same across your whole account). Two projects bound to one repository are named out loud at the foot of the listing, because that is the one shape that reads as "the same project connected twice" and is not — and a directory serves one project, so `npx flowviant` there refuses to start until one of them goes.
57
+ One box can serve several projects — one daemon per repository directory, each connected with its own `flowviant login` run inside that repo. `flowviant machines` lists every project connected on this box with the id and date it was connected, and under each one every computer that has polled it (the app's Home lists the same across your whole account). Two projects bound to one repository are named out loud at the foot of the listing, because that is the one shape that reads as "the same project connected twice" and is not — and a directory serves one project, so `flowviant` there refuses to start until one of them goes.
58
58
 
59
59
  On a terminal the listing is a menu: ↑/↓ over the projects, enter for what you can do about one, esc to leave. Two verbs, each acting on **this box's own connection** and nothing else:
60
60
 
@@ -128,6 +128,8 @@ The posture is reported on every poll and shown in the project's
128
128
  Settings → Machine section, so the team can see whether the box runs the
129
129
  guarded toolset or full permissions.
130
130
 
131
+ With Node: `npx flowviant@latest login` runs without a global install and updates by restarting through the package runner when idle.
132
+
131
133
  ## License
132
134
 
133
135
  MIT — see [LICENSE](./LICENSE).
package/bin/cli.mjs CHANGED
@@ -70,9 +70,31 @@
70
70
  * env (the box keypair, the uplink scrubber, the env-comparison scan) + vault
71
71
  * (the knowledge wiki, not secrets), resources, deploy, shot.
72
72
  */
73
- import { FLEET_TOKEN, CREDENTIAL, adoptStoredCredential } from './lib/config.mjs';
74
- import { runFleetDaemon } from './lib/fleet.mjs';
75
- import { runLogin } from './lib/login.mjs';
73
+ // Resolve --dir before config reads the project bound to cwd. A tray process
74
+ // starts outside the checkout; its explicit folder is the daemon's checkout.
75
+ const dirAt = process.argv.indexOf('--dir');
76
+ const noCheckoutCommand = new Set(['--version', '-v', 'version', 'status', 'stop', 'machines', 'projects', 'update', 'shot', 'clean', 'gh-auth', 'mcp']);
77
+ if (dirAt >= 0 && !noCheckoutCommand.has(process.argv[2])) {
78
+ const dir = process.argv[dirAt + 1];
79
+ try {
80
+ if (!dir || dir.startsWith('--')) throw new Error('missing directory');
81
+ process.chdir(dir);
82
+ } catch (e) {
83
+ if (process.argv.includes('--json') && process.argv[2] === 'login')
84
+ process.stdout.write(`${JSON.stringify({ event: 'error', message: `Cannot use directory ${dir ?? ''}: ${e.message}` })}\n`);
85
+ else console.error(`error: cannot use directory ${dir ?? ''}: ${e.message}`);
86
+ process.exit(1);
87
+ }
88
+ }
89
+ const { FLEET_TOKEN, CREDENTIAL, VERSION, adoptStoredCredential } = await import('./lib/config.mjs');
90
+ const { runFleetDaemon } = await import('./lib/fleet.mjs');
91
+ const { runLogin } = await import('./lib/login.mjs');
92
+ const { launchCommand, terminalCommand } = await import('./lib/launchCommand.mjs');
93
+
94
+ if (process.argv.includes('--remote') && !(process.argv[2] === 'status' && process.argv.includes('--json'))) {
95
+ console.error('error: --remote requires status --json');
96
+ process.exit(1);
97
+ }
76
98
 
77
99
  // `flowviant login` — device auth (recommended): approve a code in the app, the
78
100
  // credential is stored locally, and then we KEEP GOING into the daemon.
@@ -86,9 +108,17 @@ import { runLogin } from './lib/login.mjs';
86
108
  //
87
109
  // `--no-start` for scripts and CI, which want the credential and not a
88
110
  // long-running process.
111
+ // `flowviant --version` prints and exits — the first thing a person runs after
112
+ // a curl install, and it must never start a daemon.
113
+ if (process.argv[2] === '--version' || process.argv[2] === '-v' || process.argv[2] === 'version') {
114
+ console.log(VERSION);
115
+ process.exit(0);
116
+ }
117
+
89
118
  if (process.argv[2] === 'login') {
90
119
  const noStart = process.argv.includes('--no-start');
91
- const login = await runLogin({ thenStart: !noStart });
120
+ const json = process.argv.includes('--json');
121
+ const login = await runLogin({ thenStart: !noStart, json, dir: process.cwd() });
92
122
  // A login the person CANCELLED at the second-project question saved nothing,
93
123
  // so there is no credential for the child to serve — starting it would end
94
124
  // in "no credential found" over a choice they just made on purpose.
@@ -101,7 +131,8 @@ if (process.argv[2] === 'login') {
101
131
  // re-exec: stay alive as a thin proxy so the user's shell keeps one foreground
102
132
  // process.
103
133
  const { spawn } = await import('node:child_process');
104
- const child = spawn(process.execPath, [process.argv[1]], {
134
+ const startArgs = process.argv[1]?.startsWith('/$bunfs/') ? [] : [process.argv[1]];
135
+ const child = spawn(process.execPath, [...startArgs, ...(json ? ['--json-events'] : [])], {
105
136
  stdio: 'inherit',
106
137
  env: process.env,
107
138
  });
@@ -111,11 +142,18 @@ if (process.argv[2] === 'login') {
111
142
  process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
112
143
  }
113
144
 
145
+ if (process.argv[2] === 'status' && process.argv.includes('--json')) {
146
+ const { desktopStatus, desktopStatusRemote } = await import('./lib/desktopContract.mjs');
147
+ const status = process.argv.includes('--remote') ? await desktopStatusRemote() : desktopStatus();
148
+ process.stdout.write(`${JSON.stringify(status)}\n`);
149
+ process.exit(0);
150
+ }
151
+
114
152
  // `flowviant update` — install the latest published version now. The daemon also
115
153
  // self-updates on its own (at startup + when idle); this is the manual path.
116
154
  if (process.argv[2] === 'update') {
117
155
  const { runUpdateCommand } = await import('./lib/update.mjs');
118
- runUpdateCommand();
156
+ await runUpdateCommand();
119
157
  process.exit(0);
120
158
  }
121
159
 
@@ -192,8 +230,16 @@ if (process.argv[2] === 'shot') {
192
230
  // daemon is running on this machine." is the answer the asker came for, not an
193
231
  // error. Non-zero only when something was alive and could not be stopped.
194
232
  if (process.argv[2] === 'stop') {
195
- const { stopAllDaemons } = await import('./lib/instance.mjs');
196
- const { failed } = stopAllDaemons({ log: (m) => console.log(m) });
233
+ const { stopAllDaemons, stopDaemonFor } = await import('./lib/instance.mjs');
234
+ let result;
235
+ if (process.argv.includes('--project')) {
236
+ const { matchStoredProject } = await import('./lib/credentials.mjs');
237
+ const at = process.argv.indexOf('--project');
238
+ const match = matchStoredProject(process.argv[at + 1]);
239
+ if (match.error) { console.error(`error: ${match.error}`); process.exit(1); }
240
+ result = stopDaemonFor(match.entry.fleetToken, { log: (m) => console.log(m) });
241
+ } else result = stopAllDaemons({ log: (m) => console.log(m) });
242
+ const { failed } = result;
197
243
  process.exit(failed > 0 ? 1 : 0);
198
244
  }
199
245
 
@@ -206,7 +252,7 @@ if (process.argv[2] === 'projects') {
206
252
  const { listStoredProjects, projectLabel } = await import('./lib/credentials.mjs');
207
253
  const entries = listStoredProjects();
208
254
  if (entries.length === 0) {
209
- console.log('no projects connected on this machine yet — run `flowviant login` inside a repo.');
255
+ console.log(`no projects connected on this machine yet — run \`${terminalCommand('login')}\` inside a repo.`);
210
256
  process.exit(0);
211
257
  }
212
258
  for (const e of entries) {
@@ -227,8 +273,8 @@ if (process.argv[2] === 'projects') {
227
273
  );
228
274
  }
229
275
  console.log(
230
- '\n `npx flowviant` picks by the repo it is started in; `--project <name|id>` overrides;\n' +
231
- ' `flowviant login` in a new repo connects another project.'
276
+ `\n \`${launchCommand()}\` picks by the repo it is started in; \`--project <name|id>\` overrides;\n` +
277
+ ` \`${terminalCommand('login')}\` in a new repo connects another project.`
232
278
  );
233
279
  process.exit(0);
234
280
  }
@@ -267,20 +313,27 @@ if (process.argv[2] === 'projects') {
267
313
  // is the one exit that can be 1, when the daemon it had to stop would not.
268
314
  if (process.argv[2] === 'machines') {
269
315
  const creds = await import('./lib/credentials.mjs');
316
+ const machineJson = process.argv.includes('--json');
270
317
  const forgetAt = process.argv.indexOf('--forget');
271
318
  if (forgetAt >= 0) {
319
+ if (machineJson && !process.argv.includes('--yes')) {
320
+ process.stdout.write(`${JSON.stringify({ ok: false, error: '--yes is required with --json' })}\n`);
321
+ process.exit(1);
322
+ }
272
323
  // THE LOCAL-ONLY WRITE. Named explicitly, matched by full id or a ≥6
273
324
  // prefix, and AMBIGUITY REFUSES rather than guessing — this deletes a
274
325
  // credential, and two projects that look alike is the case that produced
275
326
  // the command.
276
327
  const res = creds.forgetStoredProject(process.argv[forgetAt + 1]);
277
328
  if (res.error) {
329
+ if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: false, error: res.error })}\n`); process.exit(1); }
278
330
  console.error(`error: ${res.error}`);
279
331
  process.exit(1);
280
332
  }
333
+ if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: true, action: 'forget', projectId: res.entry.projectId })}\n`); process.exit(0); }
281
334
  console.log(
282
335
  `forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box.\n` +
283
- ' Nothing was stopped or deleted anywhere else — `flowviant login` connects it again.'
336
+ ` Nothing was stopped or deleted anywhere else — \`${terminalCommand('login')}\` connects it again.`
284
337
  );
285
338
  process.exit(0);
286
339
  }
@@ -289,22 +342,28 @@ if (process.argv[2] === 'machines') {
289
342
  const { FLEET_URL } = await import('./lib/config.mjs');
290
343
  const removeAt = process.argv.indexOf('--remove');
291
344
  if (removeAt >= 0) {
345
+ if (machineJson && !process.argv.includes('--yes')) {
346
+ process.stdout.write(`${JSON.stringify({ ok: false, error: '--yes is required with --json' })}\n`);
347
+ process.exit(1);
348
+ }
292
349
  // THE SCRIPTED DISCONNECT — the menu's first verb, named by id for a
293
350
  // terminal nobody is sitting at. Same matcher and the same refusal of an
294
351
  // ambiguous prefix as --forget: a name two projects share is exactly the
295
352
  // input this command exists to untangle, and it must not pick one.
296
353
  const m = creds.matchStoredProject(process.argv[removeAt + 1]);
297
354
  if (m.error) {
298
- console.error(`error: ${m.error}. \`flowviant machines\` lists what is stored.`);
355
+ if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: false, error: m.error })}\n`); process.exit(1); }
356
+ console.error(`error: ${m.error}. \`${terminalCommand('machines')}\` lists what is stored.`);
299
357
  process.exit(1);
300
358
  }
301
- const res = await disconnectHere(m.entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL) }));
359
+ const res = await disconnectHere(m.entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL), log: machineJson ? () => {} : undefined }), { log: machineJson ? () => {} : undefined });
360
+ if (machineJson) process.stdout.write(`${JSON.stringify({ ok: res.ok, action: 'disconnect', projectId: m.entry.projectId })}\n`);
302
361
  process.exit(res.ok ? 0 : 1);
303
362
  }
304
363
 
305
364
  let entries = creds.listStoredProjects();
306
365
  if (entries.length === 0) {
307
- console.log('no projects connected on this machine yet — run `flowviant login` inside a repo.');
366
+ console.log(`no projects connected on this machine yet — run \`${terminalCommand('login')}\` inside a repo.`);
308
367
  process.exit(0);
309
368
  }
310
369
  // OUR OWN BOX ID, so the listing can mark "← this box".
@@ -391,7 +450,7 @@ if (process.argv[2] === 'machines') {
391
450
  console.log(
392
451
  res.error
393
452
  ? ` ${res.error}`
394
- : ` forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box. Nothing was stopped or deleted anywhere else — \`flowviant login\` connects it again.`
453
+ : ` forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box. Nothing was stopped or deleted anywhere else — \`${terminalCommand('login')}\` connects it again.`
395
454
  );
396
455
  }
397
456
  entries = creds.listStoredProjects();
@@ -466,7 +525,7 @@ async function reexecAfterLogin() {
466
525
  const login = await runLogin({ thenStart: false });
467
526
  if (!login?.saved) process.exit(1); // refused: the directory already serves another project
468
527
  const { spawn } = await import('node:child_process');
469
- const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
528
+ const child = spawn(process.execPath, process.argv[1]?.startsWith('/$bunfs/') ? [] : [process.argv[1]], { stdio: 'inherit', env: process.env });
470
529
  process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
471
530
  }
472
531
 
@@ -487,9 +546,13 @@ function listLines(entries, { projectRowLabel }) {
487
546
  .join('\n');
488
547
  }
489
548
 
549
+ /** A repo binding the person just consented to, persisted by runFleetDaemon
550
+ * only after the instance lock is taken — never before a refusal. */
551
+ let afterLock = null;
552
+
490
553
  if (!FLEET_TOKEN) {
491
554
  if (CREDENTIAL.error) {
492
- console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
555
+ console.error(`error: ${CREDENTIAL.error}. \`${terminalCommand('projects')}\` lists what is stored.`);
493
556
  process.exit(1);
494
557
  }
495
558
  // A DIRECTORY SERVES ONE PROJECT (2026-09-23, the owner, verbatim: "a
@@ -517,7 +580,7 @@ if (!FLEET_TOKEN) {
517
580
  : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
518
581
  );
519
582
 
520
- const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`;
583
+ const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (${terminalCommand('login')})`;
521
584
  // WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
522
585
  // refuses to serve a project the repo PATH did not name (the skadooble law);
523
586
  // this only decides which row the cursor starts on, using the repo's folder
@@ -589,21 +652,26 @@ if (!FLEET_TOKEN) {
589
652
  console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
590
653
  }
591
654
  adoptStoredCredential(picked);
592
- creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
655
+ // BOUND ONLY ONCE THE START HAS THE LOCK. Written here, the answer moved
656
+ // the binding even when the instance lock then refused the start — a live
657
+ // daemon for the same project in its own checkout — and that daemon's next
658
+ // unattended self-update re-exec'd into a store that no longer bound its
659
+ // repo, found no match headless, and exited with the machine dark.
660
+ afterLock = () => creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
593
661
  console.log(`serving ${creds.projectLabel(picked)}${repoRoot ? ` from ${repoRoot}` : ''}.`);
594
662
  } else if (CREDENTIAL.choices?.length) {
595
663
  const creds = await import('./lib/credentials.mjs');
596
664
  console.error(
597
665
  'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
598
666
  listLines(CREDENTIAL.choices, creds) +
599
- '\nPick one with `--project <name|id>`, bind this repo by running `flowviant` here in a terminal once,\n' +
600
- 'or connect this repo to its own project with `flowviant login`.'
667
+ `\nPick one with \`--project <name|id>\`, bind this repo by running \`${terminalCommand()}\` here in a terminal once,\n` +
668
+ `or connect this repo to its own project with \`${terminalCommand('login')}\`.`
601
669
  );
602
670
  process.exit(1);
603
671
  } else {
604
672
  console.error(
605
673
  'error: no credential found. Easiest:\n' +
606
- ' flowviant login (approve in the app — recommended)\n' +
674
+ ` ${terminalCommand('login')} (approve in the app — recommended)\n` +
607
675
  'Or set:\n' +
608
676
  ' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
609
677
  );
@@ -643,17 +711,29 @@ if (!FLEET_TOKEN) {
643
711
  if (raw === null) {
644
712
  console.log(
645
713
  `\n could not read an answer — serving ${label} for this run ` +
646
- `without tying it to this repo. Run \`flowviant\` here and answer to make it stick.`
714
+ `without tying it to this repo. Run \`${terminalCommand()}\` here and answer to make it stick.`
647
715
  );
648
716
  } else if (raw === '' || raw === 'y' || raw === 'yes') {
649
- creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
717
+ // After the lock, for the reason the picker's bind gives above.
718
+ afterLock = () => creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
650
719
  } else {
651
720
  console.error(
652
- `nothing started. Connect this repo to its own project with \`flowviant login\`, ` +
653
- `or see what is stored with \`flowviant projects\`.`
721
+ `nothing started. Connect this repo to its own project with \`${terminalCommand('login')}\`, ` +
722
+ `or see what is stored with \`${terminalCommand('projects')}\`.`
654
723
  );
655
724
  process.exit(1);
656
725
  }
657
726
  }
658
727
 
659
- await runFleetDaemon();
728
+ if (process.argv.includes('--json-events') && dirAt >= 0 && CREDENTIAL?.entry) {
729
+ const { detectRepoRoot, listStoredProjects, boundElsewhere } = await import('./lib/credentials.mjs');
730
+ const repo = detectRepoRoot();
731
+ const entry = CREDENTIAL.entry;
732
+ if (!repo || entry.repoRoot !== repo || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
733
+ console.error('error: this project is not connected to this repository. Run `flowviant login` for the selected folder.');
734
+ process.exit(1);
735
+ }
736
+ }
737
+ const { installDaemonLogging } = await import('./lib/desktopContract.mjs');
738
+ installDaemonLogging(CREDENTIAL?.entry?.projectId, { jsonEvents: process.argv.includes('--json-events') });
739
+ await runFleetDaemon({ afterLock });
@@ -25,6 +25,10 @@
25
25
  * Run: node --test bin/lib/agentPlan.test.mjs
26
26
  */
27
27
 
28
+ // THE UPLINK SCRUBBER, as the DEFAULT for the two readers whose every string
29
+ // goes to the server (2026-09-24, the audit). See `parseTurnResult`.
30
+ import { scrub as envScrub } from './env.mjs';
31
+
28
32
  /** The biggest a single proposal may be. Bounds on a machine, not a policy:
29
33
  * the server caps these again at its own boundary. */
30
34
  const MAX_AGENTS = 20;
@@ -53,14 +57,60 @@ const MAX_NOTE = 1000;
53
57
  * about escapes and nobody notices, because the disagreement only shows up on a
54
58
  * card title with a quote in it.
55
59
  */
56
- function candidateObjects(raw) {
57
- const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
60
+ /**
61
+ * BOUNDED, BECAUSE THE TEXT IS A MODEL'S (2026-09-24, the audit).
62
+ *
63
+ * The scan used to walk from EVERY `{` to the end of the text, and parse every
64
+ * balanced span it found — quadratic on both counts. A final message of eighty
65
+ * thousand `{` (a quoted fixture, brace padding, a turn steered into writing
66
+ * one) took nine seconds of synchronous CPU, and while it ran nothing else on
67
+ * the machine moved: no poll, no lease renewal, no other lane. Three bounds,
68
+ * none of which a real answer comes near:
69
+ *
70
+ * · only the LAST `MAX_SCAN_CHARS` are scanned — every contract here puts the
71
+ * object LAST, and the fenced block is still looked for in the whole text;
72
+ * · each `{`'s closing brace is found ONCE (see `closers`), so the scan is a
73
+ * single right-to-left pass instead of one walk per brace;
74
+ * · only spans that can open an object (`{` then a key or `}`) are handed to
75
+ * JSON.parse. Every TOP-LEVEL span is parsed — top-level spans are
76
+ * disjoint, so together they are at most the scanned window — and the
77
+ * NESTED ones (a span inside an earlier candidate) share a budget of
78
+ * `MAX_NESTED_PARSES` spans and `MAX_NESTED_CHARS` characters, so a run of
79
+ * nested braces that parse is not re-parsed thousands of times.
80
+ *
81
+ * THE BUDGET NEVER REACHES A TOP-LEVEL SPAN, and the review found why it must
82
+ * not: the first cut counted every span from the left against one budget, so
83
+ * an answer that followed 250 JSON lines of a quoted fixture (or one large
84
+ * nested document) was never parsed at all — the turn read as `nothing` and a
85
+ * delivered agent went to Stuck. The contract puts the answer LAST and at the
86
+ * top level, which is the one span a budget must never cost.
87
+ */
88
+ const MAX_SCAN_CHARS = 256 * 1024;
89
+ const MAX_NESTED_PARSES = 200;
90
+ const MAX_NESTED_CHARS = 4 * 1024 * 1024;
91
+
92
+ function candidateObjects(text) {
93
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
94
+ const raw = text.length > MAX_SCAN_CHARS ? text.slice(text.length - MAX_SCAN_CHARS) : text;
58
95
  const bodies = [];
59
96
  if (fenced) bodies.push(fenced[1]);
97
+ const close = closers(raw);
98
+ let topEnd = -1;
99
+ let nestedParses = 0;
100
+ let nestedChars = 0;
60
101
  for (let i = 0; i < raw.length; i++) {
61
102
  if (raw[i] !== '{') continue;
62
- const end = balanced(raw, i);
63
- if (end > i) bodies.push(raw.slice(i, end + 1));
103
+ const end = close.get(i);
104
+ if (!(end > i) || !/^\{\s*["}]/.test(raw.slice(i, i + 64))) continue;
105
+ if (i > topEnd) {
106
+ topEnd = end;
107
+ } else {
108
+ const len = end - i + 1;
109
+ if (nestedParses >= MAX_NESTED_PARSES || nestedChars + len > MAX_NESTED_CHARS) continue;
110
+ nestedParses++;
111
+ nestedChars += len;
112
+ }
113
+ bodies.push(raw.slice(i, end + 1));
64
114
  }
65
115
  const out = [];
66
116
  for (const body of bodies) {
@@ -75,32 +125,51 @@ function candidateObjects(raw) {
75
125
  return out;
76
126
  }
77
127
 
78
- function extract(raw) {
79
- return candidateObjects(raw).find((v) => Array.isArray(v.agents)) ?? null;
80
- }
81
-
82
- /** The index of the `}` that closes the `{` at `from`, or -1. Skips string
83
- * literals so a brace inside a card title cannot end the object early. */
84
- function balanced(s, from) {
85
- let depth = 0;
86
- let inStr = false;
87
- let esc = false;
88
- for (let i = from; i < s.length; i++) {
89
- const ch = s[i];
90
- if (inStr) {
91
- if (esc) esc = false;
92
- else if (ch === '\\') esc = true;
93
- else if (ch === '"') inStr = false;
94
- continue;
128
+ /**
129
+ * For every `{`, the index of the `}` that closes it when read as the START of
130
+ * an object (outside any string), or -1 — the answer `balanced` gives, for all
131
+ * of them in one pass.
132
+ *
133
+ * Right to left, so every `{` AFTER the one being closed already has its
134
+ * answer. A walk that meets such a `{` outside a string jumps straight to that
135
+ * brace's closer — the string state from there on is the same for both walks,
136
+ * so the nested object ends where it ends — and if that brace never closes,
137
+ * neither does this one. What is left for each walk is its own top level.
138
+ */
139
+ function closers(s) {
140
+ const close = new Map();
141
+ for (let i = s.lastIndexOf('{'); i >= 0; i = i > 0 ? s.lastIndexOf('{', i - 1) : -1) {
142
+ let inStr = false;
143
+ let esc = false;
144
+ let end = -1;
145
+ for (let k = i + 1; k < s.length; k++) {
146
+ const ch = s[k];
147
+ if (inStr) {
148
+ if (esc) esc = false;
149
+ else if (ch === '\\') esc = true;
150
+ else if (ch === '"') inStr = false;
151
+ continue;
152
+ }
153
+ if (ch === '"') inStr = true;
154
+ else if (ch === '{') {
155
+ const inner = close.get(k);
156
+ if (inner === undefined || inner < 0) break;
157
+ k = inner;
158
+ } else if (ch === '}') {
159
+ end = k;
160
+ break;
161
+ }
95
162
  }
96
- if (ch === '"') inStr = true;
97
- else if (ch === '{') depth++;
98
- else if (ch === '}' && --depth === 0) return i;
163
+ close.set(i, end);
99
164
  }
100
- return -1;
165
+ return close;
101
166
  }
102
167
 
103
- export function parseProposal(text) {
168
+ function extract(raw) {
169
+ return candidateObjects(raw).find((v) => Array.isArray(v.agents)) ?? null;
170
+ }
171
+
172
+ export function parseProposal(text, scrub = envScrub) {
104
173
  const raw = String(text ?? '');
105
174
  const parsed = extract(raw);
106
175
  if (!parsed) return null;
@@ -117,7 +186,7 @@ export function parseProposal(text) {
117
186
  if (taskIds.length === 0) continue;
118
187
  agents.push({
119
188
  tempId: String(g.tempId || `a${i + 1}`).slice(0, 64),
120
- name: String(g.name ?? '').slice(0, MAX_NAME),
189
+ name: String(scrub(String(g.name ?? ''))).slice(0, MAX_NAME),
121
190
  taskIds,
122
191
  ...(Number.isFinite(g.pointsBudget) && g.pointsBudget > 0
123
192
  ? { pointsBudget: Math.min(Math.round(g.pointsBudget), 100_000) }
@@ -150,13 +219,45 @@ export function parseProposal(text) {
150
219
  }
151
220
  if (agents.length === 0) return null;
152
221
  return {
153
- agents,
222
+ agents: uniqueTempIds(agents),
154
223
  ...(typeof parsed.note === 'string' && parsed.note.trim()
155
- ? { note: parsed.note.slice(0, MAX_NOTE) }
224
+ ? { note: String(scrub(parsed.note)).slice(0, MAX_NOTE) }
156
225
  : {}),
157
226
  };
158
227
  }
159
228
 
229
+ /**
230
+ * A DAEMON BELT ON THE SAME COLLISION THE SERVER ALREADY REFUSES TO SHIP
231
+ * (2026-09-24, the audit's A4a CROSS 5).
232
+ *
233
+ * Two groups can share one tempId two ways: the model repeats itself, or one
234
+ * group NAMES none and defaults to the positional `a${i+1}` a differently-
235
+ * ordered group already claimed. The web keys `draftByTask` / `proposedCards`
236
+ * by tempId (boardLanes.ts), so two containers sharing one id is not cosmetic —
237
+ * per-group Start/Decline on either one then deletes BOTH, because
238
+ * `planAfterGroup` filters `.agents` by tempId and both match.
239
+ *
240
+ * Kept a byte-for-byte copy of the server's own rule
241
+ * (`agentPlan.ts`'s `uniqueTempIds`, the boundary every daemon version passes
242
+ * through) rather than re-derived, so the two ends cannot disagree about which
243
+ * of two colliding agents keeps the id the model wrote. FIRST occurrence wins
244
+ * unchanged; each later collision is suffixed `-2`, `-3`, … off the ORIGINAL
245
+ * id (not the previous suffix), staying inside the 64-char bound the schema
246
+ * already enforces upstream.
247
+ *
248
+ * This is a BELT, not the fix: an older daemon proposing the same collision is
249
+ * still covered by the server's identical transform at its own boundary.
250
+ */
251
+ function uniqueTempIds(list) {
252
+ const used = new Set();
253
+ return list.map((g) => {
254
+ let id = g.tempId;
255
+ for (let n = 2; used.has(id); n++) id = `${g.tempId.slice(0, 58)}-${n}`;
256
+ used.add(id);
257
+ return id === g.tempId ? g : { ...g, tempId: id };
258
+ });
259
+ }
260
+
160
261
  /**
161
262
  * The running account, off whichever shape the turn ended with — `{ progress }`
162
263
  * or nothing at all.
@@ -181,9 +282,9 @@ export function parseProposal(text) {
181
282
  * a machine to have clamped.
182
283
  */
183
284
  const MAX_PROGRESS = 8000;
184
- const progressOf = (v) => {
285
+ const progressOf = (v, scrub) => {
185
286
  const t = typeof v.progress === 'string' ? v.progress.trim() : '';
186
- return t ? { progress: t.slice(0, MAX_PROGRESS) } : {};
287
+ return t ? { progress: String(scrub(t)).slice(0, MAX_PROGRESS) } : {};
187
288
  };
188
289
 
189
290
  /**
@@ -222,7 +323,28 @@ const progressOf = (v) => {
222
323
  * caller omits the field rather than sending an empty string, so the server
223
324
  * keeps the last account that was true instead of blanking the head.
224
325
  */
225
- export function parseTurnResult(text) {
326
+ /*
327
+ * ── EVERY STRING IS SCRUBBED HERE, BEFORE ITS CAP (2026-09-24, the audit) ──
328
+ *
329
+ * `raised` cards and a proposal's `note` and names reached the server with no
330
+ * scrub at all — the caller redacted `answer` and `progress` and nothing
331
+ * else — so a build turn that quoted `STRIPE_SECRET_KEY=sk_live_…` in a
332
+ * raised card's brief filed it, verbatim, as an Open card every member reads
333
+ * and later agents are prompted with; and a planner note quoting one became a
334
+ * notification body. And `answer` was cut at 8000 HERE, before the caller's
335
+ * scrub, which is the straddling-cut bug `progress` and the precheck already
336
+ * record: `scrub` matches whole values, so a credential across the cut kept
337
+ * its prefix. So the scrub rides in, as `parsePrecheck`'s does, and runs over
338
+ * each whole field first.
339
+ *
340
+ * DEFAULTED TO THE REAL SCRUBBER, unlike the precheck's identity default: the
341
+ * production callers pass nothing, and a redaction that depends on each caller
342
+ * remembering is the one that is missing the day it matters. Scrubbing twice
343
+ * (the caller still scrubs `answer`) is harmless — a redaction marker contains
344
+ * no secret to match.
345
+ */
346
+ export function parseTurnResult(text, scrub = envScrub) {
347
+ const clean = (s, cap) => String(scrub(s)).slice(0, cap);
226
348
  for (const v of candidateObjects(String(text ?? ''))) {
227
349
  if (v.status === 'blocked') {
228
350
  const question = typeof v.question === 'string' ? v.question.trim() : '';
@@ -230,21 +352,21 @@ export function parseTurnResult(text) {
230
352
  // parks an agent with nothing to reply to. Treated as `nothing`, which
231
353
  // at least says truthfully that the machine went quiet.
232
354
  if (!question) continue;
233
- return { outcome: 'question', answer: question.slice(0, 8000), ...progressOf(v) };
355
+ return { outcome: 'question', answer: clean(question, 8000), ...progressOf(v, scrub) };
234
356
  }
235
357
  if (v.status === 'delivered') {
236
358
  return {
237
359
  outcome: 'delivered',
238
- answer: (typeof v.summary === 'string' ? v.summary : '').slice(0, 8000),
239
- ...progressOf(v),
360
+ answer: typeof v.summary === 'string' ? clean(v.summary, 8000) : '',
361
+ ...progressOf(v, scrub),
240
362
  raised: Array.isArray(v.raised)
241
363
  ? v.raised
242
364
  .filter((r) => r && typeof r.title === 'string' && r.title.trim())
243
365
  .slice(0, 10)
244
366
  .map((r) => ({
245
- title: r.title.trim().slice(0, 300),
367
+ title: clean(r.title.trim(), 300),
246
368
  ...(typeof r.brief === 'string' && r.brief.trim()
247
- ? { brief: r.brief.trim().slice(0, 2000) }
369
+ ? { brief: clean(r.brief.trim(), 2000) }
248
370
  : {}),
249
371
  }))
250
372
  : [],