flowviant 0.54.1 → 0.55.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/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,117 @@ 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
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
237
+ const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
238
+
239
+ /** Re-exec a plain `flowviant` after an inline login — the login command's own
240
+ * pattern: config.mjs read the store at IMPORT time, before the credential
241
+ * existed, so this process cannot serve; the child can. */
242
+ async function reexecAfterLogin() {
243
+ await runLogin({ thenStart: false });
244
+ const { spawn } = await import('node:child_process');
245
+ const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
246
+ process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
247
+ }
248
+
249
+ function listLines(entries, { projectLabel }) {
250
+ return entries
251
+ .map(
252
+ (e, i) =>
253
+ ` ${i + 1}. ${projectLabel(e)}` +
254
+ (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet')
255
+ )
256
+ .join('\n');
257
+ }
258
+
194
259
  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);
260
+ if (CREDENTIAL.error) {
261
+ console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
262
+ process.exit(1);
263
+ }
264
+ if (CREDENTIAL.choices?.length && interactive) {
265
+ const creds = await import('./lib/credentials.mjs');
266
+ const { choices, repoRoot } = CREDENTIAL;
267
+ console.log(
268
+ CREDENTIAL.reason === 'outside-repo'
269
+ ? 'flowviant is not inside a git repo, and more than one project is connected here.'
270
+ : CREDENTIAL.reason === 'multiple-bound'
271
+ ? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
272
+ : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
273
+ );
274
+ console.log(listLines(choices, creds));
275
+ console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
276
+ const rl = (await import('node:readline/promises')).createInterface({
277
+ input: process.stdin,
278
+ output: process.stdout,
279
+ });
280
+ const raw = (await rl.question(`Which project should this daemon serve? [1-${choices.length + 1}] `)).trim();
281
+ rl.close();
282
+ const n = Number.parseInt(raw, 10);
283
+ if (n === choices.length + 1) await reexecAfterLogin();
284
+ const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
285
+ if (!picked) {
286
+ console.error('nothing chosen — nothing started.');
287
+ process.exit(1);
288
+ }
289
+ // An answered question is consent: adopt it, and BIND it to this repo so
290
+ // the next start needs no prompt. Repointing is legitimate and said aloud.
291
+ if (repoRoot && picked.repoRoot && picked.repoRoot !== repoRoot) {
292
+ console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
293
+ }
294
+ adoptStoredCredential(picked);
295
+ creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
296
+ console.log(`serving ${creds.projectLabel(picked)}${repoRoot ? ` from ${repoRoot}` : ''}.`);
297
+ } else if (CREDENTIAL.choices?.length) {
298
+ const creds = await import('./lib/credentials.mjs');
299
+ console.error(
300
+ 'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
301
+ listLines(CREDENTIAL.choices, creds) +
302
+ '\nPick one with `--project <name|id>`, bind this repo by running `flowviant` here in a terminal once,\n' +
303
+ 'or connect this repo to its own project with `flowviant login`.'
304
+ );
305
+ process.exit(1);
306
+ } else {
307
+ console.error(
308
+ 'error: no credential found. Easiest:\n' +
309
+ ' flowviant login (approve in the app — recommended)\n' +
310
+ 'Or set:\n' +
311
+ ' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
312
+ );
313
+ process.exit(1);
314
+ }
315
+ } else if (!externalToken && CREDENTIAL.needsConfirm && interactive) {
316
+ // ONE stored project, never tied to a repo — the pre-0.55.0 world. Ask once;
317
+ // yes binds and every later start is silent. This is the exact question
318
+ // whose absence had a calendar checkout serving skadooble.
319
+ const creds = await import('./lib/credentials.mjs');
320
+ const label = creds.projectLabel(CREDENTIAL.entry);
321
+ const rl = (await import('node:readline/promises')).createInterface({
322
+ input: process.stdin,
323
+ output: process.stdout,
324
+ });
325
+ const raw = (
326
+ await rl.question(`This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `)
327
+ ).trim().toLowerCase();
328
+ rl.close();
329
+ if (raw === '' || raw === 'y' || raw === 'yes') {
330
+ creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
331
+ } else {
332
+ console.error(
333
+ `nothing started. Connect this repo to its own project with \`flowviant login\`, ` +
334
+ `or see what is stored with \`flowviant projects\`.`
335
+ );
336
+ process.exit(1);
337
+ }
202
338
  }
