flowviant 0.54.2 → 0.55.1

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/bin/cli.mjs CHANGED
@@ -32,6 +32,13 @@
32
32
  * odd side job the roster carries — a commit's patch, a preview share, a wiki
33
33
  * regen. When you say ship, the daemon merges that branch into base `--no-ff`.
34
34
  *
35
+ * MANY PROJECTS, ONE BOX (0.55.0): `flowviant login` in each repo stores one
36
+ * credential per project (~/.flowviant/credentials.json holds a map), and a
37
+ * bare `npx flowviant` serves the project BOUND to the repo it is started in.
38
+ * Ambiguity is a picker on a TTY and a worded refusal headless — never a
39
+ * guess. `flowviant projects` lists what is stored; `--project <name|id>`
40
+ * picks without a prompt.
41
+ *
35
42
  * Env:
36
43
  * FLOWVIANT_FLEET the machine credential (or use `flowviant login`).
37
44
  * FLOWVIANT_API_URL default https://api.flowviant.com/api/v2
@@ -49,7 +56,7 @@
49
56
  * git + worktreeDiff + patch; localSessions, listeners, preview + authproxy;
50
57
  * env + env-cli + vault, resources, deploy, shot.
51
58
  */
52
- import { FLEET_TOKEN } from './lib/config.mjs';
59
+ import { FLEET_TOKEN, CREDENTIAL, adoptStoredCredential } from './lib/config.mjs';
53
60
  import { runFleetDaemon } from './lib/fleet.mjs';
54
61
  import { runLogin } from './lib/login.mjs';
55
62
 
@@ -172,6 +179,32 @@ if (process.argv[2] === 'stop') {
172
179
  process.exit(failed > 0 ? 1 : 0);
173
180
  }
174
181
 
182
+ // `flowviant projects` — every project this box has a credential for, which
183
+ // repo each is bound to, and which the legacy mirror points at. Needs no
184
+ // network: it reads the store, which is the exact thing a confused person is
185
+ // trying to see. The remedies are named because this listing IS the moment of
186
+ // confusion ("why did it say skadooble?"), not documentation.
187
+ if (process.argv[2] === 'projects') {
188
+ const { listStoredProjects, projectLabel } = await import('./lib/credentials.mjs');
189
+ const entries = listStoredProjects();
190
+ if (entries.length === 0) {
191
+ console.log('no projects connected on this machine yet — run `flowviant login` inside a repo.');
192
+ process.exit(0);
193
+ }
194
+ for (const e of entries) {
195
+ console.log(
196
+ ` ${projectLabel(e)} (${e.projectId.slice(0, 8)}…)` +
197
+ `${e.repoRoot ? `\n repo · ${e.repoRoot}` : '\n repo · not bound yet — first start or login in its repo binds it'}` +
198
+ `${e.active ? '\n what a pre-0.55.0 flowviant on this box would serve (the legacy mirror)' : ''}`
199
+ );
200
+ }
201
+ console.log(
202
+ '\n `npx flowviant` picks by the repo it is started in; `--project <name|id>` overrides;\n' +
203
+ ' `flowviant login` in a new repo connects another project.'
204
+ );
205
+ process.exit(0);
206
+ }
207
+
175
208
  // `flowviant env <import|set|show>` — the CLI half of team env sync. Values
176
209
  // are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
177
210
  // the browser); `show` decrypts locally — it only works on an ENROLLED machine.
@@ -191,14 +224,122 @@ if (process.argv[2] === 'env') {
191
224
  process.exit(0);
192
225
  }
193
226
 
