flowviant 0.93.0 → 0.95.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 +22 -0
- package/bin/cli.mjs +122 -30
- package/bin/lib/artifacts.mjs +298 -0
- package/bin/lib/claude.mjs +9 -1
- package/bin/lib/credentials.mjs +55 -0
- package/bin/lib/fleet.mjs +34 -0
- package/bin/lib/instance.mjs +98 -65
- package/bin/lib/knowledge.mjs +531 -0
- package/bin/lib/login.mjs +77 -2
- package/bin/lib/machines.mjs +216 -5
- package/bin/lib/prompts.mjs +89 -0
- package/bin/lib/runtimes.mjs +6 -1
- package/bin/lib/work.mjs +110 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,8 +41,30 @@ Prefer an explicit token? Create a machine credential in the app and pass it dir
|
|
|
41
41
|
FLOWVIANT_MACHINE_TOKEN=fva_… npx flowviant@latest # FLOWVIANT_FLEET still works
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Prefer a plain command to `npx`? Install it once and every `npx flowviant@latest` in this README is just `flowviant`:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm i -g flowviant
|
|
48
|
+
flowviant login
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Either way is fine and both stay current on their own (below). npx is the zero-install door; a global install is what you want on a box you keep running, so the daemon, `flowviant login` and `flowviant machines` are ordinary commands.
|
|
52
|
+
|
|
44
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.
|
|
45
54
|
|
|
55
|
+
## Machines on this box
|
|
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: every `npx flowviant` in that directory has to ask which of them to serve.
|
|
58
|
+
|
|
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
|
+
|
|
61
|
+
- **disconnect this box from it** — stops the daemon serving that project here (that one credential's daemon, never the others), removes this box from the project's machines list in the app, and forgets the credential here. `flowviant machines --remove <project id>` is the same thing for a script.
|
|
62
|
+
- **forget it here only** — the credential is dropped from `~/.flowviant/credentials.json` and nothing else happens; the app keeps listing this box until it goes quiet. `--forget <project id>` for a script.
|
|
63
|
+
|
|
64
|
+
Stopping or removing a daemon on **another** computer is done in the app (project settings → Machines), where the person pressing it can see what they are changing.
|
|
65
|
+
|
|
66
|
+
`flowviant login` asks before it binds a second project to a repository that is already connected to one: keep both (every start there asks which to serve), replace (the repo serves the new project and this box is disconnected from the old one), or cancel.
|
|
67
|
+
|
|
46
68
|
## Sessions
|
|
47
69
|
|
|
48
70
|
Each tab in the Workbench is a persistent Claude session with its own worktree, and it stays where you left it — the branch outlives the tab. The daemon runs each turn in event mode and relays what the CLI is printing (thinking, reads, greps, commands) back to the tab, reports the worktree's branch and diffstat after every turn, and fetches a commit's patch when you click a sha in the app.
|
package/bin/cli.mjs
CHANGED
|
@@ -41,9 +41,12 @@
|
|
|
41
41
|
* Ambiguity is a picker on a TTY and a worded refusal headless — never a
|
|
42
42
|
* guess. `flowviant projects` lists what is stored; `--project <name|id>`
|
|
43
43
|
* picks without a prompt. `flowviant machines` (0.91.0) asks the server which
|
|
44
|
-
* BOXES have polled each of those credentials — the
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* BOXES have polled each of those credentials — the answer to "am I running
|
|
45
|
+
* duplicate or redundant daemons?", which the store alone cannot give because
|
|
46
|
+
* a daemon on another computer is invisible from here; since 0.95.0 it says
|
|
47
|
+
* out loud when two projects are bound to one repo, and on a terminal it is a
|
|
48
|
+
* menu (↑/↓, enter) whose verbs disconnect THIS box from a project or forget
|
|
49
|
+
* a credential here — `--remove <id>` / `--forget <id>` for a script.
|
|
47
50
|
*
|
|
48
51
|
* Env:
|
|
49
52
|
* FLOWVIANT_MACHINE_TOKEN the machine credential (or use `flowviant login`);
|
|
@@ -82,8 +85,11 @@ import { runLogin } from './lib/login.mjs';
|
|
|
82
85
|
// long-running process.
|
|
83
86
|
if (process.argv[2] === 'login') {
|
|
84
87
|
const noStart = process.argv.includes('--no-start');
|
|
85
|
-
await runLogin({ thenStart: !noStart });
|
|
86
|
-
|
|
88
|
+
const login = await runLogin({ thenStart: !noStart });
|
|
89
|
+
// A login the person CANCELLED at the second-project question saved nothing,
|
|
90
|
+
// so there is no credential for the child to serve — starting it would end
|
|
91
|
+
// in "no credential found" over a choice they just made on purpose.
|
|
92
|
+
if (noStart || !login?.saved) process.exit(0);
|
|
87
93
|
// Re-exec as a plain `flowviant` rather than falling through. config.mjs reads
|
|
88
94
|
// the credential at IMPORT time — which was before the login we just did — so
|
|
89
95
|
// this process still has an empty FLEET_TOKEN and would exit with "no
|
|
@@ -224,16 +230,19 @@ if (process.argv[2] === 'projects') {
|
|
|
224
230
|
}
|
|
225
231
|
|
|
226
232
|
// ── `flowviant machines` — every project connected on THIS box, and every box
|
|
227
|
-
// that has polled each of them.
|
|
233
|
+
// that has polled each of them; and, on a terminal, a menu over them.
|
|
228
234
|
//
|
|
229
235
|
// A THIRD COMMAND, and the owner's ruling it amends is his own: the terminal
|
|
230
236
|
// surface is `npx flowviant` and `npx flowviant login`, full stop — which is
|
|
231
237
|
// what deleted the old terminal flag for moving a machine — and he asked for
|
|
232
|
-
// this one directly ("the CLI gets a command to list connections").
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
238
|
+
// this one directly ("the CLI gets a command to list connections"). It shipped
|
|
239
|
+
// VIEW-ONLY, and on 2026-09-23 he asked for the rest: "it seems to be only a
|
|
240
|
+
// read cmd but I want to be able to interact with it and use arrow keys to
|
|
241
|
+
// select the machines or to remove them." So: the listing, then — only where a
|
|
242
|
+
// person can drive one — a menu. Its verbs act on THIS BOX'S OWN CONNECTIONS
|
|
243
|
+
// (stop the daemon here, leave the project's list in the app, forget the
|
|
244
|
+
// credential here) and never on another computer's daemon; machines.mjs
|
|
245
|
+
// carries the argument.
|
|
237
246
|
//
|
|
238
247
|
// The question is the owner's, verbatim: "is there a way to view ALL the
|
|
239
248
|
// connected flowviants? because im not sure if i have any duplicate or
|
|
@@ -250,14 +259,16 @@ if (process.argv[2] === 'projects') {
|
|
|
250
259
|
//
|
|
251
260
|
// EXIT 0 whatever it finds, including nothing: the listing is the answer the
|
|
252
261
|
// asker came for, and a non-zero code over "you have no projects connected"
|
|
253
|
-
// would make this command unusable in anything that checks one.
|
|
262
|
+
// would make this command unusable in anything that checks one. `--remove`
|
|
263
|
+
// is the one exit that can be 1, when the daemon it had to stop would not.
|
|
254
264
|
if (process.argv[2] === 'machines') {
|
|
255
265
|
const creds = await import('./lib/credentials.mjs');
|
|
256
266
|
const forgetAt = process.argv.indexOf('--forget');
|
|
257
267
|
if (forgetAt >= 0) {
|
|
258
|
-
// THE
|
|
259
|
-
// AMBIGUITY REFUSES rather than guessing — this deletes a
|
|
260
|
-
// two projects that look alike is the case that produced
|
|
268
|
+
// THE LOCAL-ONLY WRITE. Named explicitly, matched by full id or a ≥6
|
|
269
|
+
// prefix, and AMBIGUITY REFUSES rather than guessing — this deletes a
|
|
270
|
+
// credential, and two projects that look alike is the case that produced
|
|
271
|
+
// the command.
|
|
261
272
|
const res = creds.forgetStoredProject(process.argv[forgetAt + 1]);
|
|
262
273
|
if (res.error) {
|
|
263
274
|
console.error(`error: ${res.error}`);
|
|
@@ -269,16 +280,29 @@ if (process.argv[2] === 'machines') {
|
|
|
269
280
|
);
|
|
270
281
|
process.exit(0);
|
|
271
282
|
}
|
|
283
|
+
const { boxesUrlFrom, leaveUrlFrom, fetchBoxesFor, renderMachines, disconnectHere, realDisconnectDeps, MACHINES_FOOTER, MACHINES_FLAGS_FOOTER } =
|
|
284
|
+
await import('./lib/machines.mjs');
|
|
285
|
+
const { FLEET_URL } = await import('./lib/config.mjs');
|
|
286
|
+
const removeAt = process.argv.indexOf('--remove');
|
|
287
|
+
if (removeAt >= 0) {
|
|
288
|
+
// THE SCRIPTED DISCONNECT — the menu's first verb, named by id for a
|
|
289
|
+
// terminal nobody is sitting at. Same matcher and the same refusal of an
|
|
290
|
+
// ambiguous prefix as --forget: a name two projects share is exactly the
|
|
291
|
+
// input this command exists to untangle, and it must not pick one.
|
|
292
|
+
const m = creds.matchStoredProject(process.argv[removeAt + 1]);
|
|
293
|
+
if (m.error) {
|
|
294
|
+
console.error(`error: ${m.error}. \`flowviant machines\` lists what is stored.`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
const res = await disconnectHere(m.entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL) }));
|
|
298
|
+
process.exit(res.ok ? 0 : 1);
|
|
299
|
+
}
|
|
272
300
|
|
|
273
|
-
|
|
301
|
+
let entries = creds.listStoredProjects();
|
|
274
302
|
if (entries.length === 0) {
|
|
275
303
|
console.log('no projects connected on this machine yet — run `flowviant login` inside a repo.');
|
|
276
304
|
process.exit(0);
|
|
277
305
|
}
|
|
278
|
-
const { boxesUrlFrom, fetchBoxesFor, renderMachines, MACHINES_FOOTER } = await import(
|
|
279
|
-
'./lib/machines.mjs'
|
|
280
|
-
);
|
|
281
|
-
const { FLEET_URL } = await import('./lib/config.mjs');
|
|
282
306
|
// OUR OWN BOX ID, so the listing can mark "← this box".
|
|
283
307
|
//
|
|
284
308
|
// READ, NEVER CREATED. This called `ensureKeypair()` until 2026-09-19, which
|
|
@@ -299,16 +323,83 @@ if (process.argv[2] === 'machines') {
|
|
|
299
323
|
/* unreadable keypair — nothing is marked, and nothing is claimed */
|
|
300
324
|
}
|
|
301
325
|
const url = boxesUrlFrom(FLEET_URL);
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
326
|
+
const listing = async () => {
|
|
327
|
+
const results = {};
|
|
328
|
+
// SEQUENTIAL, not a fan-out: this is a handful of credentials on somebody's
|
|
329
|
+
// laptop, and a parallel burst against the API buys nothing a person
|
|
330
|
+
// waiting two seconds can perceive.
|
|
331
|
+
for (const e of entries) {
|
|
332
|
+
results[e.projectId] = await fetchBoxesFor(e, { url, envpub: me });
|
|
333
|
+
}
|
|
334
|
+
console.log('');
|
|
335
|
+
for (const line of renderMachines(entries, results)) console.log(line);
|
|
336
|
+
console.log('');
|
|
337
|
+
};
|
|
338
|
+
await listing();
|
|
339
|
+
|
|
340
|
+
// THE MENU, only where a person can drive one. `canPrompt()` is the same
|
|
341
|
+
// gate the start path keeps — a backgrounded job or a pipe gets the listing
|
|
342
|
+
// and the flags, never a prompt that stops the process — and `menuSupported`
|
|
343
|
+
// is whether ↑/↓ can be read at all. Without both this is the command it was.
|
|
344
|
+
const { canPrompt, menuSupported, selectMenu, askWithTimeout } = await import('./lib/tty.mjs');
|
|
345
|
+
if (!(canPrompt() && menuSupported())) {
|
|
346
|
+
for (const line of MACHINES_FOOTER) console.log(line);
|
|
347
|
+
for (const line of MACHINES_FLAGS_FOOTER) console.log(line);
|
|
348
|
+
process.exit(0);
|
|
349
|
+
}
|
|
350
|
+
const deps = await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL) });
|
|
351
|
+
for (;;) {
|
|
352
|
+
// A ROW PER PROJECT connected on this box — the things this box can act on.
|
|
353
|
+
// The other computers under each project are printed above for the
|
|
354
|
+
// duplicate to be visible, and are not rows here: stopping or removing
|
|
355
|
+
// THEM is the app's verb, and a menu row that answers "not from here" is
|
|
356
|
+
// a control wired to a refusal.
|
|
357
|
+
console.log(' projects connected on this box — enter one for what you can do about it:');
|
|
358
|
+
const rowLabel = (e) => creds.projectRowLabel(e, entries) + (e.repoRoot ? ` — ${e.repoRoot}` : ' — not tied to a repo');
|
|
359
|
+
const pick = await selectMenu({ options: [...entries.map(rowLabel), 'done'] });
|
|
360
|
+
if (pick.cancelled || pick.unsupported || pick.index === entries.length) break;
|
|
361
|
+
const picked = entries[pick.index];
|
|
362
|
+
const who = creds.projectRowLabel(picked, entries);
|
|
363
|
+
// TWO VERBS, each row saying its whole consequence — the app's ⋯ menu on a
|
|
364
|
+
// machine row says the same two things, and a menu whose rows are verbs
|
|
365
|
+
// with the outcome in a manual somewhere is one you have to be sure about
|
|
366
|
+
// before you can use it.
|
|
367
|
+
const action = await selectMenu({
|
|
368
|
+
options: [
|
|
369
|
+
`disconnect this box from ${who} — stops its daemon here, removes this box from its machines list in the app, forgets its credential here`,
|
|
370
|
+
`forget ${who} here only — nothing is stopped; the app keeps listing this box until it goes quiet`,
|
|
371
|
+
'back',
|
|
372
|
+
],
|
|
373
|
+
});
|
|
374
|
+
if (action.cancelled || action.unsupported || action.index === 2) continue;
|
|
375
|
+
// ONE CONFIRM, in words, because both verbs delete a credential and the
|
|
376
|
+
// arrow keys make a slip cheap. A read that failed (stdin gone) is "no".
|
|
377
|
+
const verb = action.index === 0 ? 'Disconnect this box from' : 'Forget';
|
|
378
|
+
const answer = await askWithTimeout(` ${verb} ${who}? [y/N] `);
|
|
379
|
+
if (!/^y(es)?$/i.test(answer ?? '')) {
|
|
380
|
+
console.log(' left as it was.\n');
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (action.index === 0) {
|
|
384
|
+
await disconnectHere(picked, deps, { log: (m) => console.log(` ${m}`) });
|
|
385
|
+
} else {
|
|
386
|
+
const res = creds.forgetStoredProject(picked.projectId);
|
|
387
|
+
console.log(
|
|
388
|
+
res.error
|
|
389
|
+
? ` ${res.error}`
|
|
390
|
+
: ` forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box. Nothing was stopped or deleted anywhere else — \`flowviant login\` connects it again.`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
entries = creds.listStoredProjects();
|
|
394
|
+
if (entries.length === 0) {
|
|
395
|
+
console.log('\n no projects are connected on this box now.\n');
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
// THE LISTING AGAIN, re-asked: the server's rows are the only true account
|
|
399
|
+
// of what the leave did, and a menu redrawn over a stale listing would be
|
|
400
|
+
// this command asserting the outcome it hoped for.
|
|
401
|
+
await listing();
|
|
308
402
|
}
|
|
309
|
-
console.log('');
|
|
310
|
-
for (const line of renderMachines(entries, results)) console.log(line);
|
|
311
|
-
console.log('');
|
|
312
403
|
for (const line of MACHINES_FOOTER) console.log(line);
|
|
313
404
|
process.exit(0);
|
|
314
405
|
}
|
|
@@ -368,7 +459,8 @@ const externalToken =
|
|
|
368
459
|
* pattern: config.mjs read the store at IMPORT time, before the credential
|
|
369
460
|
* existed, so this process cannot serve; the child can. */
|
|
370
461
|
async function reexecAfterLogin() {
|
|
371
|
-
await runLogin({ thenStart: false });
|
|
462
|
+
const login = await runLogin({ thenStart: false });
|
|
463
|
+
if (!login?.saved) process.exit(0); // cancelled at the second-project question — nothing to serve
|
|
372
464
|
const { spawn } = await import('node:child_process');
|
|
373
465
|
const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
|
|
374
466
|
process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARTIFACTS, daemon side (2026-09-22, 0.94.0) — the files a turn wrote to SHOW
|
|
3
|
+
* the person, found after the turn and relayed.
|
|
4
|
+
*
|
|
5
|
+
* The owner asked whether Flowviant could "have it generate artifacts and
|
|
6
|
+
* display artifacts there". The turn contracts gain one paragraph
|
|
7
|
+
* (`ARTIFACTS_PARAGRAPH`, prompts.mjs): to show a page, a document, a chart or
|
|
8
|
+
* an image, write it under `.flowviant/artifacts/` in the directory you stand
|
|
9
|
+
* in. After each session turn and each agent turn this module lists that
|
|
10
|
+
* directory and uploads what the turn changed to `POST /fleet/artifact`; the
|
|
11
|
+
* Workbench draws it beside the thread. NOTHING HERE MAKES AN ARTIFACT — it
|
|
12
|
+
* reads what the CLI wrote and relays it, scrubbed.
|
|
13
|
+
*
|
|
14
|
+
* ── WHAT THE TURN CHANGED, NOT WHAT IS THERE ──
|
|
15
|
+
*
|
|
16
|
+
* The directory is snapshotted (name → size:mtime) BEFORE the CLI spawns and
|
|
17
|
+
* compared after it exits. The spec's words were "changed since the last
|
|
18
|
+
* report", and a per-turn snapshot is the strict form of that, chosen for two
|
|
19
|
+
* reasons: (1) every tab one person owns shares ONE place (the checkout, or
|
|
20
|
+
* their own copy), so a file one tab wrote is on disk when a sibling tab's turn
|
|
21
|
+
* ends — diffing against "last reported" per session would hand tab B every
|
|
22
|
+
* artifact tab A ever drew; and (2) a snapshot needs no memory across a daemon
|
|
23
|
+
* restart, which a "last reported" map would lose on every auto-update and then
|
|
24
|
+
* re-upload the whole directory. The residual, stated: two tabs in one place
|
|
25
|
+
* running turns AT THE SAME TIME (the place lock admits concurrent readers) can
|
|
26
|
+
* each see the other's write land inside their window. The server's upsert by
|
|
27
|
+
* (owner, name) and its bound keep that a duplicate, never a wrong answer.
|
|
28
|
+
*
|
|
29
|
+
* ── THE BOUNDS ──
|
|
30
|
+
*
|
|
31
|
+
* Regular files only, DEPTH ONE, `lstat` and never followed: a symlink an
|
|
32
|
+
* agent planted at `.flowviant/artifacts/x.txt -> ~/.ssh/id_ed25519` is
|
|
33
|
+
* reported as nothing, and the read itself opens with `O_NOFOLLOW` so a swap
|
|
34
|
+
* between the lstat and the open cannot turn one into a follow. The directory
|
|
35
|
+
* itself must be a real directory for the same reason. At most 40 files,
|
|
36
|
+
* newest first. Types off the allowlist (the server's list, repeated here —
|
|
37
|
+
* this is somebody's disk and one place checking is one deploy from zero) are
|
|
38
|
+
* reported BY NAME and never read. Over `ARTIFACT_MAX_BYTES` likewise.
|
|
39
|
+
*
|
|
40
|
+
* ── TEXT IS SCRUBBED ──
|
|
41
|
+
*
|
|
42
|
+
* html, md, svg, json, csv and txt pass through `envScrub` before upload — the
|
|
43
|
+
* turn trace's own scrub, for the trace's own reason: a page the agent wrote
|
|
44
|
+
* can quote a value out of the checkout's `.env`, and an artifact is served to
|
|
45
|
+
* a browser. Binary images are not scrubbed (there is no text to match), and
|
|
46
|
+
* the SERVER computes the sha256 of what it stored; the one sent here is for
|
|
47
|
+
* the log.
|
|
48
|
+
*
|
|
49
|
+
* ── DELIVERY ──
|
|
50
|
+
*
|
|
51
|
+
* A 2xx is delivered. A 4xx (other than 408/429) is TREATED AS DELIVERED — an
|
|
52
|
+
* older SERVER 404s the whole route, and holding bodies it will refuse forever
|
|
53
|
+
* would be the trace relay's wedge wearing a retry's clothes; a refusal of one
|
|
54
|
+
* file (ended session, wrong owner) is equally permanent. A 5xx, 408, 429 or a
|
|
55
|
+
* network error keeps the STORED BODY — the fields and the bytes as they were
|
|
56
|
+
* scrubbed — and retries it on the next report beat, the settle's shape, at
|
|
57
|
+
* most `MAX_TRIES` times; a newer copy of the same file replaces a held one.
|
|
58
|
+
*
|
|
59
|
+
* NO VERSION FLOOR: a daemon→server report on a new endpoint. The prompt
|
|
60
|
+
* paragraph is gated instead, on the roster's `artifactsAccepted` — a server
|
|
61
|
+
* that cannot show an artifact must not have the CLI told it will.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { constants, closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync } from 'node:fs';
|
|
65
|
+
import { createHash } from 'node:crypto';
|
|
66
|
+
import { join } from 'node:path';
|
|
67
|
+
|
|
68
|
+
/** Relative to the directory the turn stands in. Under `.flowviant/`, which
|
|
69
|
+
* the exclude file already hides from git (`excludeInWorktree`). */
|
|
70
|
+
export const ARTIFACT_DIR = '.flowviant/artifacts';
|
|
71
|
+
/** The server's per-file ceiling, re-checked here. */
|
|
72
|
+
export const ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
|
|
73
|
+
/** Listed per scan, newest first — the server keeps as many per owner. */
|
|
74
|
+
export const ARTIFACT_MAX_FILES = 40;
|
|
75
|
+
/** A held upload is tried this many times in all, then dropped with a line. */
|
|
76
|
+
const MAX_TRIES = 4;
|
|
77
|
+
/** Held bodies across every owner — each can be two megabytes. */
|
|
78
|
+
const MAX_PENDING = 40;
|
|
79
|
+
|
|
80
|
+
/** The allowlist, by extension. The server's `ARTIFACT_TYPES`, repeated. */
|
|
81
|
+
export const ARTIFACT_TYPES = {
|
|
82
|
+
html: { mime: 'text/html', text: true },
|
|
83
|
+
htm: { mime: 'text/html', text: true },
|
|
84
|
+
md: { mime: 'text/markdown', text: true },
|
|
85
|
+
svg: { mime: 'image/svg+xml', text: true },
|
|
86
|
+
png: { mime: 'image/png', text: false },
|
|
87
|
+
jpg: { mime: 'image/jpeg', text: false },
|
|
88
|
+
jpeg: { mime: 'image/jpeg', text: false },
|
|
89
|
+
gif: { mime: 'image/gif', text: false },
|
|
90
|
+
webp: { mime: 'image/webp', text: false },
|
|
91
|
+
json: { mime: 'application/json', text: true },
|
|
92
|
+
csv: { mime: 'text/csv', text: true },
|
|
93
|
+
txt: { mime: 'text/plain', text: true },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function artifactTypeFor(name) {
|
|
97
|
+
const dot = String(name).lastIndexOf('.');
|
|
98
|
+
if (dot <= 0) return null;
|
|
99
|
+
return ARTIFACT_TYPES[String(name).slice(dot + 1).toLowerCase()] ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The directory's regular files, newest first, at most `ARTIFACT_MAX_FILES`.
|
|
104
|
+
* `[]` for a directory that does not exist, is not a directory, or is a
|
|
105
|
+
* symlink — nothing to report is the only honest reading of any of those.
|
|
106
|
+
*/
|
|
107
|
+
export function scanArtifacts(placeDir) {
|
|
108
|
+
const dir = join(placeDir, ARTIFACT_DIR);
|
|
109
|
+
/**
|
|
110
|
+
* BOTH components are checked, not only the last (2026-09-23). `lstat` on
|
|
111
|
+
* `.flowviant/artifacts` refuses a symlink AT that name, but it resolves
|
|
112
|
+
* THROUGH a symlinked `.flowviant` — and git commits symlinks, so a cloned
|
|
113
|
+
* repo carrying `.flowviant -> /some/dir` would have had this scan listing,
|
|
114
|
+
* reading and uploading `/some/dir/artifacts/*`, a directory outside the
|
|
115
|
+
* place the turn stood in. The knowledge sync already refuses a `.flowviant`
|
|
116
|
+
* that is not a real directory (`flowviantDirOk`); this is the same check on
|
|
117
|
+
* the read side.
|
|
118
|
+
*/
|
|
119
|
+
try {
|
|
120
|
+
const parent = lstatSync(join(placeDir, '.flowviant'));
|
|
121
|
+
if (parent.isSymbolicLink() || !parent.isDirectory()) return [];
|
|
122
|
+
if (!lstatSync(dir).isDirectory()) return [];
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
let names;
|
|
127
|
+
try {
|
|
128
|
+
names = readdirSync(dir);
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const name of names) {
|
|
134
|
+
if (name.startsWith('.')) continue; // editor swap files, `.DS_Store`
|
|
135
|
+
try {
|
|
136
|
+
const st = lstatSync(join(dir, name));
|
|
137
|
+
if (!st.isFile()) continue; // symlinks, directories, sockets: nothing
|
|
138
|
+
out.push({ name, path: join(dir, name), size: st.size, mtimeMs: st.mtimeMs });
|
|
139
|
+
} catch {
|
|
140
|
+
/* vanished between the list and the stat */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
|
144
|
+
return out.slice(0, ARTIFACT_MAX_FILES);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sigOf = (e) => `${e.size}:${e.mtimeMs}`;
|
|
148
|
+
|
|
149
|
+
/** name → size:mtime, taken before the CLI spawns. */
|
|
150
|
+
export function snapshotArtifacts(placeDir) {
|
|
151
|
+
return new Map(scanArtifacts(placeDir).map((e) => [e.name, sigOf(e)]));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What the turn wrote: every entry whose (name, size, mtime) is not what the
|
|
155
|
+
* snapshot held. A deleted file is not reported — the server's copy is
|
|
156
|
+
* scrollback and goes with the session or the agent. */
|
|
157
|
+
export function changedArtifacts(before, entries) {
|
|
158
|
+
return entries.filter((e) => before?.get(e.name) !== sigOf(e));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Read a regular file WITHOUT following a symlink planted after the lstat. */
|
|
162
|
+
function readNoFollow(path, limit) {
|
|
163
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
164
|
+
try {
|
|
165
|
+
const st = fstatSync(fd);
|
|
166
|
+
if (!st.isFile() || st.size > limit) return null;
|
|
167
|
+
const buf = Buffer.alloc(st.size);
|
|
168
|
+
let off = 0;
|
|
169
|
+
while (off < st.size) {
|
|
170
|
+
const n = readSync(fd, buf, off, st.size - off, off);
|
|
171
|
+
if (n <= 0) break;
|
|
172
|
+
off += n;
|
|
173
|
+
}
|
|
174
|
+
return buf.subarray(0, off);
|
|
175
|
+
} finally {
|
|
176
|
+
closeSync(fd);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* ONE UPLOAD BODY, as data — fields plus optional bytes — so a held body is
|
|
182
|
+
* re-sent exactly as it was first built (scrubbed once, never re-read from a
|
|
183
|
+
* disk the agent may have changed since).
|
|
184
|
+
*/
|
|
185
|
+
export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s) {
|
|
186
|
+
const type = artifactTypeFor(entry.name);
|
|
187
|
+
const fields = {
|
|
188
|
+
...(sessionId ? { sessionId } : { agentId }),
|
|
189
|
+
...(turnId ? { turnId } : {}),
|
|
190
|
+
name: entry.name,
|
|
191
|
+
bytes: String(entry.size),
|
|
192
|
+
};
|
|
193
|
+
if (!type) return { fields, bytes: null }; // reported by name only
|
|
194
|
+
if (entry.size > ARTIFACT_MAX_BYTES) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
|
|
195
|
+
let bytes;
|
|
196
|
+
try {
|
|
197
|
+
bytes = readNoFollow(entry.path, ARTIFACT_MAX_BYTES);
|
|
198
|
+
} catch {
|
|
199
|
+
return null; // vanished, or a symlink swapped in: nothing to say
|
|
200
|
+
}
|
|
201
|
+
if (!bytes) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
|
|
202
|
+
if (type.text) {
|
|
203
|
+
bytes = Buffer.from(String(scrub(bytes.toString('utf8'))), 'utf8');
|
|
204
|
+
// A redaction marker can be longer than the value it replaced.
|
|
205
|
+
if (bytes.byteLength > ARTIFACT_MAX_BYTES) {
|
|
206
|
+
return { fields: { ...fields, bytes: String(bytes.byteLength), tooLarge: '1' }, bytes: null };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
fields: {
|
|
211
|
+
...fields,
|
|
212
|
+
bytes: String(bytes.byteLength),
|
|
213
|
+
mime: type.mime,
|
|
214
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
215
|
+
},
|
|
216
|
+
bytes,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The reporter a work manager holds for its life. `report` is called once per
|
|
222
|
+
* finished turn and never awaited by the turn (a slow uplink must not hold the
|
|
223
|
+
* next turn behind a readout); `retryPending` rides the settle retry beat.
|
|
224
|
+
*/
|
|
225
|
+
export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetchImpl, log = () => {} }) {
|
|
226
|
+
const url = String(fleetUrl).replace(/\/agents\/?$/, '/artifact');
|
|
227
|
+
const doFetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
228
|
+
const pending = new Map(); // `${owner}:${name}` -> { body, tries }
|
|
229
|
+
|
|
230
|
+
/** true = settled (delivered or permanently refused); false = hold it. */
|
|
231
|
+
const post = async (body) => {
|
|
232
|
+
const form = new FormData();
|
|
233
|
+
for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
|
|
234
|
+
if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
|
|
235
|
+
try {
|
|
236
|
+
const res = await doFetch(url, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
|
|
239
|
+
signal: AbortSignal.timeout(30_000),
|
|
240
|
+
body: form,
|
|
241
|
+
});
|
|
242
|
+
return (
|
|
243
|
+
res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
|
|
244
|
+
);
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const hold = (key, body, tries) => {
|
|
251
|
+
if (tries >= MAX_TRIES) {
|
|
252
|
+
pending.delete(key);
|
|
253
|
+
log(`artifact ${body.fields.name}: not delivered after ${tries} tries — dropped`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
pending.delete(key); // re-insert at the tail, so the cap sheds the oldest
|
|
257
|
+
pending.set(key, { body, tries });
|
|
258
|
+
while (pending.size > MAX_PENDING) pending.delete(pending.keys().next().value);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const send = async (key, body, tries) => {
|
|
262
|
+
if (await post(body)) pending.delete(key);
|
|
263
|
+
else hold(key, body, tries + 1);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
let retrying = false;
|
|
267
|
+
const retryPending = async () => {
|
|
268
|
+
if (retrying || pending.size === 0) return;
|
|
269
|
+
retrying = true;
|
|
270
|
+
try {
|
|
271
|
+
for (const [key, held] of [...pending]) {
|
|
272
|
+
if (pending.get(key) !== held) continue; // superseded meanwhile
|
|
273
|
+
await send(key, held.body, held.tries);
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
retrying = false;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** Upload what this turn changed in `placeDir`. Resolves to the number of
|
|
281
|
+
* bodies SENT this call (delivered or held), for the tests and the log. */
|
|
282
|
+
const report = async ({ placeDir, before, sessionId, agentId, turnId }) => {
|
|
283
|
+
if (!placeDir || (!sessionId && !agentId)) return 0;
|
|
284
|
+
const changed = changedArtifacts(before, scanArtifacts(placeDir));
|
|
285
|
+
let n = 0;
|
|
286
|
+
for (const entry of changed) {
|
|
287
|
+
const body = buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub);
|
|
288
|
+
if (!body) continue;
|
|
289
|
+
const key = `${sessionId ?? agentId}:${entry.name}`;
|
|
290
|
+
pending.delete(key); // this copy supersedes a held older one
|
|
291
|
+
await send(key, body, 0);
|
|
292
|
+
n++;
|
|
293
|
+
}
|
|
294
|
+
return n;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return { report, retryPending, pendingCount: () => pending.size };
|
|
298
|
+
}
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -351,7 +351,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
351
351
|
// returned string for sentinel detection, and each activity is handed to
|
|
352
352
|
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
353
353
|
// off and keep the raw text passthrough + line sentinels.
|
|
354
|
-
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
354
|
+
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
355
355
|
return new Promise((resolve) => {
|
|
356
356
|
const rt = runtimeById(runtime);
|
|
357
357
|
if (!rt.args) {
|
|
@@ -405,6 +405,14 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
405
405
|
// runtime that can path-scope its writes needs to know WHERE the vault is,
|
|
406
406
|
// and Claude — which cannot — simply ignores it.
|
|
407
407
|
vaultDir,
|
|
408
|
+
// THE PROJECT'S KNOWLEDGE LIBRARY (0.94.0), when this box holds one. It
|
|
409
|
+
// lives in the CHECKOUT and a turn usually runs in a worktree, so the
|
|
410
|
+
// prompt hands an absolute path OUTSIDE the cwd — and a curated Claude
|
|
411
|
+
// profile (the capture chat's read-only list, `FLOWVIANT_SAFE=1`) may
|
|
412
|
+
// refuse a read there. `--add-dir` says the directory is one it may read.
|
|
413
|
+
// Only Claude's adapter uses it; codex's sandboxes read the filesystem
|
|
414
|
+
// already, and agy's `--add-dir` is spent on the wiki vault.
|
|
415
|
+
knowledgeDir,
|
|
408
416
|
// Structured-output flags for the MEDIATED path. Handed to the adapter
|
|
409
417
|
// rather than appended here for the same reason `mcp` is: Codex takes its
|
|
410
418
|
// prompt as a trailing positional, so a flag after it is in the wrong
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -172,6 +172,61 @@ export function projectRowLabel(e, entries) {
|
|
|
172
172
|
return `${label} (id ${String(e?.projectId ?? '').slice(0, 8)}…${when})`;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* WHICH STORED PROJECTS SHARE ONE REPO — the duplicate the owner met and could
|
|
177
|
+
* not name (2026-09-23).
|
|
178
|
+
*
|
|
179
|
+
* `npx flowviant machines` printed "BRIF AI" twice, and he read it as two
|
|
180
|
+
* daemons or two "machine profiles" on one box for one project: *"when I go to
|
|
181
|
+
* BRIF AI to disconnect the duplicate, I only see one."* It was two PROJECTS
|
|
182
|
+
* with one name, both bound to one checkout — and the id-on-every-row rule
|
|
183
|
+
* (2026-09-19) had not made that legible, because two hex prefixes say
|
|
184
|
+
* "different" without saying "same repo". A project's Machines page shows its
|
|
185
|
+
* own credential's boxes, so neither page could show the other project at
|
|
186
|
+
* all.
|
|
187
|
+
*
|
|
188
|
+
* So the collision is stated as a FACT ABOUT THE REPO: every group of two or
|
|
189
|
+
* more entries bound to one directory, compared by realpath the way every
|
|
190
|
+
* other path here is. Pure over the entries; the listing prints each group as
|
|
191
|
+
* one line and the picker's row labels already carry the id and date.
|
|
192
|
+
*
|
|
193
|
+
* ONE REPO SERVES ONE PROJECT is the owner's own model ("i can have 1 daemon in
|
|
194
|
+
* one directory, 1 in another as a differentiator of projects"), which is why
|
|
195
|
+
* this is worth a sentence and an unbound duplicate is not: two projects with
|
|
196
|
+
* no repo are two things nothing has decided about yet.
|
|
197
|
+
*/
|
|
198
|
+
export function repoCollisions(entries) {
|
|
199
|
+
const groups = [];
|
|
200
|
+
for (const e of entries) {
|
|
201
|
+
if (!e?.repoRoot) continue;
|
|
202
|
+
const g = groups.find((x) => samePath(x.repoRoot, e.repoRoot));
|
|
203
|
+
if (g) g.entries.push(e);
|
|
204
|
+
else groups.push({ repoRoot: e.repoRoot, entries: [e] });
|
|
205
|
+
}
|
|
206
|
+
return groups.filter((g) => g.entries.length > 1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* THE OTHER PROJECTS ALREADY BOUND TO THIS REPO — what `flowviant login` asks
|
|
211
|
+
* about before it saves a second one (2026-09-23).
|
|
212
|
+
*
|
|
213
|
+
* The owner: *"im not sure how it even allowed me to run npx flowviant login
|
|
214
|
+
* twice and init a daemon twice on the same project/repository/directory in
|
|
215
|
+
* the first place."* Login binds the approved project to the repo it was run
|
|
216
|
+
* in and never looked at what was bound there already, so a second project —
|
|
217
|
+
* approved in a browser tab that happened to be open on a different project of
|
|
218
|
+
* the same name — landed beside the first without a word. The store's own
|
|
219
|
+
* resolution then turned every start into a picker between two identical
|
|
220
|
+
* rows.
|
|
221
|
+
*
|
|
222
|
+
* Excludes the project being logged into: re-running login for the SAME
|
|
223
|
+
* project is an upsert and asks nothing.
|
|
224
|
+
*/
|
|
225
|
+
export function boundElsewhere(entries, repoRoot, projectId) {
|
|
226
|
+
if (!repoRoot) return [];
|
|
227
|
+
return entries.filter((e) => e.projectId !== projectId && samePath(e.repoRoot, repoRoot));
|
|
228
|
+
}
|
|
229
|
+
|
|
175
230
|
/** Collapse a name or slug for loose comparison: "My Project", "my-project"
|
|
176
231
|
* and "myproject" all become "myproject". */
|
|
177
232
|
function normalizeName(s) {
|