203
339
 
204
340
  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
+ }
@@ -13,7 +13,7 @@
13
13
  import { readFileSync, existsSync } from 'node:fs';
14
14
  import { spawn } from 'node:child_process';
15
15
  import { join } from 'node:path';
16
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
16
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
17
17
  import { c, note, ok, warn } from './ui.mjs';
18
18
  import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
19
19
 
@@ -167,7 +167,15 @@ export function processDeployJobs(jobs, ctx) {
167
167
  void (async () => {
168
168
  let beat = null;
169
169
  try {
170
- const claimed = await post('deploy-claim', { jobId: job.id, pubkey: ctx.myPubB64() }).catch(() => null);
170
+ // `instance` names THIS PROCESS. The pubkey cannot: it is the env
171
+ // keypair read from one file per home directory, so two daemons on one
172
+ // box share it and a pubkey-only read-back let both "win" the claim
173
+ // and run the same deploy twice concurrently.
174
+ const claimed = await post('deploy-claim', {
175
+ jobId: job.id,
176
+ pubkey: ctx.myPubB64(),
177
+ instance: DAEMON_INSTANCE,
178
+ }).catch(() => null);
171
179
  if (!claimed?.claimed) return; // another daemon won the claim
172
180
  // Keep the claim fresh while we run — a long deploy must never be
173
181
  // re-queued out from under us (that would double-deploy). The async
@@ -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/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,
@@ -322,6 +324,14 @@ export async function runFleetDaemon() {
322
324
  const repoRoot = repoRootOrDie();
323
325
  const baseRef = detectBaseRef(repoRoot);
324
326
  info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
327
+ // WHICH PROJECT, before anything connects — the roster names it again a few
328
+ // seconds later with the server's word, but "which project is this daemon
329
+ // about to serve" must not require a network round trip to answer. Only when
330
+ // the credential came from the STORE: a --fleet/env token names no project
331
+ // until the roster does.
332
+ if (CREDENTIAL?.entry) {
333
+ info(`serves · ${projectLabel(CREDENTIAL.entry)} ${c.dim(`(${CREDENTIAL.entry.projectId.slice(0, 8)}…)`)}`);
334
+ }
325
335
  info(`repo · ${repoRoot}`);
326
336
  info(`base · ${baseRef}`);
327
337
  info(`server · ${FLEET_URL}`);
@@ -782,7 +792,7 @@ export async function runFleetDaemon() {
782
792
  // Direct enqueue = immediacy; the server's durable regroundJobs list
783
793
  // (created by merge-done above, cleared by our reground-done report)
784
794
  // is the restart-safe backstop — dedup'd here by groundedIntents.
785
- enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages);
795
+ enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages, job.shas);
786
796
  } else if (failedReason) {
787
797
  // Report into the thread (server narrates + re-arms the merge
788
798
  // button + notifies) — the job disappears from the roster.
@@ -926,7 +936,7 @@ export async function runFleetDaemon() {
926
936
  wikiQueue.push({ type: 'sweep' });
927
937
  void drainWiki();
928
938
  };
929
- const enqueueReground = (intentId, prUrl, title, dirtiesPages) => {
939
+ const enqueueReground = (intentId, prUrl, title, dirtiesPages, shas) => {
930
940
  if (!intentId || groundedIntents.has(intentId)) return;
931
941
  groundedIntents.add(intentId);
932
942
  wikiQueue.push({
@@ -939,6 +949,13 @@ export async function runFleetDaemon() {
939
949
  // frontmatter file list has drifted, or that document a concept rather
940
950
  // than a directory.
941
951
  dirtiesPages: Array.isArray(dirtiesPages) ? dirtiesPages : [],
952
+ // THE COMMITS THAT SHIPPED — what changedFilesForShas resolves against.
953
+ // Dropping this here was the whole 0.54.0/0.54.1 defect: the server sent
954
+ // shas on every reground job, this function never stored them, and the
955
+ // drain's `task.shas` was undefined on every job — so the re-ground
956
+ // "revived" on 2026-08-22 retried three times against nothing and gave
957
+ // up, on a console nobody reads, on every single ship.
958
+ shas: Array.isArray(shas) ? shas : [],
942
959
  });
943
960
  void drainWiki();
944
961
  };
@@ -1185,6 +1202,13 @@ export async function runFleetDaemon() {
1185
1202
  // crash BEFORE this line leaves the job listed for a retry.
1186
1203
  regroundAttempts.delete(task.intentId);
1187
1204
  await reportMergeOutcome(REGROUND_DONE_URL, { taskId: task.intentId });
1205
+ // The dedup was DAEMON-LIFETIME, which wedged a reopened card: its
1206
+ // second ship writes a fresh durable job, this Set still holds the
1207
+ // taskId, enqueueReground refuses it on every poll forever, and
1208
+ // the never-consumed job churns the wiki-writer lease until a
1209
+ // restart. The job is consumed now, so the guard has done its work;
1210
+ // a FUTURE ship of the same card is new work, not a duplicate.
1211
+ groundedIntents.delete(task.intentId);
1188
1212
  }
1189
1213
  } catch (e) {
1190
1214
  warn(`wiki ${task.type} failed: ${e.message}`);
@@ -1269,7 +1293,13 @@ export async function runFleetDaemon() {
1269
1293
  if (e.auth) {
1270
1294
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
1271
1295
  teardown();
1272
- process.exit(1);
1296
+ // EXIT 0, for the same reason the commanded-stop path does: a revoked
1297
+ // credential is a terminal, asked-for-by-someone state, and a relaunch
1298
+ // can never fix it. Under `Restart=on-failure` a nonzero code has
1299
+ // systemd relaunch the daemon immediately — a restart loop hammering
1300
+ // dead-credential polls, fighting the Disconnect that revoked it, and
1301
+ // ending in a unit that reads as a crash rather than a kill.
1302
+ process.exit(0);
1273
1303
  }
1274
1304
  warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
1275
1305
  await sleep(RECONCILE_SECONDS);
@@ -1285,6 +1315,11 @@ export async function runFleetDaemon() {
1285
1315
  `${c.cyan('project')} · ${c.bold(roster.project.name)} ${c.dim(`(${roster.project.id})`)}`
1286
1316
  );
1287
1317
  note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
1318
+ // Remember the NAME beside the stored credential, so the picker and
1319
+ // `flowviant projects` can say "skadooble" instead of an id — a
1320
+ // credential saved before the server sent names backfills here. No-op
1321
+ // for a --fleet/env token (nothing stored to annotate).
1322
+ setStoredProjectName(roster.project.id, roster.project.name);
1288
1323
  }
1289
1324
  }
1290
1325
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
@@ -1304,6 +1339,17 @@ export async function runFleetDaemon() {
1304
1339
  : 'stopped by Flowviant — no reason given.'
1305
1340
  );
1306
1341
  note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1342
+ // FLUSH the settle queue first, bounded: a queued-but-undelivered report
1343
+ // is a COMPLETED turn whose side effects already happened, and dropping
1344
+ // it re-runs the whole turn on the next start — quota spent twice and
1345
+ // every card write doubled. This path is async (unlike the signal
1346
+ // handlers, which cannot await), so the stop can afford five seconds of
1347
+ // delivery before it obeys.
1348
+ try {
1349
+ await Promise.race([flushWorkReports(), sleep(5)]);
1350
+ } catch {
1351
+ /* undelivered reports re-run; delivering them was best-effort */
1352
+ }
1307
1353
  // teardown() is NOT optional on this path. Detached preview tunnels
1308
1354
  // survive this process BY DESIGN, so exiting without it strands a public
1309
1355
  // hostname pointed into a worktree until somebody reboots the box — which
@@ -1421,7 +1467,7 @@ export async function runFleetDaemon() {
1421
1467
  for (const j of roster.regroundJobs ?? []) {
1422
1468
  const rid = j && (j.taskId ?? j.intentId); // new name first, old as fallback
1423
1469
  if (!j || typeof rid !== 'string') continue; // a null element would throw + wedge the loop
1424
- enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages);
1470
+ enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages, j.shas);
1425
1471
  }
1426
1472
  void drainWiki();
1427
1473
 
@@ -413,15 +413,16 @@ function stillTheHolder(holder) {
413
413
  // `process.argv[1] || ''` — and matching on '' would match every process
414
414
  // alive, so it takes the same road as a missing one.
415
415
  if (!want) return startedAroundLockWrite(holder);
416
+ let cmdline;
416
417
  try {
417
- if (platform() === 'linux') {
418
- return readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ').includes(want);
419
- }
420
- return execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
421
- encoding: 'utf8',
422
- stdio: ['ignore', 'pipe', 'ignore'],
423
- timeout: 3000,
424
- }).includes(want);
418
+ cmdline =
419
+ platform() === 'linux'
420
+ ? readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ')
421
+ : execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
422
+ encoding: 'utf8',
423
+ stdio: ['ignore', 'pipe', 'ignore'],
424
+ timeout: 3000,
425
+ });
425
426
  } catch {
426
427
  // NOT `false`. takeOverFrom already returned early if the pid were gone, so
427
428
  // reaching here means the process is alive and we could not READ it —
@@ -429,6 +430,45 @@ function stillTheHolder(holder) {
429
430
  // here is what made the refusal claim the pid belonged to somebody else.
430
431
  return null;
431
432
  }
433
+ if (!cmdline.includes(want)) return false;
434
+ // AN ENTRY MATCH ALONE IS NOT IDENTITY. Every daemon on the box shares one
435
+ // entry path under a global install, so "cmdline contains this cli.mjs"
436
+ // proves "is SOME flowviant daemon", not "is the daemon that wrote THIS
437
+ // lock" — and a crashed daemon's pid recycled to a SIBLING project's live
438
+ // daemon passed it, which let a same-repo takeover SIGTERM a different
439
+ // project's machine. Every 0.54.0+ lock also carries `startedAt`, the
440
+ // process's own witness to when it began, so when it is present the start
441
+ // time must agree too. `null` (could not measure — hidepid, no ps, a lock
442
+ // with no startedAt) falls back to the entry match alone, exactly the
443
+ // pre-check behaviour: refusing on ignorance here would re-brick takeover
444
+ // on the hosts that hide /proc.
445
+ const around = startedAroundLockWrite(holder);
446
+ return around === false ? false : true;
447
+ }
448
+
449
+ /**
450
+ * Unlink a lock file ONLY while it still names the pid the caller decided
451
+ * about (or nothing readable). Every rmSync of a lock outside the ppid-adopt
452
+ * path goes through this: between "I proved pid N is dead/stale" and the
453
+ * unlink, a concurrently starting daemon can clear the file itself and
454
+ * wx-create its own — and an unconditional rm then deletes a LIVE daemon's
455
+ * lock, leaving it running unguarded, which is the one condition this module
456
+ * exists to prevent. The read-then-rm gap that remains is microseconds against
457
+ * the seconds-wide window it closes.
458
+ *
459
+ * Returns false when the file now names a DIFFERENT pid — a handover the
460
+ * caller must treat as "not mine to clear" — true otherwise (removed, already
461
+ * gone, or best-effort failed into acquire's next pass).
462
+ */
463
+ function rmLockIfStill(path, pid) {
464
+ const cur = readHolder(path);
465
+ if (cur && cur.pid !== pid) return false;
466
+ try {
467
+ rmSync(path, { force: true });
468
+ } catch {
469
+ /* best-effort; a stale file is cleared by the next acquire */
470
+ }
471
+ return true;
432
472
  }
433
473
 
434
474
  /** Blocking, because this runs before there is an event loop worth yielding to
@@ -530,11 +570,11 @@ function standDown(holder, path, log) {
530
570
  sleep(400);
531
571
  }
532
572
 
533
- // A SIGKILLed daemon never ran its release(), so clear what it left.
534
- try {
535
- rmSync(path, { force: true });
536
- } catch {
537
- return { failed: 'could not clear the lock file' };
573
+ // A SIGKILLed daemon never ran its release(), so clear what it left — but
574
+ // only if the file still names the pid we stood down: in the gap since the
575
+ // last read a fresh daemon may have cleared it and taken the lock itself.
576
+ if (!rmLockIfStill(path, holder.pid)) {
577
+ return { failed: 'another daemon took the lock while it was being cleared — try again in a moment' };
538
578
  }
539
579
  return null;
540
580
  }
@@ -564,7 +604,13 @@ function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
564
604
  };
565
605
  }
566
606
  if (identified === false) {
567
- return { failed: `pid ${holder.pid} is no longer the daemon that took this lock — refusing to signal it` };
607
+ // MEASURED: the lock's writer is gone and the pid now belongs to something
608
+ // else. That is a STALE LOCK, not an unremovable holder — refusing here
609
+ // used to brick every start after an OOM-kill or reboot recycled the pid
610
+ // to any live process, until a human deleted ~/.flowviant/daemon-*.lock by
611
+ // hand. Nothing is signalled (the process is a stranger); the caller
612
+ // clears the corpse the same way it clears a dead pid's.
613
+ return { stale: true };
568
614
  }
569
615
  if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
570
616
  return {
@@ -608,9 +654,19 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
608
654
  // quietly.
609
655
  if (noTakeover) return { ok: false, holder: neighbour, sameRepo: true };
610
656
  log?.(`another project's daemon is serving this repo (pid ${neighbour.pid}).`);
611
- const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log);
612
- if (bad)
657
+ // The OPTIONS ride along — this call used to drop them, so a deliberate
658
+ // `flowviant --takeover-downgrade` against a newer neighbour printed
659
+ // "--takeover-downgrade if you mean it" at somebody who had already
660
+ // typed it.
661
+ const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log, { allowDowngrade });
662
+ if (bad?.stale) {
663
+ // The neighbour's lock is a corpse wearing a recycled pid — clear it (if
664
+ // it still names that pid) and carry on to our own lock.
665
+ log?.(`pid ${neighbour.pid} is no longer a daemon — clearing its stale lock.`);
666
+ rmLockIfStill(neighbourLockPath(neighbour, path), neighbour.pid);
667
+ } else if (bad) {
613
668
  return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed, unidentified: bad.unidentified };
669
+ }
614
670
  }
615
671
 
616
672
  // Two passes at most: one to clear a stale holder, one to take the lock. A
@@ -623,11 +679,20 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
623
679
  if (e.code !== 'EEXIST') return { ok: true, release: () => {}, unguarded: true };
624
680
  const holder = readHolder(path);
625
681
  if (!holder || !alive(holder.pid)) {
626
- // A crashed daemon's leftover. Clear it and take it on the next pass.
627
- try {
628
- rmSync(path, { force: true });
629
- } catch {
630
- return { ok: true, release: () => {}, unguarded: true };
682
+ // A crashed daemon's leftover. Clear it and take it on the next pass
683
+ // ownership-verified, because a concurrent start may have cleared and
684
+ // re-created it in the gap since our read.
685
+ if (holder) rmLockIfStill(path, holder.pid);
686
+ else {
687
+ // Unreadable content: re-read before clearing, so a half-written
688
+ // record a peer is writing RIGHT NOW is not deleted mid-write.
689
+ const again = readHolder(path);
690
+ if (again && alive(again.pid)) continue; // it finished writing — a real holder now
691
+ try {
692
+ rmSync(path, { force: true });
693
+ } catch {
694
+ return { ok: true, release: () => {}, unguarded: true };
695
+ }
631
696
  }
632
697
  continue;
633
698
  }
@@ -653,10 +718,28 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
653
718
  const wanted = force || (here && !noTakeover);
654
719
  if (wanted) {
655
720
  const bad = takeOverFrom(holder, path, log, { allowDowngrade });
721
+ if (bad?.stale) {
722
+ // Measured: the lock's writer is gone and its pid was recycled to a
723
+ // stranger. A corpse is cleared, never "refused" — refusing bricked
724
+ // every start after a reboot handed the pid to any live process.
725
+ log?.(`pid ${holder.pid} is no longer a daemon — clearing its stale lock.`);
726
+ rmLockIfStill(path, holder.pid);
727
+ continue;
728
+ }
656
729
  if (bad)
657
730
  return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here, unidentified: bad.unidentified };
658
731
  continue; // the file is gone — the next pass takes it
659
732
  }
733
+ // Before refusing on a different-repo holder, make sure it IS one: a
734
+ // stale lock whose pid was recycled to any live process would otherwise
735
+ // refuse this credential's start forever, naming a "daemon" that is a
736
+ // stranger. Only the MEASURED verdict clears; null (could not look)
737
+ // still refuses, because ignorance must not delete a lock.
738
+ if (stillTheHolder(holder) === false) {
739
+ log?.(`pid ${holder.pid} is no longer a daemon — clearing its stale lock.`);
740
+ rmLockIfStill(path, holder.pid);
741
+ continue;
742
+ }
660
743
  return { ok: false, holder, sameRepo: here };
661
744
  }
662
745
  try {
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',
@@ -74,8 +56,28 @@ export async function runLogin({ thenStart = false } = {}) {
74
56
  continue; // transient — keep polling
75
57
  }
76
58
  if (poll.status === 'approved') {
77
- store({ fleetToken: poll.fleetToken, projectId: poll.projectId, mcpUrl: poll.mcpUrl });
78
- ok('connected credential saved to ~/.flowviant/credentials.json');
59
+ // `machineToken` is the wire's new name; `fleetToken` is the one every
60
+ // published daemon reads. The server dual-sends until DAEMON_MIN clears
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
+ );
79
81
  // The daemon starts right here unless the caller opted out; telling
80
82
  // someone to run a second command was the step that got missed, since by
81
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
  }
@@ -228,9 +228,25 @@ function mutateRegistry(fn) {
228
228
  }
229
229
  }
230
230
 
231
+ /** Signal-0 liveness (EPERM = alive and not ours), for the OWNER check below. */
232
+ function processAlive(pid) {
233
+ if (!Number.isInteger(pid) || pid <= 0) return false;
234
+ try {
235
+ process.kill(pid, 0);
236
+ return true;
237
+ } catch (e) {
238
+ return e.code === 'EPERM';
239
+ }
240
+ }
241
+
231
242
  function recordPreviewPid(pid, sig) {
232
243
  if (!pid) return;
233
- mutateRegistry((list) => [...list, { pid, sig }]);
244
+ // `owner` is the DAEMON that spawned it. The registry is shared by design —
245
+ // two daemons serving two projects both write here — so without an owner a
246
+ // starting daemon reaped its PEER's live tunnels: killed them, wiped their
247
+ // entries, and the peer kept heartbeating a URL that 530s (its probe watches
248
+ // the origin port, which was still alive).
249
+ mutateRegistry((list) => [...list, { pid, sig, owner: process.pid }]);
234
250
  }
235
251
 
236
252
  function forgetPreviewPid(pid) {
@@ -253,12 +269,21 @@ function stillOurs(pid, sig) {
253
269
  }
254
270
 
255
271
  /** Reap tunnel process groups left behind by a previously-crashed daemon.
256
- * Call once at daemon startup, before any work begins. */
272
+ * Call once at daemon startup, before any work begins.
273
+ *
274
+ * ORPHANS ONLY: an entry whose owning daemon is STILL ALIVE belongs to a
275
+ * peer serving another project (or to the process we are replacing, whose
276
+ * own teardown handles it) — killing those and wiping their entries was a
277
+ * peer daemon's startup silently breaking every live share on the box. Only
278
+ * the entries this pass handled are removed; a peer's records survive. */
257
279
  export function reapOrphanPreviews(log) {
258
280
  const list = readRegistry();
259
281
  if (list.length === 0) return;
260
282
  let killed = 0;
261
- for (const { pid, sig } of list) {
283
+ const handled = new Set();
284
+ for (const { pid, sig, owner } of list) {
285
+ if (Number.isInteger(owner) && owner !== process.pid && processAlive(owner)) continue;
286
+ handled.add(pid);
262
287
  if (!stillOurs(pid, sig)) continue;
263
288
  try {
264
289
  process.kill(-pid, 'SIGKILL'); // whole group
@@ -272,7 +297,7 @@ export function reapOrphanPreviews(log) {
272
297
  }
273
298
  }
274
299
  }
275
- mutateRegistry(() => []);
300
+ if (handled.size) mutateRegistry((cur) => cur.filter((e) => !handled.has(e.pid)));
276
301
  if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
277
302
  }
278
303
 
@@ -295,8 +320,35 @@ const TAIL_BYTES = 2000;
295
320
  * cloudflared happily outlives a dead dev server and the gate answers a dead
296
321
  * origin with 502, so without this the product would report "live" over a 502 —
297
322
  * Flowviant asserting a state it never measured.
323
+ *
324
+ * `stillServing` (optional, async → boolean) is the ATTRIBUTION re-check the
325
+ * probe runs instead of a bare TCP connect. Ports are global to a box and a
326
+ * worktree is not: when the driver's dev server dies and anything else — a
327
+ * teammate's worktree, a database — binds the same number, a bare
328
+ * `isListening` keeps the probe green and the existing URL+password serve the
329
+ * NEW process, outside every consent gate. The caller passes the same
330
+ * `listenersIn(worktree)` check the open path uses, so "the origin is alive"
331
+ * keeps meaning "THIS session's origin".
332
+ *
333
+ * `onAbuse` fires when the gate closes itself after repeated failed password
334
+ * attempts — AFTER the share is torn down locally — so the caller can report
335
+ * the incident. Without it the abuse close was invisible: the row kept
336
+ * reading "live" until staleness, and endedReason 'abuse' was unreachable.
337
+ *
338
+ * `onTunnelGone` fires when cloudflared exits AFTER the URL was published
339
+ * (quick tunnels are best-effort and do get dropped). The probe cannot see
340
+ * this — it watches the origin — and a daemon that keeps heartbeating a dead
341
+ * hostname confirms "live" over a 530 for up to 8 hours.
298
342
  */
299
- export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
343
+ export async function openTunnel({
344
+ port,
345
+ log,
346
+ onDead,
347
+ onAbuse,
348
+ onTunnelGone,
349
+ stillServing,
350
+ probeMs = 20_000,
351
+ }) {
300
352
  // Re-validate at the machine. The server checked this port against the last
301
353
  // report; reports are up to a minute old and a dev server is a process a
302
354
  // human can stop at any moment.
@@ -337,7 +389,18 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
337
389
 
338
390
  // The gate comes up FIRST and the tunnel points at it, never at the origin —
339
391
  // so there is no window in which the public hostname is un-gated.
340
- gate = await startAuthProxy({ targetPort: port, log, onAbuse: () => stop() });
392
+ gate = await startAuthProxy({
393
+ targetPort: port,
394
+ log,
395
+ onAbuse: () => {
396
+ stop();
397
+ try {
398
+ onAbuse?.();
399
+ } catch {
400
+ /* the caller's report is best-effort */
401
+ }
402
+ },
403
+ });
341
404
  if (!gate) {
342
405
  return { error: 'could not start the password gate for this preview, so nothing was published.' };
343
406
  }
@@ -348,7 +411,11 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
348
411
  args.push('--http-host-header', 'localhost');
349
412
 
350
413
  tunnel = spawn(cf.bin, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
351
- recordPreviewPid(tunnel.pid, 'cloudflared');
414
+ // The signature names THIS tunnel's gate port, not the bare word
415
+ // 'cloudflared': the reap matches cmdline.includes(sig), and the generic
416
+ // word would let a recycled pid land on an operator's own unrelated
417
+ // cloudflared and group-SIGKILL it.
418
+ recordPreviewPid(tunnel.pid, `--url http://localhost:${gate.port}`);
352
419
 
353
420
  return new Promise((resolve) => {
354
421
  let settled = false;
@@ -378,11 +445,19 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
378
445
  const m = TUNNEL_RE.exec(s);
379
446
  if (!m) return;
380
447
 
381
- // Watch the ORIGIN, not the tunnel. A dead dev server behind a live
382
- // hostname is the failure a viewer cannot diagnose.
448
+ // Watch the ORIGIN with the caller's ATTRIBUTION check when it gave
449
+ // one, never a bare port probe: a freed port rebound by another
450
+ // worktree answers a TCP connect exactly like the origin did, and the
451
+ // share would keep serving a process nobody consented to publish.
383
452
  probe = setInterval(async () => {
384
453
  if (stopped) return;
385
- if (!(await isListening(port))) {
454
+ let serving;
455
+ try {
456
+ serving = stillServing ? await stillServing() : await isListening(port);
457
+ } catch {
458
+ serving = false; // an attribution check that errors is not a "yes"
459
+ }
460
+ if (!serving) {
386
461
  const dead = onDead;
387
462
  stop();
388
463
  try {
@@ -394,6 +469,20 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
394
469
  }, probeMs);
395
470
  if (probe.unref) probe.unref();
396
471
 
472
+ // The TUNNEL dying after publish (quick tunnels get dropped) is the one
473
+ // exit the probe cannot see. `stopped` guards our own kill: stop() sets
474
+ // it before signalling, so this only fires for a death nobody asked for.
475
+ tunnel.once('close', () => {
476
+ if (stopped) return;
477
+ const gone = onTunnelGone;
478
+ stop();
479
+ try {
480
+ gone?.();
481
+ } catch {
482
+ /* the caller's report is best-effort */
483
+ }
484
+ });
485
+
397
486
  finish({ url: m[0], user: gate.user, password: gate.password, stop });
398
487
  };
399
488
 
package/bin/lib/work.mjs CHANGED
@@ -492,7 +492,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
492
492
  /* best-effort */
493
493
  }
494
494
  }
495
- await postPreview({ sessionId, ended: true, endedReason: reason });
495
+ // Confirm only a teardown we actually PERFORMED. The stop job is a
496
+ // broadcast — every daemon on the credential gets it — and the one holding
497
+ // nothing used to answer instantly, flipping the row to 'ended' so the
498
+ // real holder was never told to stop and its tunnel outlived every
499
+ // surface. (The server drops mismatched confirms too; this is the copy on
500
+ // the component that can be published ahead of a deploy.) A stop for a
501
+ // tunnel whose daemon crashed resolves server-side: an unanswered 'ending'
502
+ // row reads as over once it goes stale.
503
+ if (live) await postPreview({ sessionId, ended: true, endedReason: reason });
496
504
  };
497
505
 
498
506
  const processPreviewJobs = (jobs) => {
@@ -552,6 +560,27 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
552
560
  livePreviews.delete(sessionId);
553
561
  void postPreview({ sessionId, ended: true, endedReason: 'origin_gone' });
554
562
  },
563
+ // ATTRIBUTION rides the probe, not just the open: a freed default
564
+ // port (5173…) rebound by any other process on the box would keep
565
+ // a bare TCP probe green, and the share's URL+password would serve
566
+ // a worktree nobody consented to publish.
567
+ stillServing: async () => listenersIn(wt).some((l) => l.port === port),
568
+ // The gate closed itself after repeated failed passwords. Stored,
569
+ // so the incident is visible — and the entry is dropped so the
570
+ // owner can re-share the port without restarting the daemon.
571
+ onAbuse: () => {
572
+ livePreviews.delete(sessionId);
573
+ void postPreview({ sessionId, ended: true, endedReason: 'abuse' });
574
+ },
575
+ // cloudflared died AFTER publishing (quick tunnels get dropped).
576
+ // Without this the daemon kept heartbeating a hostname that 530s.
577
+ onTunnelGone: () => {
578
+ livePreviews.delete(sessionId);
579
+ void postPreview({
580
+ sessionId,
581
+ error: 'the tunnel dropped — share it again to reopen.',
582
+ });
583
+ },
555
584
  });
556
585
  if (t.error) {
557
586
  await postPreview({ sessionId, error: t.error });
@@ -1195,6 +1224,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1195
1224
  // worktree would pull the directory out from under a running turn. Absence
1196
1225
  // means "the tab closed"; this is the one other thing it can mean.
1197
1226
  const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
1227
+ // A peer-held session's CACHED work token is a claim-bypass: the mint is
1228
+ // the one place the session lease 409s a non-holder, and a token younger
1229
+ // than ~23h skips the mint entirely — so a daemon that lost a lease would
1230
+ // run the next turn anyway, editing the worktree while every MCP call
1231
+ // 401s (the peer's mint rotated the secret). Dropping the cache forces
1232
+ // the next turn through the mint, where the 409 stands it down.
1233
+ for (const id of peers) workTokens.delete(id);
1198
1234
  const dir = join(baseDir, 'sessions');
1199
1235
  if (!existsSync(dir)) return;
1200
1236
  let ids;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.54.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.",
3
+ "version": "0.55.0",
4
+ "description": "Run your own coding CLIs as build agents for Flowviant Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flowviant": "bin/cli.mjs"