flowviant 0.97.0 → 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -13
- package/bin/cli.mjs +108 -28
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +36 -9
- package/bin/lib/credentials.mjs +29 -5
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/desktopContract.mjs +227 -0
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +121 -30
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +129 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/launchCommand.mjs +23 -0
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +36 -12
- package/bin/lib/machines.mjs +24 -14
- package/bin/lib/mcp-cli.mjs +4 -3
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +114 -25
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1151 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Run your own coding CLIs as build agents for [Flowviant](https://flowviant.com) — [Claude Code](https://claude.com/claude-code), Codex or Antigravity, on your own credentials. This daemon holds your sessions, keeps a worktree per tab, and ships branches on your word. Flowviant never sees your Claude, Codex or GitHub logins.
|
|
4
4
|
|
|
5
|
-
```
|
|
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
|
|
|
@@ -487,9 +546,13 @@ function listLines(entries, { projectRowLabel }) {
|
|
|
487
546
|
.join('\n');
|
|
488
547
|
}
|
|
489
548
|
|
|
549
|
+
/** A repo binding the person just consented to, persisted by runFleetDaemon
|
|
550
|
+
* only after the instance lock is taken — never before a refusal. */
|
|
551
|
+
let afterLock = null;
|
|
552
|
+
|
|
490
553
|
if (!FLEET_TOKEN) {
|
|
491
554
|
if (CREDENTIAL.error) {
|
|
492
|
-
console.error(`error: ${CREDENTIAL.error}.
|
|
555
|
+
console.error(`error: ${CREDENTIAL.error}. \`${terminalCommand('projects')}\` lists what is stored.`);
|
|
493
556
|
process.exit(1);
|
|
494
557
|
}
|
|
495
558
|
// A DIRECTORY SERVES ONE PROJECT (2026-09-23, the owner, verbatim: "a
|
|
@@ -517,7 +580,7 @@ if (!FLEET_TOKEN) {
|
|
|
517
580
|
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
518
581
|
);
|
|
519
582
|
|
|
520
|
-
const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (
|
|
583
|
+
const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (${terminalCommand('login')})`;
|
|
521
584
|
// WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
|
|
522
585
|
// refuses to serve a project the repo PATH did not name (the skadooble law);
|
|
523
586
|
// this only decides which row the cursor starts on, using the repo's folder
|
|
@@ -589,21 +652,26 @@ if (!FLEET_TOKEN) {
|
|
|
589
652
|
console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
|
|
590
653
|
}
|
|
591
654
|
adoptStoredCredential(picked);
|
|
592
|
-
|
|
655
|
+
// BOUND ONLY ONCE THE START HAS THE LOCK. Written here, the answer moved
|
|
656
|
+
// the binding even when the instance lock then refused the start — a live
|
|
657
|
+
// daemon for the same project in its own checkout — and that daemon's next
|
|
658
|
+
// unattended self-update re-exec'd into a store that no longer bound its
|
|
659
|
+
// repo, found no match headless, and exited with the machine dark.
|
|
660
|
+
afterLock = () => creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
|
|
593
661
|
console.log(`serving ${creds.projectLabel(picked)}${repoRoot ? ` from ${repoRoot}` : ''}.`);
|
|
594
662
|
} else if (CREDENTIAL.choices?.length) {
|
|
595
663
|
const creds = await import('./lib/credentials.mjs');
|
|
596
664
|
console.error(
|
|
597
665
|
'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
|
|
598
666
|
listLines(CREDENTIAL.choices, creds) +
|
|
599
|
-
|
|
600
|
-
|
|
667
|
+
`\nPick one with \`--project <name|id>\`, bind this repo by running \`${terminalCommand()}\` here in a terminal once,\n` +
|
|
668
|
+
`or connect this repo to its own project with \`${terminalCommand('login')}\`.`
|
|
601
669
|
);
|
|
602
670
|
process.exit(1);
|
|
603
671
|
} else {
|
|
604
672
|
console.error(
|
|
605
673
|
'error: no credential found. Easiest:\n' +
|
|
606
|
-
'
|
|
674
|
+
` ${terminalCommand('login')} (approve in the app — recommended)\n` +
|
|
607
675
|
'Or set:\n' +
|
|
608
676
|
' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
|
|
609
677
|
);
|
|
@@ -643,17 +711,29 @@ if (!FLEET_TOKEN) {
|
|
|
643
711
|
if (raw === null) {
|
|
644
712
|
console.log(
|
|
645
713
|
`\n could not read an answer — serving ${label} for this run ` +
|
|
646
|
-
`without tying it to this repo. Run \`
|
|
714
|
+
`without tying it to this repo. Run \`${terminalCommand()}\` here and answer to make it stick.`
|
|
647
715
|
);
|
|
648
716
|
} else if (raw === '' || raw === 'y' || raw === 'yes') {
|
|
649
|
-
|
|
717
|
+
// After the lock, for the reason the picker's bind gives above.
|
|
718
|
+
afterLock = () => creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
|
|
650
719
|
} else {
|
|
651
720
|
console.error(
|
|
652
|
-
`nothing started. Connect this repo to its own project with
|
|
653
|
-
`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')}\`.`
|
|
654
723
|
);
|
|
655
724
|
process.exit(1);
|
|
656
725
|
}
|
|
657
726
|
}
|
|
658
727
|
|
|
659
|
-
|
|
728
|
+
if (process.argv.includes('--json-events') && dirAt >= 0 && CREDENTIAL?.entry) {
|
|
729
|
+
const { detectRepoRoot, listStoredProjects, boundElsewhere } = await import('./lib/credentials.mjs');
|
|
730
|
+
const repo = detectRepoRoot();
|
|
731
|
+
const entry = CREDENTIAL.entry;
|
|
732
|
+
if (!repo || entry.repoRoot !== repo || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
|
|
733
|
+
console.error('error: this project is not connected to this repository. Run `flowviant login` for the selected folder.');
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const { installDaemonLogging } = await import('./lib/desktopContract.mjs');
|
|
738
|
+
installDaemonLogging(CREDENTIAL?.entry?.projectId, { jsonEvents: process.argv.includes('--json-events') });
|
|
739
|
+
await runFleetDaemon({ afterLock });
|
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
* Run: node --test bin/lib/agentPlan.test.mjs
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
// THE UPLINK SCRUBBER, as the DEFAULT for the two readers whose every string
|
|
29
|
+
// goes to the server (2026-09-24, the audit). See `parseTurnResult`.
|
|
30
|
+
import { scrub as envScrub } from './env.mjs';
|
|
31
|
+
|
|
28
32
|
/** The biggest a single proposal may be. Bounds on a machine, not a policy:
|
|
29
33
|
* the server caps these again at its own boundary. */
|
|
30
34
|
const MAX_AGENTS = 20;
|
|
@@ -53,14 +57,60 @@ const MAX_NOTE = 1000;
|
|
|
53
57
|
* about escapes and nobody notices, because the disagreement only shows up on a
|
|
54
58
|
* card title with a quote in it.
|
|
55
59
|
*/
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
/**
|
|
61
|
+
* BOUNDED, BECAUSE THE TEXT IS A MODEL'S (2026-09-24, the audit).
|
|
62
|
+
*
|
|
63
|
+
* The scan used to walk from EVERY `{` to the end of the text, and parse every
|
|
64
|
+
* balanced span it found — quadratic on both counts. A final message of eighty
|
|
65
|
+
* thousand `{` (a quoted fixture, brace padding, a turn steered into writing
|
|
66
|
+
* one) took nine seconds of synchronous CPU, and while it ran nothing else on
|
|
67
|
+
* the machine moved: no poll, no lease renewal, no other lane. Three bounds,
|
|
68
|
+
* none of which a real answer comes near:
|
|
69
|
+
*
|
|
70
|
+
* · only the LAST `MAX_SCAN_CHARS` are scanned — every contract here puts the
|
|
71
|
+
* object LAST, and the fenced block is still looked for in the whole text;
|
|
72
|
+
* · each `{`'s closing brace is found ONCE (see `closers`), so the scan is a
|
|
73
|
+
* single right-to-left pass instead of one walk per brace;
|
|
74
|
+
* · only spans that can open an object (`{` then a key or `}`) are handed to
|
|
75
|
+
* JSON.parse. Every TOP-LEVEL span is parsed — top-level spans are
|
|
76
|
+
* disjoint, so together they are at most the scanned window — and the
|
|
77
|
+
* NESTED ones (a span inside an earlier candidate) share a budget of
|
|
78
|
+
* `MAX_NESTED_PARSES` spans and `MAX_NESTED_CHARS` characters, so a run of
|
|
79
|
+
* nested braces that parse is not re-parsed thousands of times.
|
|
80
|
+
*
|
|
81
|
+
* THE BUDGET NEVER REACHES A TOP-LEVEL SPAN, and the review found why it must
|
|
82
|
+
* not: the first cut counted every span from the left against one budget, so
|
|
83
|
+
* an answer that followed 250 JSON lines of a quoted fixture (or one large
|
|
84
|
+
* nested document) was never parsed at all — the turn read as `nothing` and a
|
|
85
|
+
* delivered agent went to Stuck. The contract puts the answer LAST and at the
|
|
86
|
+
* top level, which is the one span a budget must never cost.
|
|
87
|
+
*/
|
|
88
|
+
const MAX_SCAN_CHARS = 256 * 1024;
|
|
89
|
+
const MAX_NESTED_PARSES = 200;
|
|
90
|
+
const MAX_NESTED_CHARS = 4 * 1024 * 1024;
|
|
91
|
+
|
|
92
|
+
function candidateObjects(text) {
|
|
93
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
94
|
+
const raw = text.length > MAX_SCAN_CHARS ? text.slice(text.length - MAX_SCAN_CHARS) : text;
|
|
58
95
|
const bodies = [];
|
|
59
96
|
if (fenced) bodies.push(fenced[1]);
|
|
97
|
+
const close = closers(raw);
|
|
98
|
+
let topEnd = -1;
|
|
99
|
+
let nestedParses = 0;
|
|
100
|
+
let nestedChars = 0;
|
|
60
101
|
for (let i = 0; i < raw.length; i++) {
|
|
61
102
|
if (raw[i] !== '{') continue;
|
|
62
|
-
const end =
|
|
63
|
-
if (end > i)
|
|
103
|
+
const end = close.get(i);
|
|
104
|
+
if (!(end > i) || !/^\{\s*["}]/.test(raw.slice(i, i + 64))) continue;
|
|
105
|
+
if (i > topEnd) {
|
|
106
|
+
topEnd = end;
|
|
107
|
+
} else {
|
|
108
|
+
const len = end - i + 1;
|
|
109
|
+
if (nestedParses >= MAX_NESTED_PARSES || nestedChars + len > MAX_NESTED_CHARS) continue;
|
|
110
|
+
nestedParses++;
|
|
111
|
+
nestedChars += len;
|
|
112
|
+
}
|
|
113
|
+
bodies.push(raw.slice(i, end + 1));
|
|
64
114
|
}
|
|
65
115
|
const out = [];
|
|
66
116
|
for (const body of bodies) {
|
|
@@ -75,32 +125,51 @@ function candidateObjects(raw) {
|
|
|
75
125
|
return out;
|
|
76
126
|
}
|
|
77
127
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
128
|
+
/**
|
|
129
|
+
* For every `{`, the index of the `}` that closes it when read as the START of
|
|
130
|
+
* an object (outside any string), or -1 — the answer `balanced` gives, for all
|
|
131
|
+
* of them in one pass.
|
|
132
|
+
*
|
|
133
|
+
* Right to left, so every `{` AFTER the one being closed already has its
|
|
134
|
+
* answer. A walk that meets such a `{` outside a string jumps straight to that
|
|
135
|
+
* brace's closer — the string state from there on is the same for both walks,
|
|
136
|
+
* so the nested object ends where it ends — and if that brace never closes,
|
|
137
|
+
* neither does this one. What is left for each walk is its own top level.
|
|
138
|
+
*/
|
|
139
|
+
function closers(s) {
|
|
140
|
+
const close = new Map();
|
|
141
|
+
for (let i = s.lastIndexOf('{'); i >= 0; i = i > 0 ? s.lastIndexOf('{', i - 1) : -1) {
|
|
142
|
+
let inStr = false;
|
|
143
|
+
let esc = false;
|
|
144
|
+
let end = -1;
|
|
145
|
+
for (let k = i + 1; k < s.length; k++) {
|
|
146
|
+
const ch = s[k];
|
|
147
|
+
if (inStr) {
|
|
148
|
+
if (esc) esc = false;
|
|
149
|
+
else if (ch === '\\') esc = true;
|
|
150
|
+
else if (ch === '"') inStr = false;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (ch === '"') inStr = true;
|
|
154
|
+
else if (ch === '{') {
|
|
155
|
+
const inner = close.get(k);
|
|
156
|
+
if (inner === undefined || inner < 0) break;
|
|
157
|
+
k = inner;
|
|
158
|
+
} else if (ch === '}') {
|
|
159
|
+
end = k;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
95
162
|
}
|
|
96
|
-
|
|
97
|
-
else if (ch === '{') depth++;
|
|
98
|
-
else if (ch === '}' && --depth === 0) return i;
|
|
163
|
+
close.set(i, end);
|
|
99
164
|
}
|
|
100
|
-
return
|
|
165
|
+
return close;
|
|
101
166
|
}
|
|
102
167
|
|
|
103
|
-
|
|
168
|
+
function extract(raw) {
|
|
169
|
+
return candidateObjects(raw).find((v) => Array.isArray(v.agents)) ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function parseProposal(text, scrub = envScrub) {
|
|
104
173
|
const raw = String(text ?? '');
|
|
105
174
|
const parsed = extract(raw);
|
|
106
175
|
if (!parsed) return null;
|
|
@@ -117,7 +186,7 @@ export function parseProposal(text) {
|
|
|
117
186
|
if (taskIds.length === 0) continue;
|
|
118
187
|
agents.push({
|
|
119
188
|
tempId: String(g.tempId || `a${i + 1}`).slice(0, 64),
|
|
120
|
-
name: String(g.name ?? '').slice(0, MAX_NAME),
|
|
189
|
+
name: String(scrub(String(g.name ?? ''))).slice(0, MAX_NAME),
|
|
121
190
|
taskIds,
|
|
122
191
|
...(Number.isFinite(g.pointsBudget) && g.pointsBudget > 0
|
|
123
192
|
? { pointsBudget: Math.min(Math.round(g.pointsBudget), 100_000) }
|
|
@@ -150,13 +219,45 @@ export function parseProposal(text) {
|
|
|
150
219
|
}
|
|
151
220
|
if (agents.length === 0) return null;
|
|
152
221
|
return {
|
|
153
|
-
agents,
|
|
222
|
+
agents: uniqueTempIds(agents),
|
|
154
223
|
...(typeof parsed.note === 'string' && parsed.note.trim()
|
|
155
|
-
? { note: parsed.note.slice(0, MAX_NOTE) }
|
|
224
|
+
? { note: String(scrub(parsed.note)).slice(0, MAX_NOTE) }
|
|
156
225
|
: {}),
|
|
157
226
|
};
|
|
158
227
|
}
|
|
159
228
|
|
|
229
|
+
/**
|
|
230
|
+
* A DAEMON BELT ON THE SAME COLLISION THE SERVER ALREADY REFUSES TO SHIP
|
|
231
|
+
* (2026-09-24, the audit's A4a CROSS 5).
|
|
232
|
+
*
|
|
233
|
+
* Two groups can share one tempId two ways: the model repeats itself, or one
|
|
234
|
+
* group NAMES none and defaults to the positional `a${i+1}` a differently-
|
|
235
|
+
* ordered group already claimed. The web keys `draftByTask` / `proposedCards`
|
|
236
|
+
* by tempId (boardLanes.ts), so two containers sharing one id is not cosmetic —
|
|
237
|
+
* per-group Start/Decline on either one then deletes BOTH, because
|
|
238
|
+
* `planAfterGroup` filters `.agents` by tempId and both match.
|
|
239
|
+
*
|
|
240
|
+
* Kept a byte-for-byte copy of the server's own rule
|
|
241
|
+
* (`agentPlan.ts`'s `uniqueTempIds`, the boundary every daemon version passes
|
|
242
|
+
* through) rather than re-derived, so the two ends cannot disagree about which
|
|
243
|
+
* of two colliding agents keeps the id the model wrote. FIRST occurrence wins
|
|
244
|
+
* unchanged; each later collision is suffixed `-2`, `-3`, … off the ORIGINAL
|
|
245
|
+
* id (not the previous suffix), staying inside the 64-char bound the schema
|
|
246
|
+
* already enforces upstream.
|
|
247
|
+
*
|
|
248
|
+
* This is a BELT, not the fix: an older daemon proposing the same collision is
|
|
249
|
+
* still covered by the server's identical transform at its own boundary.
|
|
250
|
+
*/
|
|
251
|
+
function uniqueTempIds(list) {
|
|
252
|
+
const used = new Set();
|
|
253
|
+
return list.map((g) => {
|
|
254
|
+
let id = g.tempId;
|
|
255
|
+
for (let n = 2; used.has(id); n++) id = `${g.tempId.slice(0, 58)}-${n}`;
|
|
256
|
+
used.add(id);
|
|
257
|
+
return id === g.tempId ? g : { ...g, tempId: id };
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
160
261
|
/**
|
|
161
262
|
* The running account, off whichever shape the turn ended with — `{ progress }`
|
|
162
263
|
* or nothing at all.
|
|
@@ -181,9 +282,9 @@ export function parseProposal(text) {
|
|
|
181
282
|
* a machine to have clamped.
|
|
182
283
|
*/
|
|
183
284
|
const MAX_PROGRESS = 8000;
|
|
184
|
-
const progressOf = (v) => {
|
|
285
|
+
const progressOf = (v, scrub) => {
|
|
185
286
|
const t = typeof v.progress === 'string' ? v.progress.trim() : '';
|
|
186
|
-
return t ? { progress: t.slice(0, MAX_PROGRESS) } : {};
|
|
287
|
+
return t ? { progress: String(scrub(t)).slice(0, MAX_PROGRESS) } : {};
|
|
187
288
|
};
|
|
188
289
|
|
|
189
290
|
/**
|
|
@@ -222,7 +323,28 @@ const progressOf = (v) => {
|
|
|
222
323
|
* caller omits the field rather than sending an empty string, so the server
|
|
223
324
|
* keeps the last account that was true instead of blanking the head.
|
|
224
325
|
*/
|
|
225
|
-
|
|
326
|
+
/*
|
|
327
|
+
* ── EVERY STRING IS SCRUBBED HERE, BEFORE ITS CAP (2026-09-24, the audit) ──
|
|
328
|
+
*
|
|
329
|
+
* `raised` cards and a proposal's `note` and names reached the server with no
|
|
330
|
+
* scrub at all — the caller redacted `answer` and `progress` and nothing
|
|
331
|
+
* else — so a build turn that quoted `STRIPE_SECRET_KEY=sk_live_…` in a
|
|
332
|
+
* raised card's brief filed it, verbatim, as an Open card every member reads
|
|
333
|
+
* and later agents are prompted with; and a planner note quoting one became a
|
|
334
|
+
* notification body. And `answer` was cut at 8000 HERE, before the caller's
|
|
335
|
+
* scrub, which is the straddling-cut bug `progress` and the precheck already
|
|
336
|
+
* record: `scrub` matches whole values, so a credential across the cut kept
|
|
337
|
+
* its prefix. So the scrub rides in, as `parsePrecheck`'s does, and runs over
|
|
338
|
+
* each whole field first.
|
|
339
|
+
*
|
|
340
|
+
* DEFAULTED TO THE REAL SCRUBBER, unlike the precheck's identity default: the
|
|
341
|
+
* production callers pass nothing, and a redaction that depends on each caller
|
|
342
|
+
* remembering is the one that is missing the day it matters. Scrubbing twice
|
|
343
|
+
* (the caller still scrubs `answer`) is harmless — a redaction marker contains
|
|
344
|
+
* no secret to match.
|
|
345
|
+
*/
|
|
346
|
+
export function parseTurnResult(text, scrub = envScrub) {
|
|
347
|
+
const clean = (s, cap) => String(scrub(s)).slice(0, cap);
|
|
226
348
|
for (const v of candidateObjects(String(text ?? ''))) {
|
|
227
349
|
if (v.status === 'blocked') {
|
|
228
350
|
const question = typeof v.question === 'string' ? v.question.trim() : '';
|
|
@@ -230,21 +352,21 @@ export function parseTurnResult(text) {
|
|
|
230
352
|
// parks an agent with nothing to reply to. Treated as `nothing`, which
|
|
231
353
|
// at least says truthfully that the machine went quiet.
|
|
232
354
|
if (!question) continue;
|
|
233
|
-
return { outcome: 'question', answer: question
|
|
355
|
+
return { outcome: 'question', answer: clean(question, 8000), ...progressOf(v, scrub) };
|
|
234
356
|
}
|
|
235
357
|
if (v.status === 'delivered') {
|
|
236
358
|
return {
|
|
237
359
|
outcome: 'delivered',
|
|
238
|
-
answer:
|
|
239
|
-
...progressOf(v),
|
|
360
|
+
answer: typeof v.summary === 'string' ? clean(v.summary, 8000) : '',
|
|
361
|
+
...progressOf(v, scrub),
|
|
240
362
|
raised: Array.isArray(v.raised)
|
|
241
363
|
? v.raised
|
|
242
364
|
.filter((r) => r && typeof r.title === 'string' && r.title.trim())
|
|
243
365
|
.slice(0, 10)
|
|
244
366
|
.map((r) => ({
|
|
245
|
-
title: r.title.trim()
|
|
367
|
+
title: clean(r.title.trim(), 300),
|
|
246
368
|
...(typeof r.brief === 'string' && r.brief.trim()
|
|
247
|
-
? { brief: r.brief.trim()
|
|
369
|
+
? { brief: clean(r.brief.trim(), 2000) }
|
|
248
370
|
: {}),
|
|
249
371
|
}))
|
|
250
372
|
: [],
|