227
+ // ── WHICH PROJECT THIS START SERVES — said, asked, or refused; never guessed.
228
+ //
229
+ // The store holds many projects since 0.55.0 and resolution is BY REPO
230
+ // (credentials.mjs). What is left here is the human half: an ambiguous store
231
+ // on a TTY becomes a PICKER, a single unbound credential gets ONE confirm that
232
+ // binds it, and a headless start with no unambiguous answer refuses in words
233
+ // that name every stored project and every way out. The one thing this block
234
+ // must never do is serve a project the resolution did not name — "it said
235
+ // skadooble in my calendar repo" is the confusion this exists to end.
236
+ // A RESTART IS NOT A PERSON. `reexec` (update.mjs) inherits stdio, so an
237
+ // auto-updated daemon's child sees two TTYs; without this it would stop on the
238
+ // binding confirm below and the machine would stay dark until somebody typed a
239
+ // key. Same reasoning as the headless case, and the same answer.
240
+ const interactive =
241
+ Boolean(process.stdin.isTTY && process.stdout.isTTY) && process.env.FLOWVIANT_REEXEC !== '1';
242
+ const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
243
+
244
+ /** Re-exec a plain `flowviant` after an inline login — the login command's own
245
+ * pattern: config.mjs read the store at IMPORT time, before the credential
246
+ * existed, so this process cannot serve; the child can. */
247
+ async function reexecAfterLogin() {
248
+ await runLogin({ thenStart: false });
249
+ const { spawn } = await import('node:child_process');
250
+ const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
251
+ process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
252
+ }
253
+
254
+ function listLines(entries, { projectLabel }) {
255
+ return entries
256
+ .map(
257
+ (e, i) =>
258
+ ` ${i + 1}. ${projectLabel(e)}` +
259
+ (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet')
260
+ )
261
+ .join('\n');
262
+ }
263
+
194
264
  if (!FLEET_TOKEN) {
195
- console.error(
196
- 'error: no credential found. Easiest:\n' +
197
- ' flowviant login (approve in the app — recommended)\n' +
198
- 'Or set:\n' +
199
- ' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
200
- );
201
- process.exit(1);
265
+ if (CREDENTIAL.error) {
266
+ console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
267
+ process.exit(1);
268
+ }
269
+ if (CREDENTIAL.choices?.length && interactive) {
270
+ const creds = await import('./lib/credentials.mjs');
271
+ const { choices, repoRoot } = CREDENTIAL;
272
+ console.log(
273
+ CREDENTIAL.reason === 'outside-repo'
274
+ ? 'flowviant is not inside a git repo, and more than one project is connected here.'
275
+ : CREDENTIAL.reason === 'multiple-bound'
276
+ ? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
277
+ : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
278
+ );
279
+ console.log(listLines(choices, creds));
280
+ console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
281
+ const rl = (await import('node:readline/promises')).createInterface({
282
+ input: process.stdin,
283
+ output: process.stdout,
284
+ });
285
+ const raw = (await rl.question(`Which project should this daemon serve? [1-${choices.length + 1}] `)).trim();
286
+ rl.close();
287
+ const n = Number.parseInt(raw, 10);
288
+ if (n === choices.length + 1) await reexecAfterLogin();
289
+ const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
290
+ if (!picked) {
291
+ console.error('nothing chosen — nothing started.');
292
+ process.exit(1);
293
+ }
294
+ // An answered question is consent: adopt it, and BIND it to this repo so
295
+ // the next start needs no prompt. Repointing is legitimate and said aloud.
296
+ if (repoRoot && picked.repoRoot && picked.repoRoot !== repoRoot) {
297
+ console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
298
+ }
299
+ adoptStoredCredential(picked);
300
+ creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
301
+ console.log(`serving ${creds.projectLabel(picked)}${repoRoot ? ` from ${repoRoot}` : ''}.`);
302
+ } else if (CREDENTIAL.choices?.length) {
303
+ const creds = await import('./lib/credentials.mjs');
304
+ console.error(
305
+ 'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
306
+ listLines(CREDENTIAL.choices, creds) +
307
+ '\nPick one with `--project <name|id>`, bind this repo by running `flowviant` here in a terminal once,\n' +
308
+ 'or connect this repo to its own project with `flowviant login`.'
309
+ );
310
+ process.exit(1);
311
+ } else {
312
+ console.error(
313
+ 'error: no credential found. Easiest:\n' +
314
+ ' flowviant login (approve in the app — recommended)\n' +
315
+ 'Or set:\n' +
316
+ ' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
317
+ );
318
+ process.exit(1);
319
+ }
320
+ } else if (!externalToken && CREDENTIAL.needsConfirm && interactive) {
321
+ // ONE stored project, never tied to a repo — the pre-0.55.0 world. Ask once;
322
+ // yes binds and every later start is silent. This is the exact question
323
+ // whose absence had a calendar checkout serving skadooble.
324
+ const creds = await import('./lib/credentials.mjs');
325
+ const label = creds.projectLabel(CREDENTIAL.entry);
326
+ const rl = (await import('node:readline/promises')).createInterface({
327
+ input: process.stdin,
328
+ output: process.stdout,
329
+ });
330
+ const raw = (
331
+ await rl.question(`This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `)
332
+ ).trim().toLowerCase();
333
+ rl.close();
334
+ if (raw === '' || raw === 'y' || raw === 'yes') {
335
+ creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
336
+ } else {
337
+ console.error(
338
+ `nothing started. Connect this repo to its own project with \`flowviant login\`, ` +
339
+ `or see what is stored with \`flowviant projects\`.`
340
+ );
341
+ process.exit(1);
342
+ }
202
343
  }
203
344
 
204
345
  await runFleetDaemon();
@@ -4,7 +4,7 @@ import { randomBytes } from 'node:crypto';
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { homedir, cpus, totalmem } from 'node:os';
7
+ import { cpus, totalmem } from 'node:os';
8
8
 
9
9
  // Read the daemon's version from its OWN package.json (always shipped in the npm
10
10
  // tarball) — never hardcode it. The hardcoded constant drifted: it sat at
@@ -30,14 +30,11 @@ export const MODEL = process.env.FLOWVIANT_MODEL || 'opus';
30
30
 
31
31
  // Credential stored by `flowviant login` (device auth) — the no-token,
32
32
  // no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
33
- function readStoredCredential() {
34
- try {
35
- return JSON.parse(readFileSync(join(homedir(), '.flowviant', 'credentials.json'), 'utf8'));
36
- } catch {
37
- return null;
38
- }
39
- }
40
- const stored = readStoredCredential();
33
+ // Since 0.55.0 the store holds MANY projects and resolution is BY REPO — see
34
+ // credentials.mjs for the whole rule. `CREDENTIAL` carries the resolution so
35
+ // cli.mjs can turn an ambiguity into a picker instead of a guess.
36
+ import { resolveStoredCredential } from './credentials.mjs';
37
+ export const CREDENTIAL = resolveStoredCredential();
41
38
 
42
39
  function argFlag(name) {
43
40
  const i = process.argv.indexOf(name);
@@ -167,5 +164,15 @@ export const DAEMON_INSTANCE = randomBytes(12).toString('hex');
167
164
  // --tokens) stood beside it and carried WORKER tokens into the pre-daemon loop;
168
165
  // that principal owns zero tools since dispatch was deleted, and the kind can no
169
166
  // longer be minted, so the plumbing went with the entrypoint (2026-08-19).
170
- export const FLEET_TOKEN =
171
- argFlag('--fleet') || process.env.FLOWVIANT_FLEET || stored?.fleetToken || '';
167
+ // `let`, because ES named imports are LIVE bindings: when cli.mjs answers an
168
+ // ambiguous store with a picker, adoptStoredCredential updates every importer
169
+ // before the daemon touches the network. Everything before that point (the
170
+ // resolution, the flags) is settled synchronously at import, as it always was.
171
+ export let FLEET_TOKEN =
172
+ argFlag('--fleet') || process.env.FLOWVIANT_FLEET || CREDENTIAL.entry?.fleetToken || '';
173
+
174
+ /** cli.mjs's picker chose. Must run BEFORE runFleetDaemon — nothing here
175
+ * re-authenticates a connection already made. */
176
+ export function adoptStoredCredential(entry) {
177
+ if (entry?.fleetToken) FLEET_TOKEN = entry.fleetToken;
178
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * THE CREDENTIAL STORE — one file, MANY projects (v2, 2026-08-23).
3
+ *
4
+ * ~/.flowviant/credentials.json used to hold exactly one credential, and
5
+ * `flowviant login` overwrote it — so `npx flowviant` in ANY directory served
6
+ * whatever project was logged into last. A user standing in their calendar
7
+ * repo watched the banner say another project's name, and the only thing that
8
+ * saved them from serving the wrong repo was the instance lock's same-repo
9
+ * refusal. One VM is allowed to run one daemon per project (the lock is keyed
10
+ * per credential and the header of instance.mjs says so out loud); the store
11
+ * was the only thing pretending otherwise.
12
+ *
13
+ * THE FILE SHAPE, and why it is two shapes at once:
14
+ *
15
+ * {
16
+ * fleetToken, projectId, mcpUrl, // the LEGACY MIRROR
17
+ * projects: { // v2: every connected project
18
+ * [projectId]: { fleetToken, mcpUrl, name, repoRoot, savedAt }
19
+ * }
20
+ * }
21
+ *
22
+ * The top-level trio is what every daemon before 0.55.0 reads, and the CLI is
23
+ * the one component a deploy cannot upgrade — so it stays, always mirroring
24
+ * the ACTIVE (most recently logged-in or explicitly picked) project. An old
25
+ * version keeps working with that project; a new one resolves by REPO.
26
+ *
27
+ * WHICH CREDENTIAL A START USES — resolution, in order:
28
+ * --fleet / FLOWVIANT_FLEET the operator said, verbatim (config.mjs).
29
+ * --project <name|id> an entry, named without a prompt.
30
+ * the entry BOUND to this repo repoRoot recorded at login/confirm, compared
31
+ * by realpath — the no-ambiguity path.
32
+ * one unbound entry served, with a one-time TTY confirm that
33
+ * binds it (headless keeps the old behaviour —
34
+ * a systemd restart must not hang on a prompt).
35
+ * anything else a CHOICE, never a guess: the CLI lists every
36
+ * stored project and asks (cli.mjs), or — with
37
+ * no TTY — refuses in words that name them all.
38
+ *
39
+ * The repoRoot binding is a SAFETY line, not bookkeeping: serving project X
40
+ * from a repo that is not X's checkout materializes X's session worktrees and
41
+ * decrypted env inside the wrong repository. Binding happens only at moments a
42
+ * HUMAN was present (login, an answered confirm, an explicit pick) — never
43
+ * silently on a headless start, where cementing the wrong repo would make the
44
+ * mistake permanent.
45
+ */
46
+
47
+ import { execFileSync } from 'node:child_process';
48
+ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
49
+ import { homedir } from 'node:os';
50
+ import { join } from 'node:path';
51
+
52
+ const CRED_DIR = join(homedir(), '.flowviant');
53
+ const CRED_FILE = join(CRED_DIR, 'credentials.json');
54
+
55
+ function readFile() {
56
+ try {
57
+ const v = JSON.parse(readFileSync(CRED_FILE, 'utf8'));
58
+ return v && typeof v === 'object' ? v : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** Atomic write (tmp + rename), 0600 — this file is credentials. */
65
+ function writeFile(v) {
66
+ mkdirSync(CRED_DIR, { recursive: true });
67
+ const tmp = `${CRED_FILE}.${process.pid}.tmp`;
68
+ writeFileSync(tmp, JSON.stringify(v, null, 2), { mode: 0o600 });
69
+ renameSync(tmp, CRED_FILE);
70
+ }
71
+
72
+ /** The repo root of `cwd`, realpath'd, or null when not inside a git repo. */
73
+ export function detectRepoRoot(cwd = process.cwd()) {
74
+ try {
75
+ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
76
+ cwd,
77
+ encoding: 'utf8',
78
+ stdio: ['ignore', 'pipe', 'ignore'],
79
+ timeout: 5000,
80
+ }).trim();
81
+ return root ? realpathSync(root) : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /** Same directory whatever it is spelled as — the instance.mjs rule. */
88
+ function samePath(a, b) {
89
+ if (!a || !b) return false;
90
+ const norm = (v) => {
91
+ try {
92
+ return realpathSync(v);
93
+ } catch {
94
+ return String(v).replace(/\/+$/, '');
95
+ }
96
+ };
97
+ return norm(a) === norm(b);
98
+ }
99
+
100
+ /**
101
+ * Every stored project, NORMALIZED: the v2 map, plus the legacy trio surfaced
102
+ * as an entry when no map row carries its token (a pre-0.55.0 file is one
103
+ * project with no name and no binding — real, and listed as such). `active`
104
+ * marks the entry the legacy mirror points at.
105
+ */
106
+ export function listStoredProjects() {
107
+ const f = readFile();
108
+ if (!f) return [];
109
+ const out = new Map();
110
+ for (const [projectId, e] of Object.entries(f.projects ?? {})) {
111
+ if (!e || typeof e.fleetToken !== 'string' || !e.fleetToken) continue;
112
+ out.set(projectId, {
113
+ projectId,
114
+ fleetToken: e.fleetToken,
115
+ mcpUrl: e.mcpUrl ?? null,
116
+ name: typeof e.name === 'string' && e.name ? e.name : null,
117
+ repoRoot: typeof e.repoRoot === 'string' && e.repoRoot ? e.repoRoot : null,
118
+ savedAt: e.savedAt ?? null,
119
+ });
120
+ }
121
+ if (typeof f.fleetToken === 'string' && f.fleetToken && typeof f.projectId === 'string' && f.projectId && !out.has(f.projectId)) {
122
+ out.set(f.projectId, {
123
+ projectId: f.projectId,
124
+ fleetToken: f.fleetToken,
125
+ mcpUrl: f.mcpUrl ?? null,
126
+ name: null,
127
+ repoRoot: null,
128
+ savedAt: null,
129
+ });
130
+ }
131
+ return [...out.values()].map((e) => ({ ...e, active: e.projectId === f.projectId }));
132
+ }
133
+
134
+ /** What a project is CALLED on a terminal: its name, or an id you can grep. */
135
+ export function projectLabel(e) {
136
+ return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
137
+ }
138
+
139
+ function mutate(fn) {
140
+ const f = readFile() ?? {};
141
+ if (!f.projects || typeof f.projects !== 'object') f.projects = {};
142
+ // Surface a legacy trio into the map before any edit, so nothing loses it.
143
+ if (typeof f.fleetToken === 'string' && f.fleetToken && typeof f.projectId === 'string' && f.projectId && !f.projects[f.projectId]) {
144
+ f.projects[f.projectId] = { fleetToken: f.fleetToken, mcpUrl: f.mcpUrl ?? null };
145
+ }
146
+ fn(f);
147
+ writeFile(f);
148
+ return f;
149
+ }
150
+
151
+ /** A fresh login: upsert the entry AND point the legacy mirror at it. */
152
+ export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot }) {
153
+ mutate((f) => {
154
+ f.projects[projectId] = {
155
+ ...(f.projects[projectId] ?? {}),
156
+ fleetToken,
157
+ mcpUrl: mcpUrl ?? null,
158
+ ...(name ? { name } : {}),
159
+ ...(repoRoot ? { repoRoot } : {}),
160
+ savedAt: new Date().toISOString(),
161
+ };
162
+ f.fleetToken = fleetToken;
163
+ f.projectId = projectId;
164
+ f.mcpUrl = mcpUrl ?? null;
165
+ });
166
+ }
167
+
168
+ /** An explicit human pick: repoint the legacy mirror, and — when the pick was
169
+ * made standing in a repo — bind the entry there. A pick IS the confirmation;
170
+ * binding on anything less would cement a guess. */
171
+ export function selectStoredProject(projectId, { bindRepoRoot } = {}) {
172
+ mutate((f) => {
173
+ const e = f.projects[projectId];
174
+ if (!e) return;
175
+ if (bindRepoRoot) e.repoRoot = bindRepoRoot;
176
+ f.fleetToken = e.fleetToken;
177
+ f.projectId = projectId;
178
+ f.mcpUrl = e.mcpUrl ?? null;
179
+ });
180
+ }
181
+
182
+ export function bindStoredRepo(projectId, repoRoot) {
183
+ if (!repoRoot) return;
184
+ mutate((f) => {
185
+ if (f.projects[projectId]) f.projects[projectId].repoRoot = repoRoot;
186
+ });
187
+ }
188
+
189
+ /** The roster names the project on every poll; remember it so the picker and
190
+ * `flowviant projects` can say a NAME instead of an id. */
191
+ export function setStoredProjectName(projectId, name) {
192
+ if (!projectId || typeof name !== 'string' || !name) return;
193
+ const entries = listStoredProjects();
194
+ const e = entries.find((x) => x.projectId === projectId);
195
+ if (!e || e.name === name) return;
196
+ mutate((f) => {
197
+ if (f.projects[projectId]) f.projects[projectId].name = name;
198
+ });
199
+ }
200
+
201
+ /** Match `--project <ref>` against the store: exact id, id prefix (≥6), or
202
+ * case-insensitive name. Ambiguity is an error, never a coin flip. */
203
+ export function matchStoredProject(ref) {
204
+ const entries = listStoredProjects();
205
+ const q = String(ref ?? '').trim();
206
+ if (!q) return { error: 'empty --project value' };
207
+ const byId = entries.filter(
208
+ (e) => e.projectId === q || (q.length >= 6 && e.projectId.startsWith(q))
209
+ );
210
+ const byName = entries.filter((e) => e.name && e.name.toLowerCase() === q.toLowerCase());
211
+ const hits = byId.length > 0 ? byId : byName;
212
+ if (hits.length === 1) return { entry: hits[0] };
213
+ if (hits.length > 1) return { error: `"${q}" matches ${hits.length} stored projects — use the full project id` };
214
+ return { error: `no stored project matches "${q}"` };
215
+ }
216
+
217
+ /**
218
+ * Resolve which stored credential this invocation should use. PURE over the
219
+ * store + argv + cwd; every refusal and prompt above it is built from what
220
+ * this returns. Shapes:
221
+ * { entry, source: 'project-flag' | 'repo' | 'only', needsConfirm? }
222
+ * { choices, reason: 'no-match' | 'multiple-bound' | 'outside-repo', repoRoot }
223
+ * { none: true }
224
+ * { error }
225
+ *
226
+ * Deliberately NO mirror fallback once more than one project is stored: the
227
+ * mirror is whichever login happened last, and serving it because it was
228
+ * recent is exactly the calendar-says-skadooble surprise this file exists to
229
+ * end. Two projects means the answer is a QUESTION (or --project, or the
230
+ * repo binding), never recency.
231
+ */
232
+ export function resolveStoredCredential(argv = process.argv, cwd = process.cwd()) {
233
+ const i = argv.indexOf('--project');
234
+ const projectArg = i >= 0 ? argv[i + 1] : undefined;
235
+ if (projectArg !== undefined) {
236
+ const m = matchStoredProject(projectArg);
237
+ return m.entry ? { entry: m.entry, source: 'project-flag' } : { error: m.error };
238
+ }
239
+ const entries = listStoredProjects();
240
+ if (entries.length === 0) return { none: true };
241
+ const repoRoot = detectRepoRoot(cwd);
242
+
243
+ if (repoRoot) {
244
+ const bound = entries.filter((e) => samePath(e.repoRoot, repoRoot));
245
+ if (bound.length === 1) return { entry: bound[0], source: 'repo', repoRoot };
246
+ if (bound.length > 1) return { choices: bound, reason: 'multiple-bound', repoRoot };
247
+ }
248
+
249
+ // ONE stored project with no binding: the pre-0.55.0 world. Serve it — a
250
+ // headless restart must not hang — but flag it, so a TTY start asks ONCE and
251
+ // binds, which is what turns "it said skadooble in my calendar repo" into a
252
+ // question instead of a surprise.
253
+ if (entries.length === 1 && !entries[0].repoRoot) {
254
+ return { entry: entries[0], source: 'only', needsConfirm: Boolean(repoRoot), repoRoot };
255
+ }
256
+
257
+ return { choices: entries, reason: repoRoot ? 'no-match' : 'outside-repo', repoRoot };
258
+ }
@@ -105,7 +105,11 @@ async function readSecretFromStdin(promptText) {
105
105
  }
106
106
 
107
107
  export async function runEnvCommand(args) {
108
- if (!FLEET_TOKEN) die('no fleet credential — run `flowviant login` first.');
108
+ if (!FLEET_TOKEN)
109
+ die(
110
+ 'no credential resolves here — run `flowviant login`, or run this inside the ' +
111
+ "project's own repo / pass `--project <name|id>` (`flowviant projects` lists them)."
112
+ );
109
113
  await sodium.ready;
110
114
  await ensureKeypair();
111
115
  const cmd = args[0];
package/bin/lib/env.mjs CHANGED
@@ -34,6 +34,8 @@ import {
34
34
  mkdirSync,
35
35
  existsSync,
36
36
  appendFileSync,
37
+ chmodSync,
38
+ lstatSync,
37
39
  rmSync,
38
40
  } from 'node:fs';
39
41
  import { execFileSync } from 'node:child_process';
@@ -123,11 +125,52 @@ export function myPubB64() {
123
125
  return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
124
126
  }
125
127
 
126
- /** Query params the roster poll carries: identity + materialized version. */
128
+ /** Query params the roster poll carries: identity, materialized version, and
129
+ * the target files we REFUSED to write.
130
+ *
131
+ * `envv` alone was a half-truth and the surface built on it said the wrong
132
+ * thing out loud: it is set the moment the bundle DECRYPTS, independent of
133
+ * whether a single byte reached a worktree, so a project whose `.env` is
134
+ * tracked in git got the green "on the current env" chip while every session
135
+ * ran on whatever stale placeholder git had checked out. The daemon knew —
136
+ * it warned, to a console nobody reads. `envskip` is that warning routed
137
+ * somewhere a human is actually looking.
138
+ *
139
+ * A daemon→server REPORT, so it needs no version floor: an older daemon
140
+ * simply sends no `envskip` key, which reads as "nothing to report" — and
141
+ * that is honest, because an older daemon genuinely is not measuring it.
142
+ * Bounded hard: a query string is not a log. */
127
143
  export async function envQueryParams() {
128
144
  await ensureKeypair();
129
145
  const params = { envpub: myPubB64() };
130
146
  if (bundleVersion >= 0) params.envv = String(bundleVersion);
147
+ // THE EMPTY STRING IS A REPORT, and it is the only thing that can ever CLEAR
148
+ // the surface's warning. Gating this on truthiness (which is what it did
149
+ // first) meant a person who followed the on-screen remedy exactly — gitignore
150
+ // the file, restart — sent no `envskip` at all, the server left the column
151
+ // alone by design, and the amber line stayed up forever telling them to fix
152
+ // something already fixed. Absence must keep meaning IGNORANCE, so the gate
153
+ // is "has a pass actually run", never "is there something to say".
154
+ // (fleet.mjs's query loop had to stop filtering on truthiness too — one
155
+ // check here is useless while a second one downstream drops the same value.)
156
+ if (everMaterialized) {
157
+ const files = [...new Set([...skippedByWorktree.values()].flat())].sort();
158
+ // Each path is percent-encoded BEFORE the join, because `isSafeEnvTargetFile`
159
+ // permits a comma in a filename and the server splits on one — unencoded,
160
+ // `a,b.env` would arrive as two files that do not exist. Truncation is by
161
+ // WHOLE ELEMENTS against a byte budget; a mid-path cut names a file nobody
162
+ // has, which is worse than naming fewer.
163
+ const parts = [];
164
+ let budget = 400;
165
+ for (const f of files) {
166
+ if (parts.length >= 10) break;
167
+ const enc = encodeURIComponent(f);
168
+ if (enc.length + 1 > budget) break;
169
+ parts.push(enc);
170
+ budget -= enc.length + 1;
171
+ }
172
+ params.envskip = parts.join(',');
173
+ }
131
174
  return params;
132
175
  }
133
176
 
@@ -310,6 +353,37 @@ function isIgnoredInGit(wt, relPath) {
310
353
 
311
354
  // Per-worktree: the target files we last materialized THIS SESSION.
312
355
  const lastFilesByWorktree = new Map();
356
+
357
+ /** Target files refused for a GIT reason, PER WORKTREE — reported to the
358
+ * server on the next poll. Per-worktree because the refusal is: both
359
+ * predicates (`isTrackedInGit`, `isIgnoredInGit`) run with `cwd: wt`, so
360
+ * ".env is refused" is a fact about ONE tree. A process-global set mixed two
361
+ * trees' answers together and, worse, could only ever grow.
362
+ *
363
+ * Names only — a path is not a secret, and the whole point is that a human
364
+ * can act on it ("gitignore apps/api/.dev.vars"). Only the two GIT causes go
365
+ * in here: they have a remedy the reader can carry out, and the surface names
366
+ * that remedy. A transient write failure is a warn, not a standing claim. */
367
+ const skippedByWorktree = new Map();
368
+
369
+ /** Worktrees whose most recent pass wrote everything it was asked to.
370
+ * `hasMaterialized` is built on THIS rather than on "a pass ran", so a pass
371
+ * that refused something RETRIES on the next turn — which is what lets
372
+ * `echo .env >> .gitignore` actually take effect without waiting for an
373
+ * unrelated bundle change. A pass with nothing to write counts as clean. */
374
+ const cleanWorktrees = new Set();
375
+
376
+ /** True once any materialization pass has completed. Distinguishes "we refused
377
+ * nothing" from "we have not looked", which is the whole contract of the
378
+ * `envskip` report — see envQueryParams. */
379
+ let everMaterialized = false;
380
+
381
+ /** Has this process completed a CLEAN materialization pass for this worktree?
382
+ * The creation-only rule (work.mjs) needs a second condition or a directory
383
+ * that existed before the bundle did is never revisited. */
384
+ export function hasMaterialized(wt) {
385
+ return cleanWorktrees.has(wt);
386
+ }
313
387
  // Project-global union of every target file we've ever materialized — PERSISTED
314
388
  // in the cache and seeded on load, so a file whose key was deleted while the
315
389
  // daemon was down still gets its stale plaintext copy cleaned on the next
@@ -366,6 +440,15 @@ export function appSecretsFor(env) {
366
440
  * app secrets go to the provider at deploy, deploy creds are injected only. */
367
441
  export function materializeInto(wt) {
368
442
  if (!wt || !existsSync(wt)) return;
443
+ // NEVER SYNCED IS NOT "NO SECRETS", and conflating them cost a whole session.
444
+ // `values` is empty both before the first bundle lands and for a project that
445
+ // genuinely has none; `bundleVersion < 0` is the one that means IGNORANCE.
446
+ // Writing nothing here and RECORDING it as materialized let a worktree
447
+ // created on the first poll after a restart — before handleRosterEnv had
448
+ // warmed the cache — sit secret-less for its entire life, because nothing
449
+ // re-materializes a directory that is neither fresh nor covered by a bundle
450
+ // CHANGE. Returning without recording is what makes the next turn retry.
451
+ if (bundleVersion < 0) return;
369
452
  const byFile = new Map();
370
453
  for (const v of values) {
371
454
  if (v.scope !== 'app' || v.env !== 'dev') continue; // only local dev secrets hit a worktree file
@@ -382,9 +465,16 @@ export function materializeInto(wt) {
382
465
  excludeInWorktree(wt, [...byFile.keys()]);
383
466
 
384
467
  const written = [];
468
+ /** Refused for a GIT reason this pass — reported, and remediable. */
469
+ const refusedForGit = [];
470
+ /** Anything that did not get written, git reasons and write failures alike.
471
+ * Blocks the clean mark so the next turn tries again. */
472
+ let anyProblem = false;
385
473
  for (const [file, list] of byFile) {
386
474
  if (isTrackedInGit(wt, file)) {
387
475
  warn(`env: "${file}" is tracked in git — refusing to write secrets there (gitignore it). Its keys are NOT materialized.`);
476
+ refusedForGit.push(file);
477
+ anyProblem = true;
388
478
  continue;
389
479
  }
390
480
  // The load-bearing check. A materialized secret sits in a worktree whose
@@ -392,10 +482,27 @@ export function materializeInto(wt) {
392
482
  // "git cannot see this file" is a precondition for writing it, not a nicety.
393
483
  if (!isIgnoredInGit(wt, file)) {
394
484
  warn(`env: "${file}" is not gitignored — refusing to write secrets there. Add it to .gitignore. Its keys are NOT materialized.`);
485
+ refusedForGit.push(file);
486
+ anyProblem = true;
395
487
  continue;
396
488
  }
397
489
  try {
398
490
  const abs = join(wt, file);
491
+ // A SYMLINK AT THE TARGET IS NOT A TARGET. `writeFileSync` follows one,
492
+ // so a link committed into the repo (or dropped by an agent) at the
493
+ // materialization path would write the project's decrypted secrets
494
+ // wherever it points — outside the worktree, and outside everything the
495
+ // check-ignore gate can reason about. `lstat`, not `stat`, and refuse.
496
+ // Cheap, and the whole exposure is one call away otherwise.
497
+ try {
498
+ if (lstatSync(abs).isSymbolicLink()) {
499
+ warn(`env: "${file}" is a symlink — refusing to write secrets through it.`);
500
+ anyProblem = true;
501
+ continue;
502
+ }
503
+ } catch {
504
+ /* does not exist yet — the ordinary case */
505
+ }
399
506
  mkdirSync(dirname(abs), { recursive: true });
400
507
  const body = renderEnvFile(list);
401
508
  // Skip an identical rewrite — otherwise every bundle bump touches the
@@ -407,9 +514,22 @@ export function materializeInto(wt) {
407
514
  /* new file */
408
515
  }
409
516
  if (prior !== body) writeFileSync(abs, body, { mode: 0o600 });
517
+ // `mode` on writeFileSync applies at CREATION only — an overwrite of a
518
+ // file that already existed keeps whatever mode it had, so a 0644 stub
519
+ // committed by a teammate (or left by an older daemon) would hold
520
+ // plaintext secrets world-readable on a shared box. chmod every time.
521
+ try {
522
+ chmodSync(abs, 0o600);
523
+ } catch {
524
+ /* best-effort: a filesystem without modes is not a reason to refuse */
525
+ }
410
526
  written.push(file);
411
527
  } catch (e) {
528
+ // NOT reported as a refusal: the surface's line names a git cause and a
529
+ // git remedy, and a full disk is neither. It still blocks the clean mark,
530
+ // so the next turn retries.
412
531
  warn(`env: could not write ${file} into worktree: ${e.message}`);
532
+ anyProblem = true;
413
533
  }
414
534
  }
415
535
 
@@ -423,6 +543,18 @@ export function materializeInto(wt) {
423
543
  }
424
544
  for (const f of written) knownTargetFiles.add(f);
425
545
  lastFilesByWorktree.set(wt, written);
546
+
547
+ // THE PASS'S VERDICT, recorded whole and REPLACING the previous one — this is
548
+ // what lets a refusal clear. `refusedForGit` is recomputed from scratch every
549
+ // pass, so a file that gets gitignored simply is not in the next one, and the
550
+ // union reported on the poll shrinks. `anyProblem` (which also covers a write
551
+ // failure) is what decides whether this worktree gets retried on the next
552
+ // turn; a clean pass is remembered so we stop touching a live directory.
553
+ if (refusedForGit.length > 0) skippedByWorktree.set(wt, refusedForGit);
554
+ else skippedByWorktree.delete(wt);
555
+ if (anyProblem) cleanWorktrees.delete(wt);
556
+ else cleanWorktrees.add(wt);
557
+ everMaterialized = true;
426
558
  }
427
559
 
428
560
  /**
package/bin/lib/fleet.mjs CHANGED
@@ -31,7 +31,9 @@ import {
31
31
  REFRESH_BEFORE_SECONDS,
32
32
  LIVE,
33
33
  AUTO_UPDATE,
34
+ CREDENTIAL,
34
35
  } from './config.mjs';
36
+ import { projectLabel, setStoredProjectName } from './credentials.mjs';
35
37
  import { handleVersionSignal } from './update.mjs';
36
38
  import {
37
39
  git,
@@ -67,6 +69,7 @@ import { ensureVault, syncVault } from './vault.mjs';
67
69
  import {
68
70
  envQueryParams,
69
71
  handleRosterEnv,
72
+ loadCachedEnv,
70
73
  materializeInto,
71
74
  myPubB64,
72
75
  scrub as envScrub,
@@ -148,9 +151,17 @@ async function fetchRoster(haveIds, livePreviewSessionIds = [], heldSessionIds =
148
151
  /* best-effort — the poll must never fail on a readout */
149
152
  }
150
153
  // Env-sync identity + materialized version (the Settings "env vN" chip).
154
+ //
155
+ // `!= null`, NOT truthiness. `envskip` uses the EMPTY STRING as a real
156
+ // report — "measured, refused nothing" — and it is the only value that can
157
+ // clear the surface's warning. A `if (v)` here silently dropped it, so
158
+ // fixing the filter in envQueryParams alone would have changed nothing.
159
+ // This is the same trap the skills relay eight lines up documents and
160
+ // sidesteps by calling `url.searchParams.set` directly; the general fix is
161
+ // better than a second special case.
151
162
  try {
152
163
  for (const [k, v] of Object.entries(await envQueryParams())) {
153
- if (v) url.searchParams.set(k, v);
164
+ if (v != null) url.searchParams.set(k, v);
154
165
  }
155
166
  } catch {
156
167
  /* env identity is best-effort — the poll must never fail on it */
@@ -322,6 +333,14 @@ export async function runFleetDaemon() {
322
333
  const repoRoot = repoRootOrDie();
323
334
  const baseRef = detectBaseRef(repoRoot);
324
335
  info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
336
+ // WHICH PROJECT, before anything connects — the roster names it again a few
337
+ // seconds later with the server's word, but "which project is this daemon
338
+ // about to serve" must not require a network round trip to answer. Only when
339
+ // the credential came from the STORE: a --fleet/env token names no project
340
+ // until the roster does.
341
+ if (CREDENTIAL?.entry) {
342
+ info(`serves · ${projectLabel(CREDENTIAL.entry)} ${c.dim(`(${CREDENTIAL.entry.projectId.slice(0, 8)}…)`)}`);
343
+ }
325
344
  info(`repo · ${repoRoot}`);
326
345
  info(`base · ${baseRef}`);
327
346
  info(`server · ${FLEET_URL}`);
@@ -385,6 +404,39 @@ export async function runFleetDaemon() {
385
404
  warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
386
405
 
387
406
  await preflight({ needGit: true });
407
+
408
+ // WARM THE ENV CACHE BEFORE THE FIRST POLL, not on the first roster tick.
409
+ // `handleRosterEnv` loads it, and `handleRosterEnv` runs AFTER
410
+ // `processWorkTurns` in the reconcile below — so on the first poll after a
411
+ // restart a brand-new session worktree was materialized against an EMPTY
412
+ // bundle, and then never revisited (creation-only, and `needSync` is false
413
+ // when the cache holds the version the server is already on). The turn ran
414
+ // with no secrets and nothing said so.
415
+ //
416
+ // This is only possible since 0.55.0: the credential store knows which
417
+ // project this checkout is, so the cache — which is keyed by projectId — can
418
+ // be found before the server has named anything. A `--fleet`/env token names
419
+ // no project until the roster does, so it keeps the old lazy path.
420
+ // Best-effort throughout: a cache miss is the ordinary first-run state.
421
+ //
422
+ // GATED ON THE STORE ACTUALLY BEING THE SOURCE. `--fleet` / `FLOWVIANT_FLEET`
423
+ // OVERRIDE the stored credential (config.mjs), but `CREDENTIAL` is resolved
424
+ // from the store regardless — so reading its projectId here would decrypt and
425
+ // materialize project A's cached secrets while this daemon is serving project
426
+ // B's token. That is the wrong project's plaintext in a worktree, which is
427
+ // the exact failure the repo binding exists to prevent, arriving by a
428
+ // different door. An external token names no project until the roster does,
429
+ // so it keeps the lazy path and loses nothing but one poll.
430
+ const externalToken =
431
+ process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
432
+ if (!externalToken && CREDENTIAL?.entry?.projectId) {
433
+ try {
434
+ await loadCachedEnv(CREDENTIAL.entry.projectId);
435
+ } catch {
436
+ /* no cache, no keypair yet, unreadable home — the roster tick retries */
437
+ }
438
+ }
439
+
388
440
  // Kill any preview dev-server/tunnel groups a previously-crashed daemon left
389
441
  // running (detached children survive an ungraceful exit) before we start fresh.
390
442
  reapOrphanPreviews((m) => info(m));
@@ -1305,6 +1357,11 @@ export async function runFleetDaemon() {
1305
1357
  `${c.cyan('project')} · ${c.bold(roster.project.name)} ${c.dim(`(${roster.project.id})`)}`
1306
1358
  );
1307
1359
  note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
1360
+ // Remember the NAME beside the stored credential, so the picker and
1361
+ // `flowviant projects` can say "skadooble" instead of an id — a
1362
+ // credential saved before the server sent names backfills here. No-op
1363
+ // for a --fleet/env token (nothing stored to annotate).
1364
+ setStoredProjectName(roster.project.id, roster.project.name);
1308
1365
  }
1309
1366
  }
1310
1367
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
package/bin/lib/login.mjs CHANGED
@@ -6,33 +6,15 @@
6
6
  * plain `flowviant` just runs — no token, no env var.
7
7
  */
8
8
 
9
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
- import { join } from 'node:path';
11
- import { homedir } from 'node:os';
12
9
  import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
10
+ import { saveLogin, detectRepoRoot, projectLabel } from './credentials.mjs';
13
11
  import { c, info, ok, warn, fail } from './ui.mjs';
14
12
  import { sleep } from './claude.mjs';
15
13
 
16
- const CRED_DIR = join(homedir(), '.flowviant');
17
- const CRED_FILE = join(CRED_DIR, 'credentials.json');
18
14
  const DEVICE_START = FLEET_URL.replace(/\/agents\/?$/, '/device/start');
19
15
  const DEVICE_POLL = FLEET_URL.replace(/\/agents\/?$/, '/device/poll');
20
16
  const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
21
17
 
22
- /** The locally-stored credential from a prior `login`, or null. Read by config. */
23
- export function readStoredCredential() {
24
- try {
25
- return JSON.parse(readFileSync(CRED_FILE, 'utf8'));
26
- } catch {
27
- return null;
28
- }
29
- }
30
-
31
- function store(cred) {
32
- mkdirSync(CRED_DIR, { recursive: true });
33
- writeFileSync(CRED_FILE, JSON.stringify(cred, null, 2), { mode: 0o600 });
34
- }
35
-
36
18
  async function post(url, body) {
37
19
  const res = await fetch(url, {
38
20
  method: 'POST',
@@ -76,10 +58,26 @@ export async function runLogin({ thenStart = false } = {}) {
76
58
  if (poll.status === 'approved') {
77
59
  // `machineToken` is the wire's new name; `fleetToken` is the one every
78
60
  // published daemon reads. The server dual-sends until DAEMON_MIN clears
79
- // THIS release (0.54.2) — reading both here is what makes retiring the
80
- // old key possible at all.
81
- store({ fleetToken: poll.machineToken ?? poll.fleetToken, projectId: poll.projectId, mcpUrl: poll.mcpUrl });
82
- ok('connected credential saved to ~/.flowviant/credentials.json');
61
+ // the release that reads the new one (0.54.2+) — reading both here is
62
+ // what makes retiring the old key possible at all.
63
+ //
64
+ // BOUND to the repo the login was run in: a login is the one moment we
65
+ // know for certain which checkout this project means, and the binding is
66
+ // what lets a multi-project VM resolve `npx flowviant` by DIRECTORY
67
+ // instead of by whichever login happened last.
68
+ const repoRoot = detectRepoRoot();
69
+ const entry = {
70
+ fleetToken: poll.machineToken ?? poll.fleetToken,
71
+ projectId: poll.projectId,
72
+ mcpUrl: poll.mcpUrl,
73
+ name: typeof poll.projectName === 'string' && poll.projectName ? poll.projectName : null,
74
+ repoRoot,
75
+ };
76
+ saveLogin(entry);
77
+ ok(
78
+ `connected to ${c.bold(projectLabel(entry))}` +
79
+ `${repoRoot ? ` for ${c.dim(repoRoot)}` : ''} — saved to ~/.flowviant/credentials.json`
80
+ );
83
81
  // The daemon starts right here unless the caller opted out; telling
84
82
  // someone to run a second command was the step that got missed, since by
85
83
  // this point they are looking at the browser, not this terminal.
@@ -23,8 +23,9 @@ const CLI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/cli-token');
23
23
  export async function runMcpCommand(args = []) {
24
24
  if (!FLEET_TOKEN) {
25
25
  console.error(
26
- 'error: no credential. Run `flowviant login` first — this needs the\n' +
27
- 'fleet credential the daemon uses, so it knows which project to connect.'
26
+ 'error: no credential resolves here. Run `flowviant login` first — or, with\n' +
27
+ 'several projects connected on this box, run this inside the project\'s own\n' +
28
+ 'repo or pass `--project <name|id>` (`flowviant projects` lists them).'
28
29
  );
29
30
  process.exit(1);
30
31
  }
@@ -79,7 +79,16 @@ export function humanizeClaudeTool(name, input = {}, cwd = '') {
79
79
  case 'LS':
80
80
  return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
81
81
  case 'Bash':
82
- return { kind: 'bash', label: `$ ${oneLine(input.command, 60)}` };
82
+ // `command` rides beside the display label, VERBATIM (capped): the label
83
+ // is a 60-char readout for a console and the rail, and truncation is
84
+ // fine there — but the admin's command audit exists to answer "what
85
+ // actually ran on our box", and an ellipsis is exactly where the part
86
+ // that matters would hide.
87
+ return {
88
+ kind: 'bash',
89
+ command: String(input.command ?? '').slice(0, 2000),
90
+ label: `$ ${oneLine(input.command, 60)}`,
91
+ };
83
92
  default:
84
93
  return null; // other tools: silent
85
94
  }
@@ -103,7 +112,11 @@ function humanizeCodexItem(item = {}, cwd = '') {
103
112
  case 'reasoning':
104
113
  return { kind: 'think', label: oneLine(item.text) || 'thinking…' };
105
114
  case 'command_execution':
106
- return { kind: 'bash', label: `$ ${oneLine(item.command, 60)}` };
115
+ return {
116
+ kind: 'bash',
117
+ command: String(item.command ?? '').slice(0, 2000),
118
+ label: `$ ${oneLine(item.command, 60)}`,
119
+ };
107
120
  case 'file_change': {
108
121
  // `changes` is a list of touched paths; the daemon counts distinct files,
109
122
  // so emit one activity per path rather than one for the batch.
@@ -207,7 +220,11 @@ function humanizeAgyTool(name, p = {}, cwd = '') {
207
220
  case 'list_dir':
208
221
  return { kind: 'list', label: `ls ${shortPath(path, cwd)}` };
209
222
  case 'run_command':
210
- return { kind: 'bash', label: `$ ${oneLine(p.CommandLine, 60)}` };
223
+ return {
224
+ kind: 'bash',
225
+ command: String(p.CommandLine ?? '').slice(0, 2000),
226
+ label: `$ ${oneLine(p.CommandLine, 60)}`,
227
+ };
211
228
  case 'call_mcp_tool':
212
229
  return { kind: 'tool', label: `mcp.${p.ToolName ?? ''}` };
213
230
  default:
@@ -59,7 +59,17 @@ function reexec(teardown) {
59
59
  }
60
60
  const child = spawn(process.execPath, process.argv.slice(1), {
61
61
  stdio: 'inherit',
62
- env: process.env,
62
+ // MARK THE CHILD AS A RESTART, not as a person typing `flowviant`.
63
+ // stdio is inherited, so the child sees two TTYs and believes a human is
64
+ // watching — and 0.55.0 asks a one-time binding question on exactly that
65
+ // signal. An auto-update that lands while nobody is looking would then sit
66
+ // on `Serve this repo as X? [Y/n]` with the machine dark until someone
67
+ // walks past. The rule credentials.mjs already states for systemd applies
68
+ // verbatim here: a RESTART must not hang on a prompt. Skipping the confirm
69
+ // is not a widening — the daemon serves exactly the credential it was
70
+ // already serving one second ago, and the question gets asked the next
71
+ // time a human starts it by hand.
72
+ env: { ...process.env, FLOWVIANT_REEXEC: '1' },
63
73
  });
64
74
  child.on('exit', (code) => process.exit(code ?? 0));
65
75
  }
package/bin/lib/work.mjs CHANGED
@@ -48,7 +48,7 @@ import {
48
48
  SYSTEM_WORK_PLAIN,
49
49
  WORK_TURN_KICKOFF_PLAIN,
50
50
  } from './prompts.mjs';
51
- import { materializeInto, excludeInWorktree, scrub as envScrub } from './env.mjs';
51
+ import { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
52
52
  import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
53
53
  import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
54
54
  import { worktreeDiff } from './worktreeDiff.mjs';
@@ -102,6 +102,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
102
102
  const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
103
103
  const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
104
104
  const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
105
+ const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
105
106
  const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
106
107
  const workAnswering = new Set(); // turn ids currently queued/running here
107
108
  const workAttempts = new Map(); // turn id -> completed runTurn attempts
@@ -899,6 +900,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
899
900
  } catch {
900
901
  /* best-effort */
901
902
  }
903
+ } else if (!hasMaterialized(wt)) {
904
+ // CREATION-ONLY NEEDED A SECOND CONDITION. The rule above is right about
905
+ // a LIVE directory — its env belongs to the session and re-writing it
906
+ // mid-flight is not ours to do — but "created" and "ever given a bundle"
907
+ // are different events, and the gap between them is a whole daemon
908
+ // restart: `handleRosterEnv` (which warms the encrypted cache) runs
909
+ // AFTER `processWorkTurns` on the same poll, so a worktree made on the
910
+ // first turn after a restart was materialized against an EMPTY bundle
911
+ // and, being neither fresh nor covered by a bundle CHANGE, never
912
+ // revisited. `materializeInto` now declines to record a pass it made in
913
+ // ignorance (bundleVersion < 0), so this branch is what retries it —
914
+ // once, on the next turn, and never again after it succeeds. Idempotent
915
+ // by construction: identical bodies are not rewritten, so nothing
916
+ // hot-restarts a dev server the driver is watching.
917
+ try {
918
+ materializeInto(wt);
919
+ } catch {
920
+ /* best-effort */
921
+ }
902
922
  }
903
923
  return { wt, fresh };
904
924
  };
@@ -1584,6 +1604,45 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1584
1604
  let seenThreadId = null; // codex's conversation id, off thread.started
1585
1605
  const spawned = []; // this turn's children, for the teardown registry
1586
1606
  const narrator = makeNarrator(job.sessionId, job.id);
1607
+
1608
+ // THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
1609
+ // to the server verbatim so an admin can read what actually ran on
1610
+ // this box. Same events the narrator renders and forgets; this is
1611
+ // the durable copy, and it carries ONLY commands — no prose, no
1612
+ // thinking, no file reads (the session stays private; what executed
1613
+ // on the shared machine is the machine's own fact to relay).
1614
+ // Flushed mid-turn every 25 so a long turn is not one giant loss on
1615
+ // a kill, and again at settle. Best-effort: a failed post drops the
1616
+ // batch rather than blocking the turn — the surface says it is the
1617
+ // machine's report, not a syscall trace.
1618
+ const auditBatch = [];
1619
+ const flushAudit = () => {
1620
+ if (auditBatch.length === 0) return;
1621
+ const commands = auditBatch.splice(0, auditBatch.length);
1622
+ void fetch(SESSION_COMMANDS_URL, {
1623
+ method: 'POST',
1624
+ headers: {
1625
+ Authorization: `Bearer ${FLEET_TOKEN}`,
1626
+ 'User-Agent': USER_AGENT,
1627
+ 'Content-Type': 'application/json',
1628
+ },
1629
+ signal: AbortSignal.timeout(30_000),
1630
+ body: JSON.stringify({
1631
+ sessionId: job.sessionId,
1632
+ turnId: job.id,
1633
+ runtime: rt.id,
1634
+ cwd: dir.wt,
1635
+ commands,
1636
+ }),
1637
+ }).catch(() => {
1638
+ /* best-effort — the audit records what reached it */
1639
+ });
1640
+ };
1641
+ const auditCommand = (a) => {
1642
+ if (a?.kind !== 'bash' || !a.command) return;
1643
+ auditBatch.push({ command: a.command, at: new Date().toISOString() });
1644
+ if (auditBatch.length >= 25) flushAudit();
1645
+ };
1587
1646
  try {
1588
1647
  // Files first, then the message that references them: the agent
1589
1648
  // must be able to open what it is being told about. Only the ones
@@ -1627,7 +1686,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1627
1686
  // posted once. Every line goes to the narrator above, throttled.
1628
1687
  streamJson: true,
1629
1688
  answerFromResult: true,
1630
- onActivity: (a) => narrator.line(a?.label),
1689
+ onActivity: (a) => {
1690
+ narrator.line(a?.label);
1691
+ auditCommand(a);
1692
+ },
1631
1693
  // What this CLI says it can be asked for by name. Harvested off
1632
1694
  // the init event the stream already carries — no probe, no scan,
1633
1695
  // no extra spawn — and reported on the next roster poll so the
@@ -1683,6 +1745,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1683
1745
  // is cleared server-side at settle — clearing it here would race
1684
1746
  // the settle and blank the tab a beat before the reply lands.
1685
1747
  narrator.stop();
1748
+ flushAudit();
1686
1749
  for (const ch of spawned) workChildren.delete(ch);
1687
1750
  if (lockPath) {
1688
1751
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.54.2",
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.",
3
+ "version": "0.55.1",
4
+ "description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {
7
7
  "flowviant": "bin/cli.mjs"