flowviant 0.90.0 → 0.91.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
@@ -40,7 +40,10 @@
40
40
  * bare `npx flowviant` serves the project BOUND to the repo it is started in.
41
41
  * Ambiguity is a picker on a TTY and a worded refusal headless — never a
42
42
  * guess. `flowviant projects` lists what is stored; `--project <name|id>`
43
- * picks without a prompt.
43
+ * picks without a prompt. `flowviant machines` (0.91.0) asks the server which
44
+ * BOXES have polled each of those credentials — the view-only answer to "am I
45
+ * running duplicate or redundant daemons?", which the store alone cannot give
46
+ * because a daemon on another computer is invisible from here.
44
47
  *
45
48
  * Env:
46
49
  * FLOWVIANT_FLEET the machine credential (or use `flowviant login`).
@@ -195,8 +198,18 @@ if (process.argv[2] === 'projects') {
195
198
  process.exit(0);
196
199
  }
197
200
  for (const e of entries) {
201
+ // THE DATE RIDES THE ID, always — this listing exists for the moment
202
+ // somebody is asking "why did it say skadooble?", and "which of these two
203
+ // did I set up last month" is the same question one step on. Absent when
204
+ // nothing recorded one (a credential stored before the field existed);
205
+ // nothing is invented.
206
+ const savedOn = e.savedAt ? new Date(e.savedAt) : null;
207
+ const connected =
208
+ savedOn && !Number.isNaN(savedOn.getTime())
209
+ ? `, connected ${savedOn.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`
210
+ : '';
198
211
  console.log(
199
- ` ${projectLabel(e)} (${e.projectId.slice(0, 8)}…)` +
212
+ ` ${projectLabel(e)} (${e.projectId.slice(0, 8)}…${connected})` +
200
213
  `${e.repoRoot ? `\n repo · ${e.repoRoot}` : '\n repo · not bound yet — first start or login in its repo binds it'}` +
201
214
  `${e.active ? '\n what a pre-0.55.0 flowviant on this box would serve (the legacy mirror)' : ''}`
202
215
  );
@@ -208,6 +221,96 @@ if (process.argv[2] === 'projects') {
208
221
  process.exit(0);
209
222
  }
210
223
 
224
+ // ── `flowviant machines` — every project connected on THIS box, and every box
225
+ // that has polled each of them.
226
+ //
227
+ // A THIRD COMMAND, and the owner's ruling it amends is his own: the terminal
228
+ // surface is `npx flowviant` and `npx flowviant login`, full stop — which is
229
+ // what deleted the old terminal flag for moving a machine — and he asked for
230
+ // this one directly ("the CLI gets a command to list connections"). So it is
231
+ // amended in the narrowest form that ruling allows: VIEW-ONLY. It starts
232
+ // nothing, stops nothing, moves nothing, takes no lock and reaches no daemon.
233
+ // The only state it can change is this box's credential file, and only behind
234
+ // an explicit `--forget`.
235
+ //
236
+ // The question is the owner's, verbatim: "is there a way to view ALL the
237
+ // connected flowviants? because im not sure if i have any duplicate or
238
+ // redundant daemons running". It needs the NETWORK, unlike `projects` next
239
+ // door, because the boxes are the server's answer — this box cannot see a
240
+ // daemon running on somebody else's computer, and guessing would be worse than
241
+ // asking.
242
+ //
243
+ // ONE CALL PER STORED CREDENTIAL, because the CLI has NO PERSON IDENTITY: every
244
+ // request it can make is `Bearer <machine token>`, so "all my machines" has to
245
+ // be assembled from the credentials this box holds. That is also the limit the
246
+ // footer states out loud — the app's Home is where the whole account's answer
247
+ // lives.
248
+ //
249
+ // EXIT 0 whatever it finds, including nothing: the listing is the answer the
250
+ // asker came for, and a non-zero code over "you have no projects connected"
251
+ // would make this command unusable in anything that checks one.
252
+ if (process.argv[2] === 'machines') {
253
+ const creds = await import('./lib/credentials.mjs');
254
+ const forgetAt = process.argv.indexOf('--forget');
255
+ if (forgetAt >= 0) {
256
+ // THE ONE WRITE. Named explicitly, matched by full id or a ≥6 prefix, and
257
+ // AMBIGUITY REFUSES rather than guessing — this deletes a credential, and
258
+ // two projects that look alike is the case that produced the command.
259
+ const res = creds.forgetStoredProject(process.argv[forgetAt + 1]);
260
+ if (res.error) {
261
+ console.error(`error: ${res.error}`);
262
+ process.exit(1);
263
+ }
264
+ console.log(
265
+ `forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box.\n` +
266
+ ' Nothing was stopped or deleted anywhere else — `flowviant login` connects it again.'
267
+ );
268
+ process.exit(0);
269
+ }
270
+
271
+ const entries = creds.listStoredProjects();
272
+ if (entries.length === 0) {
273
+ console.log('no projects connected on this machine yet — run `flowviant login` inside a repo.');
274
+ process.exit(0);
275
+ }
276
+ const { boxesUrlFrom, fetchBoxesFor, renderMachines, MACHINES_FOOTER } = await import(
277
+ './lib/machines.mjs'
278
+ );
279
+ const { FLEET_URL } = await import('./lib/config.mjs');
280
+ // OUR OWN BOX ID, so the listing can mark "← this box".
281
+ //
282
+ // READ, NEVER CREATED. This called `ensureKeypair()` until 2026-09-19, which
283
+ // MINTS a keypair when there is no file — a 0600 write establishing this
284
+ // machine's durable identity, performed by a command whose entire job is to
285
+ // print a list. A view-only command is what let a third terminal command
286
+ // exist at all, and enrolling a box as a side effect of looking at one is not
287
+ // that. `readStoredPubB64` reads or answers null.
288
+ //
289
+ // NULL COSTS ONLY THE MARK, and that is the honest outcome: a box that has
290
+ // never run a daemon has never polled, so it is not in this listing to be
291
+ // marked in the first place.
292
+ let me;
293
+ try {
294
+ const env = await import('./lib/env.mjs');
295
+ me = env.readStoredPubB64() ?? undefined;
296
+ } catch {
297
+ /* unreadable keypair — nothing is marked, and nothing is claimed */
298
+ }
299
+ const url = boxesUrlFrom(FLEET_URL);
300
+ const results = {};
301
+ // SEQUENTIAL, not a fan-out: this is a handful of credentials on somebody's
302
+ // laptop, and a parallel burst against the API buys nothing a person waiting
303
+ // two seconds can perceive.
304
+ for (const e of entries) {
305
+ results[e.projectId] = await fetchBoxesFor(e, { url, envpub: me });
306
+ }
307
+ console.log('');
308
+ for (const line of renderMachines(entries, results)) console.log(line);
309
+ console.log('');
310
+ for (const line of MACHINES_FOOTER) console.log(line);
311
+ process.exit(0);
312
+ }
313
+
211
314
  // `flowviant env <import|set|show>` — the CLI half of team env sync. Values
212
315
  // are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
213
316
  // the browser); `show` decrypts locally — it only works on an ENROLLED machine.
@@ -247,15 +350,14 @@ if (process.argv[2] === 'env') {
247
350
  const { canPrompt, askWithTimeout, selectMenu, menuSupported } = await import('./lib/tty.mjs');
248
351
  const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
249
352
 
250
- /** How long the one-time binding confirm waits before serving unbound. A person
251
- * who just typed `flowviant` answers in seconds; anything longer is a restart
252
- * nobody is watching, and the machine must not sit dark for it. */
253
- const CONFIRM_TIMEOUT_MS = 20_000;
254
-
255
- /** The picker's own budget. Longer than the confirm: this one asks you to READ
256
- * a list before answering, and its fallback costs you a start rather than
257
- * costing you a binding. */
258
- const PICK_TIMEOUT_MS = 60_000;
353
+ /** NO ANSWER TIME LIMIT on the start path (2026-09-20, the owner: "why is
354
+ * there an answer time limit, remove that"). The confirm below carried 20s
355
+ * and the picker 60s, each arguing that a restart nobody is watching must
356
+ * not sit dark on a prompt — but `interactive` already excludes exactly that
357
+ * case (no foreground terminal, or a self-update re-exec, and nothing is
358
+ * asked). What was left was a person reading a list being told "no answer
359
+ * in 60s — nothing started". A prompt a person can see waits for the
360
+ * person; tty.mjs's header carries the argument. */
259
361
  const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
260
362
 
261
363
  /** Re-exec a plain `flowviant` after an inline login — the login command's own
@@ -268,11 +370,16 @@ async function reexecAfterLogin() {
268
370
  process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
269
371
  }
270
372
 
271
- function listLines(entries, { projectLabel }) {
373
+ // TWO ROWS THAT READ ALIKE ARE NOT A CHOICE. `projectRowLabel` adds the id and
374
+ // the connected date to the COLLIDING rows only — production held two projects
375
+ // both called "BRIF AI", both bound to the same checkout, so this picker
376
+ // offered two identical lines and the only way to answer was to guess. See
377
+ // credentials.mjs for why the suffix is not on every row.
378
+ function listLines(entries, { projectRowLabel }) {
272
379
  return entries
273
380
  .map(
274
381
  (e, i) =>
275
- ` ${i + 1}. ${projectLabel(e)}` +
382
+ ` ${i + 1}. ${projectRowLabel(e, entries)}` +
276
383
  (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet')
277
384
  )
278
385
  .join('\n');
@@ -321,22 +428,15 @@ if (!FLEET_TOKEN) {
321
428
  let chosen = null; // 0-based into [...choices, login]
322
429
  if (menuSupported()) {
323
430
  const rowLabel = (e) =>
324
- creds.projectLabel(e) + (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet');
431
+ creds.projectRowLabel(e, choices) +
432
+ (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet');
325
433
  const options = [...choices.map(rowLabel), loginLabel];
326
434
  // Say WHY the cursor starts where it does — "intuitive" made visible.
327
435
  if (likely >= 0) options[likely] += ' ← looks like this repo';
328
436
  const res = await selectMenu({
329
437
  options,
330
438
  defaultIndex: likely >= 0 ? likely : 0,
331
- timeoutMs: PICK_TIMEOUT_MS,
332
439
  });
333
- if (res.timedOut) {
334
- console.error(
335
- `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
336
- `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
337
- );
338
- process.exit(1);
339
- }
340
440
  if (res.cancelled) {
341
441
  console.error('nothing chosen — nothing started.');
342
442
  process.exit(1);
@@ -351,12 +451,13 @@ if (!FLEET_TOKEN) {
351
451
  console.log(` ${choices.length + 1}. ${loginLabel}`);
352
452
  const hint = likely >= 0 ? ` (enter for ${creds.projectLabel(choices[likely])})` : '';
353
453
  const raw = await askWithTimeout(
354
- `Which project should this daemon serve? [1-${choices.length + 1}]${hint} `,
355
- PICK_TIMEOUT_MS
454
+ `Which project should this daemon serve? [1-${choices.length + 1}]${hint} `
356
455
  );
357
456
  if (raw === null) {
457
+ // The read itself failed — stdin closed, or the terminal went away
458
+ // mid-question. Not a timeout: there is none.
358
459
  console.error(
359
- `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
460
+ `\ncould not read an answer — nothing started. ` +
360
461
  `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
361
462
  );
362
463
  process.exit(1);
@@ -410,7 +511,10 @@ if (!FLEET_TOKEN) {
410
511
  // stop here with the machine serving nothing. A guard that only works once
411
512
  // everyone already has it is not a guard.
412
513
  //
413
- // ON TIMEOUT WE SERVE, AND WE DO NOT BIND. Those are two decisions:
514
+ // IF THE READ FAILS WE SERVE, AND WE DO NOT BIND. (This used to say "on
515
+ // timeout"; there is no timeout since 2026-09-20 — a person at the prompt
516
+ // is waited for. `null` now means stdin closed under the question.) Those
517
+ // are two decisions:
414
518
  // · SERVE, because it is what every version before 0.55.0 did with this
415
519
  // exact store, so the silent path is the status quo rather than a new
416
520
  // risk — and a daemon that answers is strictly better than one that does
@@ -422,13 +526,12 @@ if (!FLEET_TOKEN) {
422
526
  const creds = await import('./lib/credentials.mjs');
423
527
  const label = creds.projectLabel(CREDENTIAL.entry);
424
528
  const answered = await askWithTimeout(
425
- `This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `,
426
- CONFIRM_TIMEOUT_MS
529
+ `This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `
427
530
  );
428
- const raw = answered === null ? null : answered.toLowerCase(); // null = nobody answered
531
+ const raw = answered === null ? null : answered.toLowerCase(); // null = the read failed
429
532
  if (raw === null) {
430
533
  console.log(
431
- `\n no answer in ${Math.round(CONFIRM_TIMEOUT_MS / 1000)}s — serving ${label} for this run ` +
534
+ `\n could not read an answer — serving ${label} for this run ` +
432
535
  `without tying it to this repo. Run \`flowviant\` here and answer to make it stick.`
433
536
  );
434
537
  } else if (raw === '' || raw === 'y' || raw === 'yes') {
@@ -264,6 +264,24 @@ export const MACHINE_HOST = (() => {
264
264
  }
265
265
  })();
266
266
 
267
+ /**
268
+ * WHEN THIS PROCESS STARTED (0.91.0), measured ONCE at import.
269
+ *
270
+ * It rides the poll as `st` beside `pid`, and together they are what somebody
271
+ * who has lost track of their daemons actually needs: the owner, verbatim, "im
272
+ * not sure if i have any duplicate or redundant daemons running". A pid alone
273
+ * cannot tell one long-lived daemon from a process that has restarted nine
274
+ * times since you last looked; a start time can.
275
+ *
276
+ * Derived from `process.uptime()` rather than stamped at import for its own
277
+ * sake, so it stays true across a lazy import and reads as the process's own
278
+ * age rather than this module's. Computed once, deliberately: a value that
279
+ * drifts by a millisecond every poll would rewrite the column for ever.
280
+ */
281
+ export const PROCESS_STARTED_AT = new Date(
282
+ Date.now() - Math.max(0, process.uptime() * 1000)
283
+ ).toISOString();
284
+
267
285
  // The ONE credential. `tokens` (FLOWVIANT_TOKEN / FLOWVIANT_TOKENS / --token /
268
286
  // --tokens) stood beside it and carried WORKER tokens into the pre-daemon loop;
269
287
  // that principal owns zero tools since dispatch was deleted, and the kind can no
@@ -136,6 +136,42 @@ export function projectLabel(e) {
136
136
  return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
137
137
  }
138
138
 
139
+ /**
140
+ * THE SAME LABEL, DISAMBIGUATED — for a list where two rows can read alike
141
+ * (2026-09-19).
142
+ *
143
+ * Two projects genuinely named the same thing is not a hypothetical: production
144
+ * held two called "BRIF AI", both bound to the same checkout on one box, so
145
+ * `npx flowviant` there offered two IDENTICAL picker rows and the only way to
146
+ * choose between them was to guess. A picker whose options cannot be told apart
147
+ * is not a picker.
148
+ *
149
+ * THE SUFFIX IS ONLY ON THE COLLIDING ROWS. An id on every row would be noise
150
+ * on the ordinary case — one project per repo, nothing ambiguous — and noise is
151
+ * what makes the one row that matters unfindable. The DATE rides beside the id
152
+ * because it is the fact a person can actually match against their own memory
153
+ * ("I set that one up last month"), where two random-looking hex prefixes are
154
+ * two things to compare character by character.
155
+ *
156
+ * `flowviant machines` deliberately does the OPPOSITE and prints the id on
157
+ * every row: there you are reconciling a whole box's worth of credentials, and
158
+ * a suffix you have to notice before you know it matters has already made you
159
+ * do the work the listing exists to do for you.
160
+ */
161
+ export function projectRowLabel(e, entries) {
162
+ const label = projectLabel(e);
163
+ const collides =
164
+ Array.isArray(entries) &&
165
+ entries.filter((x) => x !== e && projectLabel(x) === label).length > 0;
166
+ if (!collides) return label;
167
+ const d = e?.savedAt ? new Date(e.savedAt) : null;
168
+ const when =
169
+ d && !Number.isNaN(d.getTime())
170
+ ? `, connected ${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`
171
+ : '';
172
+ return `${label} (id ${String(e?.projectId ?? '').slice(0, 8)}…${when})`;
173
+ }
174
+
139
175
  /** Collapse a name or slug for loose comparison: "My Project", "my-project"
140
176
  * and "myproject" all become "myproject". */
141
177
  function normalizeName(s) {
@@ -213,6 +249,48 @@ export function selectStoredProject(projectId, { bindRepoRoot } = {}) {
213
249
  });
214
250
  }
215
251
 
252
+ /**
253
+ * FORGET A STORED CREDENTIAL — `flowviant machines --forget <id>` (2026-09-19).
254
+ *
255
+ * The one write this otherwise view-only command can make, and it edits THIS
256
+ * BOX'S credential file and nothing else: no server is called, no daemon is
257
+ * stopped, no project is deleted. It exists because a credential whose project
258
+ * was disconnected or deleted in the app 401s forever from here, and until now
259
+ * the only way to clear one was to hand-edit JSON under `~/.flowviant`.
260
+ *
261
+ * IT CLEARS THE LEGACY MIRROR TOO when the mirror names the entry being
262
+ * removed. Leaving it would resurrect the row on the next read —
263
+ * `listStoredProjects` surfaces the top-level trio as an entry whenever no map
264
+ * row carries its token — so the forget would silently not have happened, which
265
+ * is the worst outcome available here.
266
+ *
267
+ * MATCHES BY FULL ID OR A PREFIX OF AT LEAST SIX, the same rule
268
+ * `matchStoredProject` keeps, and AMBIGUITY IS A REFUSAL rather than a coin
269
+ * flip: this deletes a credential, and the case that produced the whole command
270
+ * is two projects that look alike.
271
+ */
272
+ export function forgetStoredProject(ref) {
273
+ const q = String(ref ?? '').trim();
274
+ if (!q) return { error: 'empty --forget value' };
275
+ const hits = listStoredProjects().filter(
276
+ (e) => e.projectId === q || (q.length >= 6 && e.projectId.startsWith(q))
277
+ );
278
+ if (hits.length === 0) return { error: `no stored project matches "${q}"` };
279
+ if (hits.length > 1) {
280
+ return { error: `"${q}" matches ${hits.length} stored projects — use the full project id` };
281
+ }
282
+ const gone = hits[0];
283
+ mutate((f) => {
284
+ delete f.projects[gone.projectId];
285
+ if (f.projectId === gone.projectId) {
286
+ delete f.projectId;
287
+ delete f.fleetToken;
288
+ delete f.mcpUrl;
289
+ }
290
+ });
291
+ return { entry: gone };
292
+ }
293
+
216
294
  export function bindStoredRepo(projectId, repoRoot) {
217
295
  if (!repoRoot) return;
218
296
  mutate((f) => {
package/bin/lib/env.mjs CHANGED
@@ -146,6 +146,31 @@ export function myPubB64() {
146
146
  return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
147
147
  }
148
148
 
149
+ /**
150
+ * THIS BOX'S PUBLIC KEY, READ AND NEVER WRITTEN (2026-09-19, the review).
151
+ *
152
+ * `flowviant machines` is a VIEW-ONLY command — that narrowness is the whole
153
+ * reason a third terminal command was allowed to exist at all — and it was
154
+ * calling `ensureKeypair`, which on a box with no keypair CREATES ONE: a
155
+ * filesystem write, 0600, minting this machine's durable identity, from a
156
+ * command whose entire job is to print a list. Listing must not enrol.
157
+ *
158
+ * It reads the stored file and nothing else. No sodium, no parse of the private
159
+ * half, no creation. NULL for every failure — absent, unreadable, malformed —
160
+ * and null simply means no row gets the "← this box" mark, which is honest: a
161
+ * box that has never run a daemon has never polled, so it is not in the listing
162
+ * to be marked. Claiming a row is you on a guess would be worse than the mark
163
+ * being absent.
164
+ */
165
+ export function readStoredPubB64() {
166
+ try {
167
+ const stored = JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8'));
168
+ return typeof stored?.pub === 'string' && stored.pub ? stored.pub : null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
149
174
  /** Query params the roster poll carries: identity, materialized version, and
150
175
  * the target files we REFUSED to write.
151
176
  *
package/bin/lib/fleet.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  SAFE,
26
26
  DAEMON_INSTANCE,
27
27
  MACHINE_HOST,
28
+ PROCESS_STARTED_AT,
28
29
  POLL_SECONDS,
29
30
  MAX_CONCURRENT,
30
31
  IDLE_SECONDS,
@@ -93,6 +94,64 @@ import { claudeAuthContext } from './claudeAuth.mjs';
93
94
  /** Said once per process — see the catch around `envQueryParams` below. */
94
95
  let warnedEnvIdentity = false;
95
96
 
97
+ /**
98
+ * HAS THIS PROCESS ALREADY ASKED FOR THE MACHINE? (0.91.0)
99
+ *
100
+ * The owner's ruling on two boxes running one project: "it should kill the
101
+ * first one and take over". So a daemon asks on the FIRST poll of its life and
102
+ * never again, and this pair is the whole of that "never again".
103
+ *
104
+ * ONE ASK PER PROCESS IS THE SAFETY, NOT A POLITENESS. A param that kept asking
105
+ * would be two boxes trading a machine back and forth every ten seconds. Asked
106
+ * once, the story ends at the first exchange, because the box that loses
107
+ * SETTLES ITS TURNS AND EXITS rather than restarting — and a process that has
108
+ * exited sends no more first polls.
109
+ *
110
+ * THAT LAST SENTENCE IS TRUE OF THE PROCESS AND NOT OF THE UNIT, which is why
111
+ * the server stopped relying on it (2026-09-19). Under `Restart=always`, pm2 or
112
+ * docker, exit 0 is a relaunch and the relaunch's first poll is a genuine one.
113
+ * `takeMachineHolderNow` refuses a take by the box it just displaced for the
114
+ * length of the displaced window, so the ping-pong is bounded on the side that
115
+ * can see both boxes. This module still keeps its half of the bargain.
116
+ *
117
+ * SPENT ON A DELIVERED POLL, NOT ON AN ATTEMPTED ONE, which is why this is two
118
+ * functions instead of one consuming read. A daemon that starts while the wire
119
+ * is down retries every ten seconds; spending the ask on the failed attempt
120
+ * would mean the box you walked over to and started never takes the machine,
121
+ * and nothing anywhere would say why.
122
+ *
123
+ * IT IS NOT THE DELETED FLAG COMING BACK. There is still nothing to type: the
124
+ * terminal surface is `npx flowviant` and `npx flowviant login`, and what
125
+ * claims here is starting the daemon, which is already one of those two.
126
+ *
127
+ * Exported so both properties can be proved without a server, a credential or a
128
+ * second box — they are the contract, and nothing else in the file states them.
129
+ */
130
+ /**
131
+ * AND A RESTART IS NOT A PERSON (2026-09-19, the review).
132
+ *
133
+ * `update.mjs` re-execs the daemon with `FLOWVIANT_REEXEC='1'` after an
134
+ * UNATTENDED auto-update, which is on by default. Born `false`, the new process
135
+ * would then spend a fresh ask and TAKE THE PROJECT'S MACHINE — so a standby
136
+ * sitting quietly on somebody's second computer would seize the machine off the
137
+ * live holder in the middle of the night, settling its running turns as moved,
138
+ * because npm published a patch. Nobody was at either keyboard, and nothing on
139
+ * any surface would say why.
140
+ *
141
+ * The ask belongs to the GESTURE, not to the process: what claims a machine is
142
+ * a person typing `npx flowviant`, and a re-exec is the same start continuing.
143
+ * So a re-executed daemon is born with the ask already spent and behaves
144
+ * exactly as it did before the update — it keeps the machine if it had it (arm
145
+ * (a) stamps it), and stands by if it did not.
146
+ */
147
+ let machineAskSpent = process.env.FLOWVIANT_REEXEC === '1';
148
+ export function machineAskPending() {
149
+ return !machineAskSpent;
150
+ }
151
+ export function spendMachineAsk() {
152
+ machineAskSpent = true;
153
+ }
154
+
96
155
  async function fetchRoster(
97
156
  haveIds,
98
157
  livePreviewSessionIds = [],
@@ -101,7 +160,12 @@ async function fetchRoster(
101
160
  * every unattended lane asks — see the `pr` param below for why it is the
102
161
  * admission and not the pressure reading alone. Undefined where the caller
103
162
  * has no admission to offer, which reads exactly like an older daemon. */
104
- churnHold = undefined
163
+ churnHold = undefined,
164
+ /** The checkout this daemon serves, for the `cp` report below. Passed in
165
+ * rather than re-derived: `repoRootOrDie` is resolved once at startup and
166
+ * running git on every poll to re-learn a constant would be a syscall for
167
+ * a readout. */
168
+ repoRoot = null
105
169
  ) {
106
170
  const url = new URL(FLEET_URL);
107
171
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
@@ -132,9 +196,51 @@ async function fetchRoster(
132
196
  // Absent when the host has no readable name — an older daemon looks the same,
133
197
  // and both mean "nobody said", which is what the nameless fallback renders.
134
198
  if (MACHINE_HOST) url.searchParams.set('mh', MACHINE_HOST);
135
- // NOTHING HERE ASKS FOR THE MACHINE. A poll reports what this box IS; moving
136
- // the project's machine onto it is a gesture a person makes in the app, and
137
- // the daemon learns the outcome on its next poll like every other holder fact.
199
+ /**
200
+ * WHICH CHECKOUT, WHICH PROCESS, AND SINCE WHEN (0.91.0) — the three facts
201
+ * that turn "a box polled" into a row somebody can act on.
202
+ *
203
+ * The owner could not answer a plain question about his own machines: "im not
204
+ * sure if i have any duplicate or redundant daemons running". The hostname
205
+ * alone cannot answer it either, because ONE box legitimately runs several
206
+ * daemons — "i can have 1 daemon in one directory, 1 in another as a
207
+ * differentiator of projects" — so `cp` is what makes two rows on one box
208
+ * legible, and `pid` + `st` are what let somebody standing at that box find
209
+ * the process and tell a long-lived daemon from one that has restarted nine
210
+ * times since they last looked.
211
+ *
212
+ * DAEMON→SERVER REPORTS, so no floor: an older daemon sends none of these and
213
+ * the server stores null, which reads as "it did not say" rather than as a
214
+ * box with no checkout. Bounded here as well as at the boundary — a query
215
+ * string is not a log, and this one is rendered into a terminal.
216
+ */
217
+ if (repoRoot) url.searchParams.set('cp', String(repoRoot).slice(0, 256));
218
+ url.searchParams.set('pid', String(process.pid));
219
+ url.searchParams.set('st', PROCESS_STARTED_AT);
220
+ /**
221
+ * THE FIRST POLL OF THIS PROCESS ASKS FOR THE MACHINE (`claim`, 0.91.0).
222
+ *
223
+ * The owner, on two boxes running one project: "it should kill the first one
224
+ * and take over." Starting the daemon on the computer in front of you IS the
225
+ * gesture — there is no flag, no env var and no third command, so the ruling
226
+ * that deleted the old machine-moving flag ("i dont intend to run or do
227
+ * anything in the terminal besides npx flowviant or npx flowviant login")
228
+ * stands untouched.
229
+ *
230
+ * SENT ONCE PER PROCESS, and that is the safety rather than a politeness: see
231
+ * `askForMachineOnce`. The server takes holdership there and then, the box it
232
+ * displaces learns through the existing named-and-windowed `displaced` signal
233
+ * and exits 0, and nothing restarts to ask again.
234
+ *
235
+ * AN OLDER SERVER IGNORES IT and the old standby behaviour stands, which is
236
+ * why this needs no floor of its own — the server applies one (it will not
237
+ * take a machine from a box too old to be told it lost it).
238
+ */
239
+ if (machineAskPending()) url.searchParams.set('claim', '1');
240
+ // A poll reports what this box IS. Moving the project's machine onto it, when
241
+ // this process has already spent its one ask above, is a gesture a person
242
+ // makes in the app — and the daemon learns that outcome on its next poll like
243
+ // every other holder fact.
138
244
  // Which shares this machine is still serving. It rides the poll rather than
139
245
  // taking an endpoint of its own: one beat, no floor, and the stale window is
140
246
  // the reconcile interval instead of minutes — which matters, because a share
@@ -305,6 +411,12 @@ async function fetchRoster(
305
411
  throw e;
306
412
  }
307
413
  if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
414
+ // THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
415
+ // answered. Spending it where the param is SET would lose the claim of a
416
+ // daemon that started while the wire was down: it would retry, silently
417
+ // without `claim`, and the box somebody walked over to and started would
418
+ // never take the machine.
419
+ spendMachineAsk();
308
420
  const body = await res.json();
309
421
  // Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
310
422
  // throws a NORMAL retryable error inside the loop's try/catch, instead of a
@@ -600,6 +712,34 @@ export function createHolderWatch({ say = () => {} } = {}) {
600
712
  return 'absent';
601
713
  }
602
714
  if (holder.mine === true) {
715
+ /**
716
+ * WE TOOK IT, AND THE SERVER SAID SO (0.91.0).
717
+ *
718
+ * Sent on the one poll that moved holdership and never again, so this
719
+ * is news by construction — no key to remember, no window to judge.
720
+ * RELAYED AND NEVER DERIVED: the box we displaced is named because the
721
+ * server named it, and an unnamed one gets the nameless fallback the
722
+ * rest of this file uses rather than a guess. The turns clause DROPS
723
+ * WHOLE when the count is absent — "0 turns were running" is a
724
+ * measurement nobody took, printed at the one moment somebody is
725
+ * deciding whether they have just interrupted their own work.
726
+ */
727
+ const took = holder.took;
728
+ if (took && typeof took === 'object' && !Array.isArray(took)) {
729
+ standbyKey = null;
730
+ const from =
731
+ typeof took.from === 'string' && took.from.trim()
732
+ ? took.from.trim().slice(0, 64)
733
+ : 'another machine';
734
+ const turns = Number.isInteger(took.turns) && took.turns > 0 ? took.turns : null;
735
+ say(
736
+ `took this project's machine from ${from}` +
737
+ (turns
738
+ ? ` — ${turns} turn${turns === 1 ? '' : 's'} ${turns === 1 ? 'was' : 'were'} running there and ${turns === 1 ? 'was' : 'were'} settled as moved.`
739
+ : '.')
740
+ );
741
+ return 'mine';
742
+ }
603
743
  if (standbyKey !== null) {
604
744
  standbyKey = null;
605
745
  say('this machine now serves the project.');
@@ -631,10 +771,28 @@ export function createHolderWatch({ say = () => {} } = {}) {
631
771
  // rather than a zero — because "heard 0s ago" is a measurement nobody
632
772
  // took, printed at the one moment the person is deciding whether to
633
773
  // wait.
774
+ //
775
+ // THE PROMISE IS GONE (0.91.0), and its absence is the honest half.
776
+ // This sentence used to end "It moves here automatically once that
777
+ // machine has been quiet 10 minutes", which was true while starting a
778
+ // daemon did nothing but wait. Since 0.91.0 a process START asks for
779
+ // the machine on its first poll — the owner's ruling, "it should kill
780
+ // the first one and take over" — so reaching this branch at all means
781
+ // the ask was REFUSED or never spent. Four ways: an older server that
782
+ // does not honour it; a daemon this server will not hand a machine to;
783
+ // an UNATTENDED RE-EXEC, which is born with the ask spent because a
784
+ // restart is not a person; and — the one worth naming — a box this
785
+ // credential DISPLACED inside the last ten minutes, which the server
786
+ // refuses so that two supervised daemons cannot trade a machine back
787
+ // and forth forever (see `takeMachineHolderNow`). In that last case the
788
+ // right next move is a decision, not a wait, which is exactly what this
789
+ // sentence already points at. Restating the staleness clock instead
790
+ // would promise a handover on the one path where it is least likely to
791
+ // be what happens. So the line relays the fact and points at the door,
792
+ // which is the same door it always pointed at.
634
793
  say(
635
794
  `This project's machine is ${name ?? 'another machine'}${ago ? ` (heard ${ago} ago)` : ''}. ` +
636
- 'It moves here automatically once that machine has been quiet 10 minutes ' +
637
- "— or move it now from the app's project settings."
795
+ "This one is standing by — move it here from the app's project settings."
638
796
  );
639
797
  }
640
798
  return 'standby';
@@ -1926,7 +2084,8 @@ export async function runFleetDaemon() {
1926
2084
  buildHave(),
1927
2085
  livePreviewIds(),
1928
2086
  heldSessionIds(),
1929
- admit('churn')
2087
+ admit('churn'),
2088
+ repoRoot
1930
2089
  );
1931
2090
  } catch (e) {
1932
2091
  if (e.auth) {
@@ -1960,6 +2119,35 @@ export async function runFleetDaemon() {
1960
2119
  // for a --fleet/env token (nothing stored to annotate).
1961
2120
  setStoredProjectName(roster.project.id, roster.project.name);
1962
2121
  }
2122
+ /**
2123
+ * WHAT ELSE IS SERVING THIS PROJECT — said ONCE, right after the first
2124
+ * poll lands, and never again (0.91.0).
2125
+ *
2126
+ * The owner could not answer a plain question about his own setup: "im
2127
+ * not sure if i have any duplicate or redundant daemons running". This is
2128
+ * the half of the answer that belongs in the terminal you just typed
2129
+ * into, because that is where you are standing when the question occurs
2130
+ * to you — the app's Home is where the whole account's answer lives.
2131
+ *
2132
+ * A RELAY, and it withholds nothing on failure: an older server has no
2133
+ * boxes route and 404s, which prints NOTHING rather than a complaint
2134
+ * about a feature nobody asked for. Fire-and-forget and never awaited on
2135
+ * the poll path — a listing must never delay a roster tick.
2136
+ */
2137
+ void (async () => {
2138
+ try {
2139
+ const { boxesUrlFrom, fetchBoxesFor, otherBoxesLine } = await import('./machines.mjs');
2140
+ const res = await fetchBoxesFor(
2141
+ { fleetToken: FLEET_TOKEN },
2142
+ { url: boxesUrlFrom(FLEET_URL), envpub: myPubB64() ?? undefined }
2143
+ );
2144
+ if (!res.boxes) return;
2145
+ const line = otherBoxesLine(res.boxes, res.me);
2146
+ if (line) note(line);
2147
+ } catch {
2148
+ /* a readout — it must never disturb the daemon that produced it */
2149
+ }
2150
+ })();
1963
2151
  }
1964
2152
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
1965
2153
  /**
@@ -0,0 +1,270 @@
1
+ /**
2
+ * `npx flowviant machines` — every project connected on THIS box, and every box
3
+ * that has polled each of them (0.91.0).
4
+ *
5
+ * ── WHY A THIRD COMMAND EXISTS AT ALL ──
6
+ *
7
+ * The terminal surface is `npx flowviant` and `npx flowviant login`, full stop.
8
+ * That is the owner's own ruling and it is what deleted the terminal flag that
9
+ * used to move a project's machine. This amends it BY HIS OWN REQUEST — "the CLI gets a command to list connections" —
10
+ * and it is amended in the narrowest way the ruling can survive: this command
11
+ * is VIEW-ONLY. It starts nothing, stops nothing, moves nothing and takes no
12
+ * lock; the one thing it can change is the local credential file, and only
13
+ * behind an explicit `--forget`. Every decision is still made in the app.
14
+ *
15
+ * The question it answers is the owner's, verbatim: "is there a way to view ALL
16
+ * the connected flowviants? because im not sure if i have any duplicate or
17
+ * redundant daemons running". Measured the day it was asked: four live daemons
18
+ * across two boxes, two credentials last heard three days ago, and two projects
19
+ * both named "BRIF AI" bound to the same checkout — which is why `npx
20
+ * flowviant` there offered two identical picker rows. THE PROJECT ID IS
21
+ * THEREFORE ALWAYS PRINTED: it is the only thing that tells those two apart,
22
+ * and the whole listing is worth less without it.
23
+ *
24
+ * ── IT IS A RELAY ──
25
+ *
26
+ * The role words (`serving`, `standing by`, `idle`) are the SERVER'S, computed
27
+ * in one place so this command, project settings and the account's Home block
28
+ * cannot disagree about what a box is doing. Nothing here re-derives a state, and
29
+ * a project the server could not answer for says so rather than rendering as
30
+ * empty.
31
+ *
32
+ * ── RENDERING IS PURE, FETCHING IS NOT ──
33
+ *
34
+ * `renderMachines` takes the entries and the results and returns lines, so
35
+ * every case that matters — rejected, unreachable, an older server, no boxes,
36
+ * behind, this box — is provable without a network, a credential or a daemon.
37
+ * The wiring above it is the thin part on purpose.
38
+ *
39
+ * It deliberately imports NOTHING from fleet.mjs: that module pulls the whole
40
+ * daemon (the CLIs, the worktree machinery, the preview tunnels) and this
41
+ * command runs before the auth gate, like `stop` and `projects`. The small
42
+ * duration formatter below is a second copy of a ten-line function, knowingly,
43
+ * and it says different words anyway — "heard 12s ago" against "last heard 3d
44
+ * ago", where the word LAST is the staleness mark.
45
+ */
46
+
47
+ import { USER_AGENT } from './config.mjs';
48
+
49
+ /** The boxes read, derived from the roster URL the way the diffstat post is —
50
+ * one place configures the API base and everything else is a suffix swap. */
51
+ export function boxesUrlFrom(fleetUrl) {
52
+ return String(fleetUrl).replace(/\/agents\/?$/, '/boxes');
53
+ }
54
+
55
+ /** The one answer `agoFrom` gives that is a PHRASE rather than a duration, so
56
+ * it is the one answer that must not take an " ago" after it. Named because
57
+ * two callers have to agree about it and a bare string literal is how they
58
+ * stopped agreeing. */
59
+ const JUST_NOW = 'just now';
60
+
61
+ /**
62
+ * A coarse age. Never "0s": a box heard a moment ago reads as "just now",
63
+ * because a daemon that polled four hundred milliseconds ago has not been
64
+ * silent for no time at all — the same rule `agoLabel` keeps in fleet.mjs and
65
+ * `elapsed` keeps in the web.
66
+ *
67
+ * NULL for anything unmeasurable, so the caller DROPS the clause rather than
68
+ * printing a duration nobody took.
69
+ */
70
+ export function agoFrom(iso, now = Date.now()) {
71
+ const t = Date.parse(iso ?? '');
72
+ if (Number.isNaN(t)) return null;
73
+ const ms = Math.max(0, now - t);
74
+ const s = Math.round(ms / 1000);
75
+ if (s < 5) return JUST_NOW;
76
+ if (s < 90) return `${s}s`;
77
+ const m = Math.round(s / 60);
78
+ if (m < 90) return `${m}m`;
79
+ const h = Math.round(m / 60);
80
+ if (h < 36) return `${h}h`;
81
+ return `${Math.round(h / 24)}d`;
82
+ }
83
+
84
+ /**
85
+ * WHEN THIS BOX WAS LAST HEARD FROM, as a whole phrase — and the phrase is one
86
+ * function because the two callers got it WRONG THE SAME WAY (2026-09-19, the
87
+ * review).
88
+ *
89
+ * `agoFrom` answers with a DURATION for everything except the newest case,
90
+ * where it answers with the words "just now" — and both callers appended
91
+ * " ago", so the most common row in the listing, a daemon that polled four
92
+ * hundred milliseconds ago, read "heard just now ago". On the serving box. On
93
+ * the first line anybody looks at.
94
+ *
95
+ * Stated once here rather than fixed twice: a duration takes the suffix, a
96
+ * phrase does not, and "LAST heard" is the staleness mark the whole listing
97
+ * turns on. An unmeasurable stamp says "never heard" rather than printing a
98
+ * duration nobody took.
99
+ */
100
+ export function heardPhrase(box, now = Date.now()) {
101
+ const ago = agoFrom(box?.lastHeardAt, now);
102
+ if (!ago) return 'never heard';
103
+ const lead = box?.fresh ? 'heard' : 'last heard';
104
+ return ago === JUST_NOW ? `${lead} ${ago}` : `${lead} ${ago} ago`;
105
+ }
106
+
107
+ /** `savedAt` as a date somebody can match against their own memory of setting
108
+ * it up. Absent stays absent — a credential stored before the field existed
109
+ * has no date, and inventing one would be the listing asserting a day. */
110
+ export function connectedOn(iso) {
111
+ const t = Date.parse(iso ?? '');
112
+ if (Number.isNaN(t)) return null;
113
+ return new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
114
+ }
115
+
116
+ /** The server's own role word, spelled for a terminal. Unknown values pass
117
+ * through untouched: this is a relay, and a role this build has never heard of
118
+ * is still the server's answer. */
119
+ function roleWord(role) {
120
+ return role === 'standing-by' ? 'standing by' : String(role ?? 'idle');
121
+ }
122
+
123
+ /** One box, as a line under its project. The MARK is the only derived thing on
124
+ * it — a filled dot for a box the server still calls fresh, hollow for one it
125
+ * does not — and the words beside it are all the server's or the box's own. */
126
+ export function renderBox(box, { me = null, latest = null } = {}, now = Date.now()) {
127
+ const mark = box.fresh ? '●' : '○';
128
+ const name = box.boxName || 'an unnamed machine';
129
+ // "LAST heard" is the staleness mark, and the row is never hidden for it —
130
+ // the owner's answer to "what about a box that stopped" was exactly this.
131
+ const heard = heardPhrase(box, now);
132
+ const cells = [
133
+ `${mark} ${name}`,
134
+ roleWord(box.role),
135
+ box.checkoutPath || '(checkout not reported)',
136
+ box.daemonVersion ? `v${box.daemonVersion}` : 'version not reported',
137
+ heard,
138
+ ];
139
+ let line = ` ${cells.join(' ')}`;
140
+ if (me && box.boxId === me) line += ' ← this box';
141
+ // Said only when the server said it, and it names the version that is
142
+ // actually published rather than a feature floor — a floor may sit above
143
+ // LATEST on purpose, and an instruction to install something npm does not
144
+ // serve is worse than silence.
145
+ if (box.behind && latest) line += ` · behind, ${latest} is latest`;
146
+ return line;
147
+ }
148
+
149
+ /**
150
+ * THE WHOLE LISTING, as lines. Pure over (entries, results, now).
151
+ *
152
+ * `results` is keyed by projectId and each value is one of:
153
+ * { boxes, latest, me } the server answered
154
+ * { rejected: true } 401/403 — the credential is dead here
155
+ * { unsupported: true } 404 — an older server with no boxes route
156
+ * { error: '<words>' } anything else, relayed rather than summarised
157
+ * A project with no entry in `results` is treated as unreachable, because that
158
+ * is the only thing an absent answer can honestly mean.
159
+ */
160
+ export function renderMachines(entries, results, { now = Date.now() } = {}) {
161
+ const lines = [];
162
+ // WHICH LABELS COLLIDE. Two projects genuinely called the same thing is the
163
+ // case that produced this command, so the id carries it — and it is printed
164
+ // on EVERY row rather than only the colliding ones, because a reader who has
165
+ // to notice a suffix to know whether it matters has already been asked to do
166
+ // the work this listing is for.
167
+ for (const e of entries) {
168
+ const label = e.name || `project ${e.projectId.slice(0, 8)}…`;
169
+ const connected = connectedOn(e.savedAt);
170
+ const id = `(id ${e.projectId.slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
171
+ lines.push(
172
+ ` ${label} · ${e.repoRoot || 'not bound to a repo yet'} ${id}`
173
+ );
174
+ const r = results?.[e.projectId];
175
+ if (!r || r.error) {
176
+ // The failure is RELAYED, not summarised, and the loop carries on: one
177
+ // unreachable project must not cost you the listing for the others.
178
+ lines.push(` could not ask the server — ${r?.error ?? 'no answer'}`);
179
+ continue;
180
+ }
181
+ if (r.rejected) {
182
+ lines.push(
183
+ ' credential rejected — this project was disconnected or deleted. ' +
184
+ `\`flowviant machines --forget ${e.projectId.slice(0, 8)}\` removes it here.`
185
+ );
186
+ continue;
187
+ }
188
+ if (r.unsupported) {
189
+ lines.push(' server does not report machines yet');
190
+ continue;
191
+ }
192
+ const boxes = Array.isArray(r.boxes) ? r.boxes : [];
193
+ if (boxes.length === 0) {
194
+ // NOT "no machines". Nothing has polled this credential, which is a
195
+ // different fact from a project whose machines have all gone quiet — and
196
+ // a quiet one still has rows, wearing its last-heard.
197
+ lines.push(' (no machine has polled this project)');
198
+ continue;
199
+ }
200
+ for (const b of boxes) lines.push(renderBox(b, { me: r.me, latest: r.latest }, now));
201
+ }
202
+ return lines;
203
+ }
204
+
205
+ /**
206
+ * THE FOOTER. Two sentences, both of which exist because this listing is the
207
+ * moment somebody is confused — the same reason `flowviant projects` names its
208
+ * remedies inline rather than in a manual.
209
+ *
210
+ * The first says where the verbs are, because there are none here. The second
211
+ * states this command's own SCOPE, which is the one thing a reader cannot see
212
+ * from the output: it lists what is connected on THIS box, and a daemon running
213
+ * on a computer that has never met this one is invisible to it. The app's Home
214
+ * is where "all of them" lives, and pretending otherwise would be this listing
215
+ * quietly answering a narrower question than the one asked.
216
+ */
217
+ export const MACHINES_FOOTER = [
218
+ ' stop a machine from the app: project settings → Machine → Disconnect;',
219
+ ' a new `npx flowviant` on another box takes the project over.',
220
+ ' this lists the projects connected on THIS box; the app’s Home lists every machine on your account.',
221
+ ];
222
+
223
+ /**
224
+ * Ask one credential for its boxes. Never throws: every outcome is a shape the
225
+ * renderer knows, because one dead project must not end the listing.
226
+ *
227
+ * A 404 is an older SERVER rather than a missing project — the route is new —
228
+ * and it is kept apart from a 401, which is a credential this box should
229
+ * probably forget. Two different next moves; collapsing them is how somebody
230
+ * deletes a working credential.
231
+ */
232
+ export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch } = {}) {
233
+ const target = new URL(url);
234
+ if (envpub) target.searchParams.set('envpub', envpub);
235
+ try {
236
+ const res = await fetchImpl(target, {
237
+ headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
238
+ signal: AbortSignal.timeout(15_000),
239
+ });
240
+ if (res.status === 401 || res.status === 403) return { rejected: true };
241
+ if (res.status === 404) return { unsupported: true };
242
+ if (!res.ok) return { error: `HTTP ${res.status}` };
243
+ const body = await res.json();
244
+ const data = body?.data;
245
+ if (!data || !Array.isArray(data.boxes)) return { error: 'unexpected answer shape' };
246
+ return { boxes: data.boxes, latest: data.latest ?? null, me: data.me ?? null };
247
+ } catch (e) {
248
+ return { error: String(e?.message ?? e) };
249
+ }
250
+ }
251
+
252
+ /**
253
+ * THE OTHER BOXES ON THIS PROJECT, as ONE line for the daemon's startup — or
254
+ * null when there are none, which is the common case and must print nothing.
255
+ *
256
+ * Said ONCE per process, after the first successful poll, and never per poll:
257
+ * the fact only changes when somebody starts a daemon somewhere else, and a
258
+ * true sentence restated every ten seconds is a scrolling console nobody reads.
259
+ * THE CALLING BOX IS EXCLUDED — it is the one box the person at that terminal
260
+ * does not need to be told about.
261
+ */
262
+ export function otherBoxesLine(boxes, me, now = Date.now()) {
263
+ const others = (Array.isArray(boxes) ? boxes : []).filter((b) => b && b.boxId !== me);
264
+ if (others.length === 0) return null;
265
+ const parts = others.slice(0, 6).map(
266
+ (b) => `${b.boxName || 'an unnamed machine'} (${roleWord(b.role)} · ${heardPhrase(b, now)})`
267
+ );
268
+ const more = others.length - parts.length;
269
+ return `other machines on this project: ${parts.join(', ')}${more > 0 ? `, and ${more} more` : ''}`;
270
+ }
package/bin/lib/tty.mjs CHANGED
@@ -19,13 +19,29 @@
19
19
  *
20
20
  * 2. `askWithTimeout()` — while asking, install no-op SIGTTIN/SIGTTOU handlers.
21
21
  * A handler (even an empty one) replaces the default STOP, so a misjudged
22
- * foreground check degrades to a read that fails or hangs — and a hang is
23
- * something the timer can now actually interrupt, because the process is
24
- * still running.
22
+ * foreground check degrades to a read that fails rather than a process
23
+ * that is frozen.
25
24
  *
26
25
  * The detection is best-effort by design and FAILS TOWARDS ASKING: an unknown
27
26
  * platform returns true, because refusing to prompt a human who IS there is a
28
- * worse failure than a prompt that times out on its own.
27
+ * worse failure than a prompt that waits.
28
+ *
29
+ * A PROMPT A PERSON IS LOOKING AT WAITS FOR THE PERSON (2026-09-20). Both
30
+ * helpers used to carry a mandatory timer — 20s on the binding confirm, 60s
31
+ * on the project picker — on the argument that "anything longer is a restart
32
+ * nobody is watching, and the machine must not sit dark for it". That
33
+ * argument was already answered one layer up: the start path never asks at
34
+ * all unless `canPrompt()` says a foreground terminal is attached AND the
35
+ * process is not a self-update re-exec, so the unattended restart the timer
36
+ * was defending against cannot reach a prompt. What the timer actually did
37
+ * was refuse a person who went to read the list, make a coffee, or answer a
38
+ * message — "no answer in 60s — nothing started" — on the one path where
39
+ * nothing has been started yet and waiting costs nothing. The owner's ruling,
40
+ * verbatim: "why is there an answer time limit, remove that." So `timeoutMs`
41
+ * is OPTIONAL now and the start path passes none; a caller that has a real
42
+ * reason to bound a wait (the install prompt, where silence is "no") may
43
+ * still pass one. The signal handlers stay — they were never about the timer,
44
+ * they are what keeps a backgrounded job from freezing.
29
45
  */
30
46
 
31
47
  import { readFileSync } from 'node:fs';
@@ -80,11 +96,21 @@ export function canPrompt() {
80
96
  return Boolean(process.stdin.isTTY && process.stdout.isTTY) && inForeground();
81
97
  }
82
98
 
99
+ /** A timer only when a caller asked for one. `clearTimeout(null)` is a no-op,
100
+ * so the finally blocks need no branch. Non-finite or non-positive means
101
+ * "wait for the person". */
102
+ export function boundedTimer(timeoutMs, fire) {
103
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(fire, timeoutMs) : null;
104
+ }
105
+
83
106
  /**
84
- * Ask, and come back no matter what. Resolves the trimmed answer, or `null`
85
- * when nobody answered within `timeoutMs` — the caller decides what silence
86
- * means, because it is not the same answer everywhere (the binding confirm
87
- * serves unbound; the project picker refuses, exactly as it does headless).
107
+ * Ask, and come back with an answer or a refusal. Resolves the trimmed
108
+ * answer, or `null` when the read failed (stdin closed under us, the terminal
109
+ * went away) or — only when a caller passed a finite `timeoutMs` — when nobody
110
+ * answered in time. With no `timeoutMs` it waits for the person; see the
111
+ * header. The caller decides what `null` means, because it is not the same
112
+ * answer everywhere (the binding confirm serves unbound; the project picker
113
+ * refuses, exactly as it does headless).
88
114
  */
89
115
  export async function askWithTimeout(query, timeoutMs) {
90
116
  const noop = () => {};
@@ -97,7 +123,7 @@ export async function askWithTimeout(query, timeoutMs) {
97
123
  output: process.stdout,
98
124
  });
99
125
  const ac = new AbortController();
100
- const timer = setTimeout(() => ac.abort(), timeoutMs);
126
+ const timer = boundedTimer(timeoutMs, () => ac.abort());
101
127
  try {
102
128
  return (await rl.question(query, { signal: ac.signal })).trim();
103
129
  } catch {
@@ -153,15 +179,15 @@ const MENU_FOOTER = '↑/↓ move · enter select · 1–9 jump · esc cancel';
153
179
  * An arrow-navigable picker for the start path. Resolves one of:
154
180
  * { index } the row taken
155
181
  * { cancelled } Esc/q/Ctrl-C
156
- * { timedOut } nobody drove it within timeoutMs
182
+ * { timedOut } a caller passed `timeoutMs` and nobody drove it in time
183
+ * (the start path passes none — see the header)
157
184
  * { unsupported } no raw mode — the caller uses the numeric prompt instead
158
185
  *
159
- * Held to this file's one law — no start-path prompt may hang the daemon — the
160
- * same way `askWithTimeout` is: it keeps the timeout, installs the
161
- * SIGTTIN/SIGTTOU no-ops that keep that timer alive, restores the terminal in
162
- * every exit, and refuses (returns `unsupported`) rather than half-drawing when
163
- * raw mode is not really there. The caller still gates on `canPrompt()` before
164
- * ever reaching here.
186
+ * Held to this file's one law — no start-path prompt may STOP the daemon — the
187
+ * same way `askWithTimeout` is: it installs the SIGTTIN/SIGTTOU no-ops,
188
+ * restores the terminal in every exit, and refuses (returns `unsupported`)
189
+ * rather than half-drawing when raw mode is not really there. The caller still
190
+ * gates on `canPrompt()` before ever reaching here.
165
191
  */
166
192
  export async function selectMenu({ options, defaultIndex = 0, timeoutMs }) {
167
193
  if (!menuSupported()) return { unsupported: true };
@@ -220,7 +246,7 @@ export async function selectMenu({ options, defaultIndex = 0, timeoutMs }) {
220
246
  };
221
247
  process.on('SIGTTIN', noop);
222
248
  process.on('SIGTTOU', noop);
223
- const timer = setTimeout(() => finish({ timedOut: true }), timeoutMs);
249
+ const timer = boundedTimer(timeoutMs, () => finish({ timedOut: true }));
224
250
  try {
225
251
  stdin.setRawMode(true);
226
252
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.90.0",
3
+ "version": "0.91.1",
4
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": {