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 +15 -13
- package/bin/cli.mjs +95 -25
- package/bin/lib/config.mjs +12 -8
- package/bin/lib/credentials.mjs +2 -1
- package/bin/lib/desktopContract.mjs +227 -0
- package/bin/lib/fleet.mjs +22 -2
- package/bin/lib/instance.mjs +14 -0
- package/bin/lib/launchCommand.mjs +23 -0
- package/bin/lib/login.mjs +34 -10
- package/bin/lib/machines.mjs +8 -7
- package/bin/lib/mcp-cli.mjs +4 -3
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/update.mjs +109 -25
- package/bin/lib/workAgentTurns.mjs +4 -1
- package/package.json +1 -2
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
|
-
```
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
31
|
+
Download the standalone binary, then start it in your checkout:
|
|
31
32
|
|
|
32
|
-
```
|
|
33
|
-
|
|
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 `
|
|
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_…
|
|
43
|
+
FLOWVIANT_MACHINE_TOKEN=fva_… flowviant # FLOWVIANT_FLEET still works
|
|
42
44
|
```
|
|
43
45
|
|
|
44
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
231
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 —
|
|
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}.
|
|
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 (
|
|
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
|
-
|
|
609
|
-
|
|
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
|
-
'
|
|
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 \`
|
|
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
|
|
663
|
-
`or see what is stored with
|
|
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 });
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/bin/lib/instance.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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(
|
|
127
|
+
: `\n Now run: ${c.bold(launchCommand())}\n`
|
|
106
128
|
);
|
|
107
129
|
return { saved: true };
|
|
108
130
|
}
|
|
109
131
|
if (poll.status === 'expired') {
|
|
110
|
-
|
|
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
|
-
|
|
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
|
}
|
package/bin/lib/machines.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
|
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.
|
|
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
|
|
package/bin/lib/mcp-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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);
|
package/bin/lib/preflight.mjs
CHANGED
|
@@ -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(
|
|
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
|
|
package/bin/lib/update.mjs
CHANGED
|
@@ -30,7 +30,14 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { execFileSync, spawn } from 'node:child_process';
|
|
33
|
-
import {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 —
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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.
|
|
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
|
},
|