flowviant 0.99.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.
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
 
@@ -493,7 +552,7 @@ let afterLock = null;
493
552
 
494
553
  if (!FLEET_TOKEN) {
495
554
  if (CREDENTIAL.error) {
496
- console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
555
+ console.error(`error: ${CREDENTIAL.error}. \`${terminalCommand('projects')}\` lists what is stored.`);
497
556
  process.exit(1);
498
557
  }
499
558
  // A DIRECTORY SERVES ONE PROJECT (2026-09-23, the owner, verbatim: "a
@@ -521,7 +580,7 @@ if (!FLEET_TOKEN) {
521
580
  : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
522
581
  );
523
582
 
524
- 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')})`;
525
584
  // WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
526
585
  // refuses to serve a project the repo PATH did not name (the skadooble law);
527
586
  // this only decides which row the cursor starts on, using the repo's folder
@@ -605,14 +664,14 @@ if (!FLEET_TOKEN) {
605
664
  console.error(
606
665
  'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
607
666
  listLines(CREDENTIAL.choices, creds) +
608
- '\nPick one with `--project <name|id>`, bind this repo by running `flowviant` here in a terminal once,\n' +
609
- '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')}\`.`
610
669
  );
611
670
  process.exit(1);
612
671
  } else {
613
672
  console.error(
614
673
  'error: no credential found. Easiest:\n' +
615
- ' flowviant login (approve in the app — recommended)\n' +
674
+ ` ${terminalCommand('login')} (approve in the app — recommended)\n` +
616
675
  'Or set:\n' +
617
676
  ' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
618
677
  );
@@ -652,18 +711,29 @@ if (!FLEET_TOKEN) {
652
711
  if (raw === null) {
653
712
  console.log(
654
713
  `\n could not read an answer — serving ${label} for this run ` +
655
- `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.`
656
715
  );
657
716
  } else if (raw === '' || raw === 'y' || raw === 'yes') {
658
717
  // After the lock, for the reason the picker's bind gives above.
659
718
  afterLock = () => creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
660
719
  } else {
661
720
  console.error(
662
- `nothing started. Connect this repo to its own project with \`flowviant login\`, ` +
663
- `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')}\`.`
664
723
  );
665
724
  process.exit(1);
666
725
  }
667
726
  }
668
727
 
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') });
669
739
  await runFleetDaemon({ afterLock });
@@ -11,14 +11,18 @@ import { cpus, hostname, totalmem } from 'node:os';
11
11
  // '0.28.0' across every release through 0.28.6, so the startup banner, the
12
12
  // User-Agent the server version-gates on, and the self-update check all reported
13
13
  // a stale version (and the "update available" nag never cleared).
14
- export const VERSION = (() => {
15
- try {
16
- const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
17
- return JSON.parse(readFileSync(pkgPath, 'utf8')).version || '0.0.0';
18
- } catch {
19
- return '0.0.0';
20
- }
21
- })();
14
+ // The compile script substitutes this constant into the binary. npm installs
15
+ // still read their own package.json exactly as before (including on Node 20).
16
+ export const VERSION = typeof __FLOWVIANT_BUILD_VERSION__ === 'string'
17
+ ? __FLOWVIANT_BUILD_VERSION__
18
+ : (() => {
19
+ try {
20
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
21
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version || '0.0.0';
22
+ } catch {
23
+ return '0.0.0';
24
+ }
25
+ })();
22
26
 
23
27
  // The model EVERY daemon Claude turn runs on — pinned so autonomous work never
24
28
  // inherits your interactive `~/.claude/settings.json` default. That matters: a
@@ -49,6 +49,7 @@ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from
49
49
  import { homedir } from 'node:os';
50
50
  import { join } from 'node:path';
51
51
  import { printable } from './printable.mjs';
52
+ import { terminalCommand } from './launchCommand.mjs';
52
53
 
53
54
  const CRED_DIR = join(homedir(), '.flowviant');
54
55
  const CRED_FILE = join(CRED_DIR, 'credentials.json');
@@ -281,7 +282,7 @@ export function directoryTakenRefusal(bound, repoRoot, { incoming } = {}) {
281
282
  `${head}\n` +
282
283
  'A directory serves one project — two projects must not edit it at the same time.\n' +
283
284
  'Delete the project you do not mean in Flowviant first (project settings → General → Delete project),\n' +
284
- 'or disconnect this box from it with `flowviant machines`, then run this again.'
285
+ `or disconnect this box from it with \`${terminalCommand('machines')}\`, then run this again.`
285
286
  );
286
287
  }
287
288
 
