flowviant 0.55.1 → 0.55.3

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
@@ -237,8 +237,22 @@ if (process.argv[2] === 'env') {
237
237
  // auto-updated daemon's child sees two TTYs; without this it would stop on the
238
238
  // binding confirm below and the machine would stay dark until somebody typed a
239
239
  // key. Same reasoning as the headless case, and the same answer.
240
- const interactive =
241
- Boolean(process.stdin.isTTY && process.stdout.isTTY) && process.env.FLOWVIANT_REEXEC !== '1';
240
+ // `canPrompt()`, not a bare isTTY pair: a BACKGROUNDED job (`flowviant &`) has
241
+ // two TTYs and cannot be asked anything — the first read raises SIGTTIN and the
242
+ // kernel STOPS the process, which is why 0.55.2's timeout did not save it (a
243
+ // stopped process runs no timers). See tty.mjs.
244
+ const { canPrompt, askWithTimeout } = await import('./lib/tty.mjs');
245
+ const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
246
+
247
+ /** How long the one-time binding confirm waits before serving unbound. A person
248
+ * who just typed `flowviant` answers in seconds; anything longer is a restart
249
+ * nobody is watching, and the machine must not sit dark for it. */
250
+ const CONFIRM_TIMEOUT_MS = 20_000;
251
+
252
+ /** The picker's own budget. Longer than the confirm: this one asks you to READ
253
+ * a list before answering, and its fallback costs you a start rather than
254
+ * costing you a binding. */
255
+ const PICK_TIMEOUT_MS = 60_000;
242
256
  const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
243
257
 
244
258
  /** Re-exec a plain `flowviant` after an inline login — the login command's own
@@ -278,12 +292,22 @@ if (!FLEET_TOKEN) {
278
292
  );
279
293
  console.log(listLines(choices, creds));
280
294
  console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
281
- const rl = (await import('node:readline/promises')).createInterface({
282
- input: process.stdin,
283
- output: process.stdout,
284
- });
285
- const raw = (await rl.question(`Which project should this daemon serve? [1-${choices.length + 1}] `)).trim();
286
- rl.close();
295
+ // Bounded like the confirm below, and for the same reason — but silence
296
+ // means something DIFFERENT here and the difference is load-bearing. There
297
+ // is a real ambiguity to resolve; serving a guess is the skadooble bug.
298
+ // So no answer REFUSES, which is exactly what this branch already does
299
+ // headless, and the message says how to answer without being present.
300
+ const raw = await askWithTimeout(
301
+ `Which project should this daemon serve? [1-${choices.length + 1}] `,
302
+ PICK_TIMEOUT_MS
303
+ );
304
+ if (raw === null) {
305
+ console.error(
306
+ `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
307
+ `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
308
+ );
309
+ process.exit(1);
310
+ }
287
311
  const n = Number.parseInt(raw, 10);
288
312
  if (n === choices.length + 1) await reexecAfterLogin();
289
313
  const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
@@ -321,17 +345,37 @@ if (!FLEET_TOKEN) {
321
345
  // ONE stored project, never tied to a repo — the pre-0.55.0 world. Ask once;
322
346
  // yes binds and every later start is silent. This is the exact question
323
347
  // whose absence had a calendar checkout serving skadooble.
348
+ //
349
+ // AND IT TIMES OUT, because a prompt on a start path is a way for a machine
350
+ // to go dark. `FLOWVIANT_REEXEC` above covers the restart THIS version
351
+ // performs, but it cannot cover the one that matters most: the hop that
352
+ // installs a fixed daemon is spawned by the OLD one, which never sets it.
353
+ // 0.55.0 → 0.55.1 was exactly that — an auto-update landing unattended would
354
+ // stop here with the machine serving nothing. A guard that only works once
355
+ // everyone already has it is not a guard.
356
+ //
357
+ // ON TIMEOUT WE SERVE, AND WE DO NOT BIND. Those are two decisions:
358
+ // · SERVE, because it is what every version before 0.55.0 did with this
359
+ // exact store, so the silent path is the status quo rather than a new
360
+ // risk — and a daemon that answers is strictly better than one that does
361
+ // not, which is the whole reason this product has exactly one refusal.
362
+ // · DO NOT BIND, because binding is the thing the question was FOR. Nobody
363
+ // answered, so nothing is cemented; the next human start asks again. That
364
+ // keeps the skadooble case fixed for the person who is actually looking,
365
+ // which is the only person it could ever have been fixed for.
324
366
  const creds = await import('./lib/credentials.mjs');
325
367
  const label = creds.projectLabel(CREDENTIAL.entry);
326
- const rl = (await import('node:readline/promises')).createInterface({
327
- input: process.stdin,
328
- output: process.stdout,
329
- });
330
- const raw = (
331
- await rl.question(`This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `)
332
- ).trim().toLowerCase();
333
- rl.close();
334
- if (raw === '' || raw === 'y' || raw === 'yes') {
368
+ const answered = await askWithTimeout(
369
+ `This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `,
370
+ CONFIRM_TIMEOUT_MS
371
+ );
372
+ const raw = answered === null ? null : answered.toLowerCase(); // null = nobody answered
373
+ if (raw === null) {
374
+ console.log(
375
+ `\n no answer in ${Math.round(CONFIRM_TIMEOUT_MS / 1000)}s — serving ${label} for this run ` +
376
+ `without tying it to this repo. Run \`flowviant\` here and answer to make it stick.`
377
+ );
378
+ } else if (raw === '' || raw === 'y' || raw === 'yes') {
335
379
  creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
336
380
  } else {
337
381
  console.error(
@@ -27,17 +27,23 @@ export function addLocalBinToPath() {
27
27
  }
28
28
  }
29
29
 
30
- /** TTY-guarded y/N. Non-interactive (no TTY) never auto-installs → returns false
31
- * so a headless/cron run just prints the manual instructions instead. */
30
+ /** TTY-guarded y/N. Non-interactive never auto-installs → returns false so a
31
+ * headless/cron run just prints the manual instructions instead.
32
+ *
33
+ * `canPrompt()` rather than `stdin.isTTY`, and BOUNDED: preflight runs before
34
+ * the daemon serves anything, so a question here that cannot be answered is a
35
+ * machine that never starts. A backgrounded job has a TTY and cannot be asked
36
+ * — the read raises SIGTTIN and the kernel stops the process. Silence is `no`,
37
+ * which is already what this returns when nobody is there. */
32
38
  export async function promptYesNo(question, defaultYes) {
33
- if (!process.stdin.isTTY) return false;
34
- const { createInterface } = await import('node:readline');
35
- const rl = createInterface({ input: process.stdin, output: process.stdout });
36
- const answer = await new Promise((res) =>
37
- rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `, res),
39
+ const { canPrompt, askWithTimeout } = await import('./tty.mjs');
40
+ if (!canPrompt()) return false;
41
+ const answer = await askWithTimeout(
42
+ `${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `,
43
+ 30_000
38
44
  );
39
- rl.close();
40
- const a = answer.trim().toLowerCase();
45
+ if (answer === null) return false;
46
+ const a = answer.toLowerCase();
41
47
  if (!a) return defaultYes;
42
48
  return a === 'y' || a === 'yes';
43
49
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * NO PROMPT ON THE START PATH MAY STOP THE DAEMON.
3
+ *
4
+ * This exists because 0.55.2 added a 20-second timeout to the repo-binding
5
+ * confirm and it did not work in the one case that mattered most. `flowviant &`
6
+ * from an interactive shell puts the process in a background process group; the
7
+ * first TTY read raises SIGTTIN (and a TTY write SIGTTOU), whose DEFAULT
8
+ * disposition is to STOP the process. A stopped process runs no timers, so the
9
+ * AbortController never fires — the guard was on the wrong side of the thing it
10
+ * was guarding against. The shell prints `[1]+ Stopped` and nothing else: no
11
+ * banner, no error, no poll, forever, and `bg` does not rescue it.
12
+ *
13
+ * Two independent defences, because either one alone has a hole:
14
+ *
15
+ * 1. `canPrompt()` — do not ask at all unless we are the terminal's FOREGROUND
16
+ * process group. `stdin.isTTY` is true for a backgrounded job, so it cannot
17
+ * answer this on its own; the foreground group is what actually decides
18
+ * whether a read will succeed or be signalled.
19
+ *
20
+ * 2. `askWithTimeout()` — while asking, install no-op SIGTTIN/SIGTTOU handlers.
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.
25
+ *
26
+ * The detection is best-effort by design and FAILS TOWARDS ASKING: an unknown
27
+ * 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.
29
+ */
30
+
31
+ import { readFileSync } from 'node:fs';
32
+ import { execFileSync } from 'node:child_process';
33
+
34
+ /**
35
+ * Is this process in the controlling terminal's foreground process group?
36
+ *
37
+ * Linux: BOTH numbers come out of one `/proc/self/stat` read — field 5 is our
38
+ * own `pgrp` and field 8 is `tpgid`, the foreground group of our controlling
39
+ * terminal. Parsed from AFTER the last ')' because field 2 is the executable
40
+ * name and may itself contain spaces and parentheses.
41
+ *
42
+ * NOT `process.getpgrp()`: it DOES NOT EXIST in Node (verified on 24.16 — it
43
+ * throws `TypeError: process.getpgrp is not a function`). The first cut of this
44
+ * file called it, the throw was swallowed by the fail-open catch below, and the
45
+ * function therefore returned `true` unconditionally — a detector that always
46
+ * says "yes, a human is here" is not a detector, and only the signal handlers
47
+ * in `askWithTimeout` were doing any work. Caught by probing this function in
48
+ * isolation on a real pty; nothing else would have shown it, because the
49
+ * fallback it degraded to still behaves acceptably.
50
+ *
51
+ * Elsewhere: ask `ps` for both. Unknown: assume foreground (see the header).
52
+ */
53
+ export function inForeground() {
54
+ try {
55
+ if (process.platform === 'linux') {
56
+ const stat = readFileSync('/proc/self/stat', 'utf8');
57
+ const after = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/);
58
+ const pgrp = Number(after[2]); // state ppid PGRP session tty_nr tpgid
59
+ const tpgid = Number(after[5]);
60
+ if (!Number.isFinite(tpgid) || !Number.isFinite(pgrp)) return true;
61
+ if (tpgid <= 0) return true; // no controlling terminal — nothing to be behind
62
+ return tpgid === pgrp;
63
+ }
64
+ const out = execFileSync('ps', ['-o', 'tpgid=,pgid=', '-p', String(process.pid)], {
65
+ encoding: 'utf8',
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ timeout: 2000,
68
+ }).trim().split(/\s+/);
69
+ const tpgid = Number(out[0]);
70
+ const pgrp = Number(out[1]);
71
+ if (!Number.isFinite(tpgid) || !Number.isFinite(pgrp) || tpgid <= 0) return true;
72
+ return tpgid === pgrp;
73
+ } catch {
74
+ return true;
75
+ }
76
+ }
77
+
78
+ /** A human is at this terminal AND can actually be reached by a question. */
79
+ export function canPrompt() {
80
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY) && inForeground();
81
+ }
82
+
83
+ /**
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).
88
+ */
89
+ export async function askWithTimeout(query, timeoutMs) {
90
+ const noop = () => {};
91
+ // Replacing the DEFAULT disposition is the whole point — an empty handler is
92
+ // enough, and it is what keeps the timer below able to run at all.
93
+ process.on('SIGTTIN', noop);
94
+ process.on('SIGTTOU', noop);
95
+ const rl = (await import('node:readline/promises')).createInterface({
96
+ input: process.stdin,
97
+ output: process.stdout,
98
+ });
99
+ const ac = new AbortController();
100
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
101
+ try {
102
+ return (await rl.question(query, { signal: ac.signal })).trim();
103
+ } catch {
104
+ return null; // aborted, or the read failed because we are not in front
105
+ } finally {
106
+ clearTimeout(timer);
107
+ rl.close();
108
+ process.off('SIGTTIN', noop);
109
+ process.off('SIGTTOU', noop);
110
+ }
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.55.1",
3
+ "version": "0.55.3",
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": {