@@ -0,0 +1,227 @@
1
+ /** Local, read-only desktop contract and bounded daemon logs. */
2
+ import { createHash } from 'node:crypto';
3
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { format } from 'node:util';
7
+ import { FLEET_URL, MACHINE_HOST, USER_AGENT, VERSION } from './config.mjs';
8
+ import { listStoredProjects } from './credentials.mjs';
9
+ import { readStoredPubB64 } from './env.mjs';
10
+ import { daemonRunningFor } from './instance.mjs';
11
+ import { runningViaNpx } from './launchCommand.mjs';
12
+ import { boxesUrlFrom, fetchBoxesFor } from './machines.mjs';
13
+ import { detectRuntimes } from './runtimes.mjs';
14
+ import { runningCompiledBinary } from './update.mjs';
15
+
16
+ export const MAX_DAEMON_LOG_BYTES = 2 * 1024 * 1024;
17
+ const store = () => join(homedir(), '.flowviant');
18
+ const key = (projectId) => createHash('sha256').update(String(projectId)).digest('hex').slice(0, 16);
19
+ export const daemonLogPath = (projectId) => join(store(), `daemon-${key(projectId)}.log`);
20
+ export const daemonStatePath = (projectId) => join(store(), `daemon-${key(projectId)}.state.json`);
21
+
22
+ function readState(projectId) {
23
+ try {
24
+ const value = JSON.parse(readFileSync(daemonStatePath(projectId), 'utf8'));
25
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
26
+ } catch { return {}; }
27
+ }
28
+
29
+ export function writeDaemonState(projectId, change) {
30
+ if (!projectId) return;
31
+ try {
32
+ mkdirSync(store(), { recursive: true });
33
+ const path = daemonStatePath(projectId);
34
+ const current = readState(projectId);
35
+ const next = { ...current, ...change, pid: process.pid };
36
+ const tmp = `${path}.${process.pid}.tmp`;
37
+ writeFileSync(tmp, JSON.stringify(next), { mode: 0o600 });
38
+ renameSync(tmp, path);
39
+ } catch { /* a local readout must never interrupt a daemon */ }
40
+ }
41
+
42
+ export function setRuntimeLimit(projectId, runtime, words) {
43
+ if (!projectId || !runtime) return false;
44
+ const state = readState(projectId);
45
+ const limits = { ...(state.limits ?? {}) };
46
+ if (words == null) {
47
+ if (!Object.hasOwn(limits, runtime)) return false;
48
+ delete limits[runtime];
49
+ } else {
50
+ if (limits[runtime] === words) return false;
51
+ limits[runtime] = words;
52
+ }
53
+ writeDaemonState(projectId, { limits });
54
+ emitMachineEvent({ event: words == null ? 'limit-cleared' : 'limit-hit', runtime, ...(words == null ? {} : { message: words }) });
55
+ return true;
56
+ }
57
+
58
+ export function installChannel() {
59
+ return runningCompiledBinary() ? 'binary' : runningViaNpx() ? 'npx' : 'npm-g';
60
+ }
61
+
62
+ export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readState, pidFor = lockPid } = {}) {
63
+ return {
64
+ schema: 1,
65
+ version: VERSION,
66
+ installChannel: installChannel(),
67
+ projects: entries.map((entry) => {
68
+ const running = runningFor(entry.fleetToken);
69
+ const state = stateFor(entry.projectId);
70
+ const liveState = running === true && state.pid != null && state.pid === pidFor(entry.fleetToken) ? state : null;
71
+ return {
72
+ id: entry.projectId,
73
+ name: entry.name ?? null,
74
+ dir: entry.repoRoot ?? null,
75
+ running,
76
+ holder: liveState?.holder ?? null,
77
+ lastPoll: state.lastPoll ?? null,
78
+ logFile: daemonLogPath(entry.projectId),
79
+ runtimes: runtimes.map((rt) => ({
80
+ id: rt.id,
81
+ installed: rt.installed,
82
+ version: rt.version ?? null,
83
+ dispatchable: rt.dispatchable,
84
+ ...(liveState?.limits?.[rt.id] ? { parkedByLimit: true, message: liveState.limits[rt.id] } : {}),
85
+ })),
86
+ };
87
+ }),
88
+ // A box with no projects still has installed CLIs.
89
+ runtimes: runtimes.map((rt) => ({ id: rt.id, installed: rt.installed, version: rt.version ?? null, dispatchable: rt.dispatchable })),
90
+ };
91
+ }
92
+
93
+ const REMOTE_TIMEOUT_MS = 4_000;
94
+
95
+ async function remoteRead(read, timeoutMs) {
96
+ const controller = new AbortController();
97
+ let timer;
98
+ try {
99
+ // Abort the request and bound body parsing even if a fetch ignores its signal.
100
+ return await Promise.race([
101
+ read(controller.signal),
102
+ new Promise((resolve) => {
103
+ timer = setTimeout(() => {
104
+ controller.abort();
105
+ resolve({ error: `timed out after ${timeoutMs}ms` });
106
+ }, timeoutMs);
107
+ }),
108
+ ]);
109
+ } catch (error) {
110
+ return { error: String(error?.message ?? error) };
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
114
+ }
115
+
116
+ function liveAgent(row) {
117
+ if (!row || typeof row !== 'object' || typeof row.id !== 'string' || !row.id ||
118
+ typeof row.name !== 'string' || !row.name || typeof row.status !== 'string' ||
119
+ typeof row.runtime !== 'string' || !row.runtime ||
120
+ !['delivered', 'total'].every((field) => Number.isInteger(row[field]) && row[field] >= 0) ||
121
+ !['asks', 'parked'].every((field) => typeof row[field] === 'boolean') ||
122
+ !(row.since === null || typeof row.since === 'string')) return null;
123
+ const { id, name, status, runtime, delivered, total, asks, parked, since } = row;
124
+ return { id, name, status, runtime, delivered, total, asks, parked, since };
125
+ }
126
+
127
+ async function fetchLiveAgentsFor(entry, { url, fetchImpl, signal }) {
128
+ const res = await fetchImpl(url, {
129
+ headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
130
+ signal,
131
+ });
132
+ if (!res.ok) return { error: `HTTP ${res.status}` };
133
+ const body = await res.json();
134
+ const data = body?.data ?? body;
135
+ if (!data || !Array.isArray(data.agents)) return { error: 'unexpected answer shape' };
136
+ const pressure = data.pressure;
137
+ return {
138
+ agents: data.agents.map(liveAgent).filter(Boolean),
139
+ pressure: pressure && typeof pressure.reason === 'string' && typeof pressure.at === 'string'
140
+ ? { reason: pressure.reason, at: pressure.at } : null,
141
+ };
142
+ }
143
+
144
+ export async function desktopStatusRemote({ entries = listStoredProjects(), runtimes = detectRuntimes(),
145
+ runningFor = daemonRunningFor, stateFor = readState, pidFor = lockPid,
146
+ fetchImpl = fetch, envpub = readStoredPubB64(), fleetUrl = FLEET_URL, host = MACHINE_HOST,
147
+ now = () => new Date().toISOString(), timeoutMs = REMOTE_TIMEOUT_MS, log = console.error } = {}) {
148
+ const status = desktopStatus({ entries, runtimes, runningFor, stateFor, pidFor });
149
+ const boxesUrl = boxesUrlFrom(fleetUrl);
150
+ const liveUrl = String(fleetUrl).replace(/\/agents\/?$/, '/live-agents');
151
+ const remotes = await Promise.all(entries.map(async (entry) => {
152
+ if (!entry.fleetToken) return null;
153
+ const [boxes, live] = await Promise.all([
154
+ remoteRead((signal) => fetchBoxesFor(entry, { url: boxesUrl, envpub, fetchImpl, signal }), timeoutMs),
155
+ remoteRead((signal) => fetchLiveAgentsFor(entry, { url: liveUrl, fetchImpl, signal }), timeoutMs),
156
+ ]);
157
+ const boxesOk = Array.isArray(boxes.boxes);
158
+ const liveOk = Array.isArray(live.agents);
159
+ if (!boxesOk) {
160
+ const reason = boxes.error ?? (boxes.unsupported ? 'HTTP 404' : boxes.rejected ? 'credential rejected' : 'unexpected answer shape');
161
+ log(`status --remote ${entry.projectId} boxes: ${reason}`);
162
+ }
163
+ if (!liveOk) log(`status --remote ${entry.projectId} live-agents: ${live.error ?? 'unexpected answer shape'}`);
164
+ if (!boxesOk && !liveOk) return null;
165
+ const mine = boxesOk ? boxes.boxes.find((box) => box?.boxId === boxes.me) : null;
166
+ const serving = boxesOk ? boxes.boxes.find((box) => box?.role === 'serving') : null;
167
+ return {
168
+ at: now(),
169
+ boxName: boxesOk ? (typeof mine?.boxName === 'string' && mine.boxName ? mine.boxName : host || null) : null,
170
+ role: mine?.role === 'serving' || mine?.role === 'inactive' ? mine.role : null,
171
+ servingBoxName: typeof serving?.boxName === 'string' ? serving.boxName : null,
172
+ daemonVersion: typeof mine?.daemonVersion === 'string' ? mine.daemonVersion : null,
173
+ latest: boxesOk ? boxes.latest : null,
174
+ agents: liveOk ? live.agents : null,
175
+ pressure: liveOk ? live.pressure : null,
176
+ };
177
+ }));
178
+ status.projects.forEach((project, index) => { project.remote = remotes[index]; });
179
+ return status;
180
+ }
181
+
182
+ function lockPid(token) {
183
+ try {
184
+ const hash = createHash('sha256').update(String(token || 'anon')).digest('hex').slice(0, 12);
185
+ return JSON.parse(readFileSync(join(store(), `daemon-${hash}.lock`), 'utf8')).pid;
186
+ } catch { return null; }
187
+ }
188
+
189
+ let events = false;
190
+ let eventWriter = process.stdout.write.bind(process.stdout);
191
+ export function enableMachineEvents(write = eventWriter) { events = true; eventWriter = write; }
192
+ export function emitMachineEvent(value) {
193
+ if (events) eventWriter(`${JSON.stringify(value)}\n`);
194
+ }
195
+
196
+ /** All human output stays on its existing stream in terminal mode. In desktop
197
+ * mode stdout is reserved for JSONL and those same lines go to stderr. */
198
+ export function installDaemonLogging(projectId, { jsonEvents = false } = {}) {
199
+ if (!projectId) return;
200
+ if (jsonEvents) {
201
+ enableMachineEvents();
202
+ process.stdout.write = (...args) => process.stderr.write(...args);
203
+ }
204
+ const original = { log: console.log, info: console.info, warn: console.warn, error: console.error };
205
+ const logPath = daemonLogPath(projectId);
206
+ for (const method of Object.keys(original)) {
207
+ console[method] = (...args) => {
208
+ const line = format(...args);
209
+ try {
210
+ mkdirSync(store(), { recursive: true });
211
+ const encoded = Buffer.from(`${line}\n`);
212
+ const tail = Buffer.from('\n[truncated]\n');
213
+ const chunk = encoded.length > MAX_DAEMON_LOG_BYTES
214
+ ? Buffer.concat([encoded.subarray(0, MAX_DAEMON_LOG_BYTES - tail.length), tail])
215
+ : encoded;
216
+ let size = 0;
217
+ try { size = statSync(logPath).size; } catch { /* new file */ }
218
+ if (size + chunk.length > MAX_DAEMON_LOG_BYTES) {
219
+ try { renameSync(logPath, `${logPath}.1`); } catch { /* no old log */ }
220
+ }
221
+ appendFileSync(logPath, chunk, { mode: 0o600 });
222
+ } catch { /* logging must never stop service */ }
223
+ if (jsonEvents) process.stderr.write(`${line}\n`);
224
+ else original[method](...args);
225
+ };
226
+ }
227
+ }
package/bin/lib/fleet.mjs CHANGED
@@ -39,7 +39,8 @@ import {
39
39
  } from './config.mjs';
40
40
  import { projectLabel, safeName, setStoredProjectName } from './credentials.mjs';
41
41
  import { credentialRejected } from './authReject.mjs';
42
- import { handleVersionSignal } from './update.mjs';
42
+ import { handleVersionSignal, cmpVersion } from './update.mjs';
43
+ import { emitMachineEvent, writeDaemonState } from './desktopContract.mjs';
43
44
  import {
44
45
  git,
45
46
  gitNetAsync,
@@ -1128,6 +1129,8 @@ export async function standDownDisplaced({
1128
1129
  ? `this project's machine moved to ${name} — standing down.`
1129
1130
  : "this project's machine moved to another box — standing down."
1130
1131
  );
1132
+ if (removed) emitMachineEvent({ event: 'stopped', reason: 'removed' });
1133
+ else emitMachineEvent({ event: 'displaced', message: displacedTurnSentence(by) });
1131
1134
  // FIRST, and awaited: an unsettled turn is the one thing here that no later
1132
1135
  // poll from anybody can fix — this process holds the only copy of the fact
1133
1136
  // that it was running.
@@ -1160,6 +1163,10 @@ export async function standDownDisplaced({
1160
1163
  // base between tasks (fresh conversation), resume in place while on a blocker.
1161
1164
 
1162
1165
  export async function runFleetDaemon({ afterLock = null } = {}) {
1166
+ let desktopProjectId = CREDENTIAL?.entry?.projectId ?? null;
1167
+ let wasServing = false;
1168
+ if (process.env.FLOWVIANT_UPDATE_TARGET && cmpVersion(VERSION, process.env.FLOWVIANT_UPDATE_TARGET) >= 0)
1169
+ emitMachineEvent({ event: 'update-applied', from: process.env.FLOWVIANT_UPDATE_FROM ?? null, to: VERSION });
1163
1170
  console.log('');
1164
1171
  console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
1165
1172
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
@@ -1279,6 +1286,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1279
1286
  } catch {
1280
1287
  /* the binding is asked again next time */
1281
1288
  }
1289
+ writeDaemonState(desktopProjectId, { limits: {}, holder: null });
1282
1290
 
1283
1291
  await preflight({ needGit: true });
1284
1292
 
@@ -1443,6 +1451,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1443
1451
  // A SAME-REPO TAKEOVER IS DELIBERATELY NOT DISTINGUISHED, and that is a
1444
1452
  };
1445
1453
  process.on('SIGINT', () => {
1454
+ emitMachineEvent({ event: 'stopped', reason: 'signal' });
1446
1455
  console.log('');
1447
1456
  note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1448
1457
  teardown();
@@ -1452,6 +1461,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1452
1461
  // handler every child survived a `systemctl stop` — the exact orphaning the
1453
1462
  // teardown exists to prevent.
1454
1463
  process.on('SIGTERM', () => {
1464
+ emitMachineEvent({ event: 'stopped', reason: 'signal' });
1455
1465
  console.log('');
1456
1466
  note('shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1457
1467
  teardown();
@@ -2407,6 +2417,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2407
2417
  );
2408
2418
  } catch (e) {
2409
2419
  if (e.auth) {
2420
+ emitMachineEvent({ event: 'stopped', reason: 'credential-revoked' });
2410
2421
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
2411
2422
  teardown();
2412
2423
  // EXIT 0, for the same reason the commanded-stop path does: a revoked
@@ -2467,6 +2478,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2467
2478
  }
2468
2479
  })();
2469
2480
  }
2481
+ if (roster.project?.id) desktopProjectId = roster.project.id;
2482
+ writeDaemonState(desktopProjectId, { lastPoll: new Date().toISOString() });
2470
2483
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
2471
2484
  /**
2472
2485
  * HOW MANY TURNS THE APP SAYS THIS MACHINE MAY RUN (2026-09-17).
@@ -2495,6 +2508,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2495
2508
  // going away.
2496
2509
  const stopSignal = shouldStop(roster.daemon);
2497
2510
  if (stopSignal) {
2511
+ emitMachineEvent({ event: 'stopped', reason: stopSignal.reason ?? null });
2498
2512
  warn(
2499
2513
  stopSignal.reason
2500
2514
  ? `stopped by Flowviant — ${stopSignal.reason}`
@@ -2599,6 +2613,12 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2599
2613
  * would start deleting worktrees it cannot see the tabs for.
2600
2614
  */
2601
2615
  holderState = holderWatch.observe(roster.holder);
2616
+ writeDaemonState(desktopProjectId, {
2617
+ lastPoll: new Date().toISOString(),
2618
+ holder: ({ mine: 'serving', standby: 'inactive' })[holderState] ?? null,
2619
+ });
2620
+ if (holderState === 'mine' && !wasServing) emitMachineEvent({ event: 'serving' });
2621
+ wasServing = holderState === 'mine';
2602
2622
  // Keep the daemon current. Safe = no worker mid-task (true at startup, since
2603
2623
  // no workers are spawned yet). If it self-updates it re-execs into the new
2604
2624
  // version and this process becomes a proxy — stop the loop.
@@ -2620,7 +2640,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2620
2640
  safeToUpdate,
2621
2641
  teardown,
2622
2642
  });
2623
- if (updating) return;
2643
+ if (await updating) return;
2624
2644
  }
2625
2645
  // Settle any turn/ship answers whose earlier report POST failed BEFORE
2626
2646
  // taking new work — the skip-if-pending guards make the ordering safe, but
@@ -139,6 +139,20 @@ function readHolder(path) {
139
139
  }
140
140
  }
141
141
 
142
+ /** Read-only desktop measurement. No lock is unknown because an unguarded or
143
+ * older daemon may still run; a stale lock with a dead pid measures empty. */
144
+ export function daemonRunningFor(fleetToken) {
145
+ const path = instanceLockPath(fleetToken);
146
+ let raw;
147
+ try { raw = readFileSync(path, 'utf8'); }
148
+ catch { return null; }
149
+ let holder;
150
+ try { holder = JSON.parse(raw); } catch { return null; }
151
+ if (!Number.isInteger(holder?.pid) || holder.pid <= 0) return null;
152
+ if (!alive(holder.pid)) return false;
153
+ return stillTheHolder(holder);
154
+ }
155
+
142
156
  /**
143
157
  * argv[1] AS IT WAS TYPED, when that differs from the resolved form.
144
158
  *
@@ -0,0 +1,23 @@
1
+ import { fileURLToPath } from 'node:url';
2
+
3
+ /** The updater and terminal advice share one detection of the launch channel. */
4
+ export function runningViaNpx() {
5
+ const ua = process.env.npm_config_user_agent || '';
6
+ const argv1 = process.argv[1] || '';
7
+ let self = '';
8
+ try {
9
+ self = fileURLToPath(import.meta.url);
10
+ } catch {
11
+ /* non-file URL — ignore */
12
+ }
13
+ return /\bnpx\b/.test(ua) || /[\\/]_npx[\\/]/.test(argv1) || /[\\/]_npx[\\/]/.test(self);
14
+ }
15
+
16
+ /** Name the command that launched this process when giving a person a next step. */
17
+ export function launchCommand() {
18
+ return 'flowviant';
19
+ }
20
+
21
+ export function terminalCommand(args = '') {
22
+ return `${launchCommand()}${args ? ` ${args}` : ''}`;
23
+ }
package/bin/lib/login.mjs CHANGED
@@ -10,6 +10,7 @@ import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
10
10
  import { saveLogin, detectRepoRoot, projectLabel, safeName, listStoredProjects, boundElsewhere, directoryTakenRefusal } from './credentials.mjs';
11
11
  import { c, info, ok, warn, fail } from './ui.mjs';
12
12
  import { sleep } from './claude.mjs';
13
+ import { launchCommand, terminalCommand } from './launchCommand.mjs';
13
14
 
14
15
  const DEVICE_START = FLEET_URL.replace(/\/agents\/?$/, '/device/start');
15
16
  const DEVICE_POLL = FLEET_URL.replace(/\/agents\/?$/, '/device/poll');
@@ -27,24 +28,35 @@ async function post(url, body) {
27
28
  return j.data ?? j;
28
29
  }
29
30
 
30
- export async function runLogin({ thenStart = false } = {}) {
31
- console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
31
+ export async function runLogin({ thenStart = false, json = false, dir = process.cwd() } = {}) {
32
+ const event = (value) => { if (json) process.stdout.write(`${JSON.stringify(value)}\n`); };
33
+ const selectedRepo = json ? detectRepoRoot(dir) : null;
34
+ if (json && !selectedRepo) { event({ event: 'error', message: `No git repository found in ${dir}.` }); return { saved: false }; }
35
+ if (!json) console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
32
36
  let start;
33
37
  try {
34
38
  start = await post(DEVICE_START, {});
35
39
  } catch (e) {
40
+ if (json) { event({ event: 'error', message: `couldn't reach Flowviant (${e.message}).` }); return { saved: false }; }
36
41
  fail(`couldn't reach Flowviant (${e.message}).`);
37
42
  process.exit(1);
38
43
  }
39
44
  const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
45
+ if (json && (typeof deviceCode !== 'string' || typeof userCode !== 'string' || userCode.length < 8)) {
46
+ event({ event: 'error', message: 'Flowviant returned an invalid login code.' });
47
+ return { saved: false };
48
+ }
40
49
  const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
41
50
  // Where the control ACTUALLY is. It was "the Agents panel", a settings
42
51
  // section deleted 2026-08-17; connecting a machine is offered on the surface
43
52
  // you are on when it matters, and for a new operator that is the Workbench —
44
53
  // the project's empty state says so before it can show you any sessions.
45
- console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Workbench')} → ${c.bold('Connect a machine')}.`);
46
- console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
47
- info('waiting for you to approve…');
54
+ if (json) event({ event: 'open_url', url: APP_URL, code: pretty });
55
+ else {
56
+ console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Workbench')} → ${c.bold('Connect a machine')}.`);
57
+ console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
58
+ info('waiting for you to approve…');
59
+ }
48
60
 
49
61
  const deadline = Date.now() + expiresInSeconds * 1000;
50
62
  while (Date.now() < deadline) {
@@ -56,6 +68,10 @@ export async function runLogin({ thenStart = false } = {}) {
56
68
  continue; // transient — keep polling
57
69
  }
58
70
  if (poll.status === 'approved') {
71
+ if (json && (!poll.projectId || !(poll.machineToken ?? poll.fleetToken))) {
72
+ event({ event: 'error', message: 'Flowviant did not return a project credential.' });
73
+ return { saved: false };
74
+ }
59
75
  // `machineToken` is the wire's new name; `fleetToken` is the one every
60
76
  // published daemon reads. The server dual-sends until DAEMON_MIN clears
61
77
  // the release that reads the new one (0.54.2+) — reading both here is
@@ -65,7 +81,7 @@ export async function runLogin({ thenStart = false } = {}) {
65
81
  // know for certain which checkout this project means, and the binding is
66
82
  // what lets a multi-project VM resolve `npx flowviant` by DIRECTORY
67
83
  // instead of by whichever login happened last.
68
- const repoRoot = detectRepoRoot();
84
+ const repoRoot = selectedRepo ?? detectRepoRoot(dir);
69
85
  const entry = {
70
86
  fleetToken: poll.machineToken ?? poll.fleetToken,
71
87
  projectId: poll.projectId,
@@ -86,12 +102,18 @@ export async function runLogin({ thenStart = false } = {}) {
86
102
  // (`directoryTakenRefusal`, shared with the daemon's own start).
87
103
  const clash = boundElsewhere(listStoredProjects(), repoRoot, entry.projectId);
88
104
  if (clash.length > 0) {
105
+ if (json) { event({ event: 'error', message: directoryTakenRefusal(clash, repoRoot, { incoming: entry }) }); return { saved: false }; }
89
106
  console.log('');
90
107
  warn(directoryTakenRefusal(clash, repoRoot, { incoming: entry }));
91
108
  console.log('');
92
109
  return { saved: false };
93
110
  }
94
- saveLogin(entry);
111
+ try { saveLogin(entry); }
112
+ catch (e) {
113
+ if (json) { event({ event: 'error', message: `Could not save this connection: ${e.message}` }); return { saved: false }; }
114
+ throw e;
115
+ }
116
+ if (json) { event({ event: 'bound', projectId: entry.projectId, name: entry.name, dir: repoRoot }); return { saved: true }; }
95
117
  ok(
96
118
  `connected to ${c.bold(projectLabel(entry))}` +
97
119
  `${repoRoot ? ` for ${c.dim(repoRoot)}` : ''} — saved to ~/.flowviant/credentials.json`
@@ -102,15 +124,17 @@ export async function runLogin({ thenStart = false } = {}) {
102
124
  console.log(
103
125
  thenStart
104
126
  ? `\n ${c.dim('starting your agent — leave this running')}\n`
105
- : `\n Now run: ${c.bold('npx flowviant')}\n`
127
+ : `\n Now run: ${c.bold(launchCommand())}\n`
106
128
  );
107
129
  return { saved: true };
108
130
  }
109
131
  if (poll.status === 'expired') {
110
- warn('that code expired — run `flowviant login` again.');
132
+ if (json) { event({ event: 'error', message: 'Login code expired.' }); return { saved: false }; }
133
+ warn(`that code expired — run \`${terminalCommand('login')}\` again.`);
111
134
  process.exit(1);
112
135
  }
113
136
  }
114
- warn('login timed out — run `flowviant login` again.');
137
+ if (json) { event({ event: 'error', message: 'Login timed out.' }); return { saved: false }; }
138
+ warn(`login timed out — run \`${terminalCommand('login')}\` again.`);
115
139
  process.exit(1);
116
140
  }
@@ -74,6 +74,7 @@
74
74
  import { USER_AGENT } from './config.mjs';
75
75
  import { projectLabel, repoCollisions, safeName } from './credentials.mjs';
76
76
  import { credentialRejected } from './authReject.mjs';
77
+ import { launchCommand, terminalCommand } from './launchCommand.mjs';
77
78
 
78
79
  /** The boxes read, derived from the roster URL the way the diffstat post is —
79
80
  * one place configures the API base and everything else is a suffix swap. */
@@ -246,7 +247,7 @@ export function renderMachines(entries, results, { now = Date.now() } = {}) {
246
247
  if (r.rejected) {
247
248
  lines.push(
248
249
  ' credential rejected — this project was disconnected or deleted. ' +
249
- `\`flowviant machines --forget ${e.projectId.slice(0, 8)}\` removes it here.`
250
+ `\`${terminalCommand(`machines --forget ${e.projectId.slice(0, 8)}`)}\` removes it here.`
250
251
  );
251
252
  continue;
252
253
  }
@@ -297,7 +298,7 @@ export function renderCollisions(entries) {
297
298
  const list = `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
298
299
  return (
299
300
  ` ! ${g.entries.length} projects are connected for ${g.repoRoot}: ${list} — ` +
300
- 'a directory serves one project, and `npx flowviant` there refuses to start until it does. ' +
301
+ `a directory serves one project, and \`${launchCommand()}\` there refuses to start until it does. ` +
301
302
  'Delete the one you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it.'
302
303
  );
303
304
  });
@@ -317,7 +318,7 @@ export function renderCollisions(entries) {
317
318
  */
318
319
  export const MACHINES_FOOTER = [
319
320
  ' stop a machine from the app: project settings → Machine → Disconnect;',
320
- ' a new `npx flowviant` on another box takes the project over.',
321
+ ` a new \`${launchCommand()}\` on another box takes the project over.`,
321
322
  ' this lists the projects connected on THIS box; the app’s Home lists every machine on your account.',
322
323
  ];
323
324
 
@@ -325,7 +326,7 @@ export const MACHINES_FOOTER = [
325
326
  * terminal that could draw the menu was offered the same two things by name,
326
327
  * and repeating the flags under it would be a manual under a control. */
327
328
  export const MACHINES_FLAGS_FOOTER = [
328
- ' `flowviant machines --remove <id>` disconnects this box from a project: stops its daemon here,',
329
+ ` \`${terminalCommand('machines --remove <id>')}\` disconnects this box from a project: stops its daemon here,`,
329
330
  ' removes this box from that project’s machines list in the app, and forgets its credential here.',
330
331
  ' `--forget <id>` forgets the credential here only.',
331
332
  ];
@@ -339,13 +340,13 @@ export const MACHINES_FLAGS_FOOTER = [
339
340
  * probably forget. Two different next moves; collapsing them is how somebody
340
341
  * deletes a working credential.
341
342
  */
342
- export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch } = {}) {
343
+ export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch, signal = AbortSignal.timeout(15_000) } = {}) {
343
344
  const target = new URL(url);
344
345
  if (envpub) target.searchParams.set('envpub', envpub);
345
346
  try {
346
347
  const res = await fetchImpl(target, {
347
348
  headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
348
- signal: AbortSignal.timeout(15_000),
349
+ signal,
349
350
  });
350
351
  if (await credentialRejected(res)) return { rejected: true };
351
352
  if (res.status === 401 || res.status === 403) return { error: `HTTP ${res.status} from something in front of the app` };
@@ -487,7 +488,7 @@ export async function disconnectHere(entry, deps, { log = (m) => console.log(m)
487
488
  log(`could not forget the credential here: ${gone.error}`);
488
489
  return { ok: false };
489
490
  }
490
- log(`forgot ${who}’s credential on this box. \`flowviant login\` in its repo connects it again.`);
491
+ log(`forgot ${who}’s credential on this box. \`${terminalCommand('login')}\` in its repo connects it again.`);
491
492
  return { ok: true };
492
493
  }
493
494
 
@@ -18,15 +18,16 @@
18
18
  */
19
19
 
20
20
  import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
21
+ import { terminalCommand } from './launchCommand.mjs';
21
22
 
22
23
  const CLI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/cli-token');
23
24
 
24
25
  export async function runMcpCommand(args = []) {
25
26
  if (!FLEET_TOKEN) {
26
27
  console.error(
27
- 'error: no credential resolves here. Run `flowviant login` first — or, with\n' +
28
+ `error: no credential resolves here. Run \`${terminalCommand('login')}\` first — or, with\n` +
28
29
  'several projects connected on this box, run this inside the project\'s own\n' +
29
- 'repo or pass `--project <name|id>` (`flowviant projects` lists them).'
30
+ `repo or pass \`--project <name|id>\` (\`${terminalCommand('projects')}\` lists them).`
30
31
  );
31
32
  process.exit(1);
32
33
  }
@@ -50,7 +51,7 @@ export async function runMcpCommand(args = []) {
50
51
  console.error(
51
52
  `error: could not mint a CLI credential (${res.status}). ` +
52
53
  (res.status === 401 || res.status === 403
53
- ? 'Your credential may have been revoked — try `flowviant login` again.'
54
+ ? `Your credential may have been revoked — try \`${terminalCommand('login')}\` again.`
54
55
  : 'Try again in a moment.')
55
56
  );
56
57
  process.exit(1);
@@ -8,6 +8,7 @@ import { execFileSync } from 'node:child_process';
8
8
  import { ok, warn, info, c } from './ui.mjs';
9
9
  import { addLocalBinToPath, promptYesNo, installClaude, installGh } from './install.mjs';
10
10
  import { RUNTIMES, detectRuntimes, mediatedSafeGap } from './runtimes.mjs';
11
+ import { terminalCommand } from './launchCommand.mjs';
11
12
 
12
13
  function present(cmd) {
13
14
  try {
@@ -106,7 +107,7 @@ export async function preflight({ needGit = true } = {}) {
106
107
  if (await installGh((m) => info(m))) gh = present('gh');
107
108
  }
108
109
  gh
109
- ? ok('gh installed to ~/.flowviant/bin — authenticate with: flowviant gh-auth')
110
+ ? ok(`gh installed to ~/.flowviant/bin — authenticate with: ${terminalCommand('gh-auth')}`)
110
111
  : info(c.dim('or install it later: https://cli.github.com, then: gh auth login'));
111
112
  }
112
113
 
@@ -30,7 +30,14 @@
30
30
  */
31
31
 
32
32
  import { execFileSync, spawn } from 'node:child_process';
33
- import { fileURLToPath } from 'node:url';
33
+ import { createHash } from 'node:crypto';
34
+ import { createReadStream, createWriteStream } from 'node:fs';
35
+ import { chmod, mkdtemp, open, rename, rm } from 'node:fs/promises';
36
+ import { dirname, join } from 'node:path';
37
+ import { platform, arch } from 'node:process';
38
+ import { pipeline } from 'node:stream/promises';
39
+ import { Readable } from 'node:stream';
40
+ import { runningViaNpx, terminalCommand } from './launchCommand.mjs';
34
41
  import { VERSION } from './config.mjs';
35
42
  import { note, ok, warn } from './ui.mjs';
36
43
 
@@ -45,22 +52,66 @@ export function cmpVersion(a, b) {
45
52
  return 0;
46
53
  }
47
54
 
48
- /**
49
- * npx runs from a per-invocation cache dir. `npm i -g` would install to a
50
- * DIFFERENT location than the one executing, so re-execing our own path would
51
- * loop on the stale cached copy. Detect npx and skip the install (nag instead —
52
- * relaunching with `npx flowviant@latest` is the npx-native update).
53
- */
54
- export function runningViaNpx() {
55
- const ua = process.env.npm_config_user_agent || '';
56
- const argv1 = process.argv[1] || '';
57
- let self = '';
55
+ /** Bun marks compiled entrypoints with a virtual /$bunfs/ script path. */
56
+ export function runningCompiledBinary() {
57
+ return Boolean(process.versions.bun && process.argv[1]?.startsWith('/$bunfs/'));
58
+ }
59
+
60
+ const RELEASE_ORIGIN = 'https://api.flowviant.com';
61
+
62
+ /** Download and verify before replacing the executable. Exposed for temp-dir tests. */
63
+ export async function installBinaryUpdate({
64
+ executable = process.execPath,
65
+ target = `${platform === 'darwin' ? 'darwin' : platform}-${arch}`,
66
+ fetchImpl = fetch,
67
+ origin = RELEASE_ORIGIN,
68
+ minimumVersion = null,
69
+ } = {}) {
70
+ if (!/^(linux|darwin)-(x64|arm64)$/.test(target)) throw new Error(`unsupported binary target ${target}`);
71
+ const manifestResponse = await fetchImpl(`${origin}/dl/latest.json`);
72
+ if (!manifestResponse.ok) throw new Error(`release manifest: HTTP ${manifestResponse.status}`);
73
+ const manifest = await manifestResponse.json();
74
+ const version = manifest?.version;
75
+ const file = manifest?.files?.[target];
76
+ if (!/^\d+\.\d+\.\d+$/.test(version) ||
77
+ file?.name !== `flowviant-${version}-${target}` ||
78
+ !/^[a-f0-9]{64}$/.test(file?.sha256) ||
79
+ !Number.isSafeInteger(file?.bytes) || file.bytes <= 0) {
80
+ throw new Error('invalid release manifest');
81
+ }
82
+ if (minimumVersion && cmpVersion(version, minimumVersion) < 0) {
83
+ throw new Error(`release manifest still serves ${version}; waiting for ${minimumVersion}`);
84
+ }
85
+ const binaryResponse = await fetchImpl(`${origin}/dl/${version}/${file.name}`);
86
+ if (!binaryResponse.ok || !binaryResponse.body) throw new Error(`binary download: HTTP ${binaryResponse.status}`);
87
+ const parent = dirname(executable);
88
+ const temporaryDir = await mkdtemp(join(parent, '.flowviant-update-'));
89
+ const temporaryFile = join(temporaryDir, file.name);
58
90
  try {
59
- self = fileURLToPath(import.meta.url);
60
- } catch {
61
- /* non-file URL — ignore */
91
+ await pipeline(Readable.fromWeb(binaryResponse.body), createWriteStream(temporaryFile, { mode: 0o755 }));
92
+ const digest = createHash('sha256');
93
+ let bytes = 0;
94
+ for await (const chunk of createReadStream(temporaryFile)) {
95
+ digest.update(chunk);
96
+ bytes += chunk.length;
97
+ }
98
+ if (bytes !== file.bytes || digest.digest('hex') !== file.sha256) {
99
+ throw new Error('downloaded binary failed SHA-256 or size verification');
100
+ }
101
+ await chmod(temporaryFile, 0o755);
102
+ const handle = await open(temporaryFile, 'r');
103
+ try { await handle.sync(); } finally { await handle.close(); }
104
+ await rename(temporaryFile, executable);
105
+ // The rename has committed. Directory fsync is best effort on filesystems
106
+ // that do not support it; a failure here must not claim the binary survived.
107
+ try {
108
+ const dirHandle = await open(parent, 'r');
109
+ try { await dirHandle.sync(); } finally { await dirHandle.close(); }
110
+ } catch { /* rename already succeeded */ }
111
+ return version;
112
+ } finally {
113
+ await rm(temporaryDir, { recursive: true, force: true }).catch(() => {});
62
114
  }
63
- return /\bnpx\b/.test(ua) || /[\\/]_npx[\\/]/.test(argv1) || /[\\/]_npx[\\/]/.test(self);
64
115
  }
65
116
 
66
117
  /**
@@ -70,7 +121,7 @@ export function runningViaNpx() {
70
121
  * this process alive only as a thin proxy waiting on the child, so the user's
71
122
  * shell stays attached to one foreground process.
72
123
  */
73
- function reexec(teardown, { viaNpx = false, target = null } = {}) {
124
+ function reexec(teardown, { viaNpx = false, viaBinary = false, target = null } = {}) {
74
125
  try {
75
126
  teardown?.();
76
127
  } catch {
@@ -84,7 +135,7 @@ function reexec(teardown, { viaNpx = false, target = null } = {}) {
84
135
  // on npx's install prompt — the same rule FLOWVIANT_REEXEC keeps below.
85
136
  const [cmd, args] = viaNpx
86
137
  ? ['npx', ['-y', 'flowviant@latest', ...process.argv.slice(2)]]
87
- : [process.execPath, process.argv.slice(1)];
138
+ : [process.execPath, process.argv.slice(viaBinary ? 2 : 1)];
88
139
  const child = spawn(cmd, args, {
89
140
  stdio: 'inherit',
90
141
  // MARK THE CHILD AS A RESTART, not as a person typing `flowviant`.
@@ -113,6 +164,7 @@ function reexec(teardown, { viaNpx = false, target = null } = {}) {
113
164
  // the global path there is no install step whose failure would throw and
114
165
  // stop it.
115
166
  ...(target ? { FLOWVIANT_UPDATE_TARGET: target } : {}),
167
+ FLOWVIANT_UPDATE_FROM: VERSION,
116
168
  },
117
169
  });
118
170
  child.on('exit', (code) => process.exit(code ?? 0));
@@ -138,9 +190,18 @@ function installLatest() {
138
190
 
139
191
  /** `flowviant update` — explicit, manual update. Does not re-exec into a daemon
140
192
  * (the user ran a one-shot command); it installs and tells them to relaunch. */
141
- export function runUpdateCommand() {
193
+ export async function runUpdateCommand() {
194
+ if (runningCompiledBinary()) {
195
+ try {
196
+ const version = await installBinaryUpdate();
197
+ ok(`updated to ${version}. Relaunch \`flowviant\` to run the new version.`);
198
+ } catch (e) {
199
+ warn(`update failed (${e?.message ?? e}) — running binary untouched.`);
200
+ }
201
+ return;
202
+ }
142
203
  if (runningViaNpx()) {
143
- note('running via npx — just relaunch with `npx flowviant@latest` to get the newest.');
204
+ note('running via npx — relaunch `flowviant` to get the newest.');
144
205
  return;
145
206
  }
146
207
  try {
@@ -148,7 +209,7 @@ export function runUpdateCommand() {
148
209
  installLatest();
149
210
  ok('updated. Relaunch `flowviant` to run the new version.');
150
211
  } catch (e) {
151
- warn(`update failed (${e?.message ?? e}). Try: npm i -g flowviant@latest`);
212
+ warn(`update failed (${e?.message ?? e}). Try: ${terminalCommand('update')}`);
152
213
  }
153
214
  }
154
215
 
@@ -170,13 +231,14 @@ const INSTALL_RETRY_MS = 15 * 60_000;
170
231
  * React to the server's {latest, min} signal from a roster poll.
171
232
  * @returns true if it kicked off a self-update + re-exec (caller must stop).
172
233
  */
173
- export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, teardown }) {
234
+ export async function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, teardown }) {
174
235
  const cur = VERSION;
175
236
  const belowMin = min && cmpVersion(cur, min) < 0;
176
237
  const belowLatest = latest && cmpVersion(cur, latest) < 0;
177
238
  if (!belowMin && !belowLatest) return false; // current — nothing to do
178
239
  const target = latest || min;
179
240
  const npx = runningViaNpx();
241
+ const binary = runningCompiledBinary();
180
242
  const wantInstall = belowMin || autoUpdate;
181
243
 
182
244
  // A RESTART THAT DID NOT TAKE must not be tried again on the next poll. The
@@ -189,13 +251,35 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
189
251
  naggedFor = target;
190
252
  warn(
191
253
  `restarted to pick up ${target} but came back as ${cur} — staying put. Update by hand: ${
192
- npx ? 'relaunch with `npx flowviant@latest`' : 'npm i -g flowviant@latest'
254
+ binary ? 'run `flowviant update`' : npx ? 'relaunch `flowviant`' : `run \`${terminalCommand('update')}\``
193
255
  }.`
194
256
  );
195
257
  }
196
258
  return false;
197
259
  }
198
260
 
261
+ if (wantInstall && binary) {
262
+ if (!safeToUpdate) {
263
+ if (naggedFor !== target) {
264
+ naggedFor = target;
265
+ note(`flowviant ${cur} → ${target} available — self-updating once no turn is running.`);
266
+ }
267
+ return false;
268
+ }
269
+ if (Date.now() - lastInstallFailAt < INSTALL_RETRY_MS) return false;
270
+ try {
271
+ note(`flowviant ${cur} → ${target}: downloading binary…`);
272
+ const installed = await installBinaryUpdate({ minimumVersion: target });
273
+ ok('updated — restarting into the new version.');
274
+ reexec(teardown, { viaBinary: true, target: installed });
275
+ return true;
276
+ } catch (e) {
277
+ lastInstallFailAt = Date.now();
278
+ warn(`binary self-update failed (${e?.message ?? e}) — running binary untouched; retrying in 15m.`);
279
+ return false;
280
+ }
281
+ }
282
+
199
283
  // UNDER NPX THERE IS NOTHING TO INSTALL — the relaunch IS the update, because
200
284
  // `npx -y flowviant@latest` resolves `latest` against the registry. Same two
201
285
  // gates as the global path: the operator's AUTO_UPDATE choice, and an idle
@@ -215,7 +299,7 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
215
299
  return true;
216
300
  }
217
301
 
218
- if (wantInstall && !npx) {
302
+ if (wantInstall && !npx && !binary) {
219
303
  if (!safeToUpdate) {
220
304
  // Outdated but a turn is running — wait until the machine is quiet. Nag
221
305
  // once meanwhile.
@@ -265,7 +349,7 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
265
349
  } catch (e) {
266
350
  lastInstallFailAt = Date.now();
267
351
  warn(
268
- `self-update failed (${e?.message ?? e}) — retrying in 15m; to fix it now: npm i -g flowviant@latest`
352
+ `self-update failed (${e?.message ?? e}) — retrying in 15m; to fix it now: ${terminalCommand('update')}`
269
353
  );
270
354
  return false;
271
355
  }
@@ -274,7 +358,7 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
274
358
  // Can't or won't auto-install → nag once per target version.
275
359
  if (naggedFor !== target) {
276
360
  naggedFor = target;
277
- const how = npx ? 'relaunch with `npx flowviant@latest`' : 'run `npm i -g flowviant@latest`';
361
+ const how = binary ? 'run `flowviant update`' : npx ? 'relaunch `flowviant`' : `run \`${terminalCommand('update')}\``;
278
362
  if (belowMin) {
279
363
  warn(`flowviant ${cur} is below the minimum ${min} — live mode may not work. Update: ${how}.`);
280
364
  } else {
@@ -2,7 +2,8 @@
2
2
  import { limitLine } from './workAgentReview.mjs';
3
3
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
5
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, PROJECT_ID } from './config.mjs';
6
+ import { setRuntimeLimit } from './desktopContract.mjs';
6
7
  import { git, isSafePathSegment } from './git.mjs';
7
8
  import { parseTurnResult } from './agentPlan.mjs';
8
9
  import { stashCard } from './agentCards.mjs';
@@ -813,7 +814,9 @@ export function createWorkAgentTurns({
813
814
  * sentence it failed with.
814
815
  */
815
816
  const limit = res ? null : limitLine(out);
817
+ if (res) setRuntimeLimit(PROJECT_ID, rt, null);
816
818
  if (limit) {
819
+ setRuntimeLimit(PROJECT_ID, rt, limit);
817
820
  // Every agent ON THIS CLI parks, because that account is shared: one
818
821
  // hitting the limit means all of them have. Named since 2026-09-24 —
819
822
  // a machine can hold a Claude login and a Codex login, and a Codex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.99.0",
3
+ "version": "0.100.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — 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": {
@@ -19,7 +19,6 @@
19
19
  "test": "node --test bin/lib/*.test.mjs"
20
20
  },
21
21
  "dependencies": {
22
- "@anthropic-ai/claude-agent-sdk": "^0.3.0",
23
22
  "libsodium-wrappers-sumo": "^0.8.4",
24
23
  "ws": "^8.18.0"
25
24
  },