flowviant 0.71.1 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,7 +51,7 @@ Nothing starts work except you opening a tab and typing in it.
51
51
 
52
52
  ## Sharing a preview
53
53
 
54
- You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed) behind a **mandatory password gate**. Flowviant stores only the tunnel URL; your browser talks to it directly.
54
+ You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed) behind a **mandatory password gate**. Flowviant stores only the tunnel URL; your browser talks to it directly. Since 0.72.0 the gate also lets the Workbench embed the share in a frame: it rewrites the response's frame policy to permit exactly the app's origin, and a framed sign-in gets a partitioned cookie so it works where third-party cookies are blocked.
55
55
 
56
56
  The daemon never executes anything the repository declares. An earlier version read a `.flowviant/preview.json` from the branch and spawned the command it named — that start path was removed in 0.53.0 and is not coming back; see the header of `bin/lib/preview.mjs` for exactly what it did, so nobody rebuilds it.
57
57
 
package/bin/cli.mjs CHANGED
@@ -244,7 +244,7 @@ if (process.argv[2] === 'env') {
244
244
  // two TTYs and cannot be asked anything — the first read raises SIGTTIN and the
245
245
  // kernel STOPS the process, which is why 0.55.2's timeout did not save it (a
246
246
  // stopped process runs no timers). See tty.mjs.
247
- const { canPrompt, askWithTimeout } = await import('./lib/tty.mjs');
247
+ const { canPrompt, askWithTimeout, selectMenu, menuSupported } = await import('./lib/tty.mjs');
248
248
  const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
249
249
 
250
250
  /** How long the one-time binding confirm waits before serving unbound. A person
@@ -285,6 +285,8 @@ if (!FLEET_TOKEN) {
285
285
  }
286
286
  if (CREDENTIAL.choices?.length && interactive) {
287
287
  const creds = await import('./lib/credentials.mjs');
288
+ const { originSlug } = await import('./lib/git.mjs');
289
+ const { basename } = await import('node:path');
288
290
  const { choices, repoRoot } = CREDENTIAL;
289
291
  console.log(
290
292
  CREDENTIAL.reason === 'outside-repo'
@@ -293,27 +295,78 @@ if (!FLEET_TOKEN) {
293
295
  ? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
294
296
  : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
295
297
  );
296
- console.log(listLines(choices, creds));
297
- console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
298
+
299
+ const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`;
300
+ // WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
301
+ // refuses to serve a project the repo PATH did not name (the skadooble law);
302
+ // this only decides which row the cursor starts on, using the repo's folder
303
+ // name and its github repo-name against the stored project names. A unique
304
+ // match becomes ONE keypress; a wrong guess costs nothing, because the human
305
+ // still confirms. `multiple-bound` gets no hint — every choice already names
306
+ // this repo, so nothing distinguishes them.
307
+ const slug = repoRoot ? originSlug(repoRoot) : null;
308
+ const likely =
309
+ CREDENTIAL.reason === 'multiple-bound'
310
+ ? -1
311
+ : creds.likelyChoiceIndex(choices, {
312
+ repoBasename: repoRoot ? basename(repoRoot) : null,
313
+ repoSlugName: slug ? slug.split('/')[1] : null,
314
+ });
315
+
298
316
  // Bounded like the confirm below, and for the same reason — but silence
299
317
  // means something DIFFERENT here and the difference is load-bearing. There
300
318
  // is a real ambiguity to resolve; serving a guess is the skadooble bug.
301
319
  // So no answer REFUSES, which is exactly what this branch already does
302
320
  // headless, and the message says how to answer without being present.
303
- const raw = await askWithTimeout(
304
- `Which project should this daemon serve? [1-${choices.length + 1}] `,
305
- PICK_TIMEOUT_MS
306
- );
307
- if (raw === null) {
308
- console.error(
309
- `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s nothing started. ` +
310
- `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
321
+ let chosen = null; // 0-based into [...choices, login]
322
+ if (menuSupported()) {
323
+ const rowLabel = (e) =>
324
+ creds.projectLabel(e) + (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet');
325
+ const options = [...choices.map(rowLabel), loginLabel];
326
+ // Say WHY the cursor starts where it does — "intuitive" made visible.
327
+ if (likely >= 0) options[likely] += ' ← looks like this repo';
328
+ const res = await selectMenu({
329
+ options,
330
+ defaultIndex: likely >= 0 ? likely : 0,
331
+ timeoutMs: PICK_TIMEOUT_MS,
332
+ });
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
+ if (res.cancelled) {
341
+ console.error('nothing chosen — nothing started.');
342
+ process.exit(1);
343
+ }
344
+ if (!res.unsupported) chosen = res.index;
345
+ }
346
+ if (chosen === null) {
347
+ // Numeric fallback — a pipe, or a terminal without raw mode. Empty Enter
348
+ // takes the likely default when there is one, so it is one keystroke here
349
+ // too.
350
+ console.log(listLines(choices, creds));
351
+ console.log(` ${choices.length + 1}. ${loginLabel}`);
352
+ const hint = likely >= 0 ? ` (enter for ${creds.projectLabel(choices[likely])})` : '';
353
+ const raw = await askWithTimeout(
354
+ `Which project should this daemon serve? [1-${choices.length + 1}]${hint} `,
355
+ PICK_TIMEOUT_MS
311
356
  );
312
- process.exit(1);
357
+ if (raw === null) {
358
+ console.error(
359
+ `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
360
+ `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
361
+ );
362
+ process.exit(1);
363
+ }
364
+ const n = raw === '' && likely >= 0 ? likely + 1 : Number.parseInt(raw, 10);
365
+ chosen = Number.isInteger(n) ? n - 1 : -1;
313
366
  }
314
- const n = Number.parseInt(raw, 10);
315
- if (n === choices.length + 1) await reexecAfterLogin();
316
- const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
367
+
368
+ if (chosen === choices.length) await reexecAfterLogin();
369
+ const picked = chosen >= 0 ? choices[chosen] : undefined;
317
370
  if (!picked) {
318
371
  console.error('nothing chosen — nothing started.');
319
372
  process.exit(1);
@@ -32,10 +32,45 @@
32
32
  * - NEVER 302 A WEBSOCKET UPGRADE. Browsers do not follow 3xx on an upgrade,
33
33
  * they fail the connection — HMR would break in a way that looks like a dead
34
34
  * dev server. A cookie-less upgrade stays a 401.
35
- * - `SameSite=Lax` is chosen, so this gate CANNOT be embedded in a
36
- * cross-site iframe. The Workbench must not try; making it work would mean
37
- * `SameSite=None`, which is a different security posture and a conscious
38
- * re-argument, not a quiet flag change.
35
+ * - THE FRAME IS THE APP'S AND ONLY THE APP'S (0.72.0, and this reverses the
36
+ * old "cannot be embedded, the Workbench must not try" rule deliberately
37
+ * the Workbench now DOES frame the share, so both halves are re-argued
38
+ * here). Two changes, one per blocker:
39
+ * (a) THE COOKIE. `SameSite=Lax` is kept for a top-level visit — it is the
40
+ * CSRF boundary and works in every browser ever shipped. A FRAMED
41
+ * navigation (`Sec-Fetch-Dest: iframe`) instead gets `SameSite=None;
42
+ * Partitioned` (CHIPS): partitioning keys the cookie to the top-level
43
+ * site that framed it, so evil.com framing this hostname gets its OWN
44
+ * empty jar, never the viewer's grant — which is why None here is not
45
+ * the CSRF hole it would be unpartitioned. Branching on Sec-Fetch-Dest
46
+ * rather than always sending both attributes matters: Safari 18.5–26.1
47
+ * DROPPED any Set-Cookie carrying `Partitioned` outright, so stamping it
48
+ * unconditionally would have broken the working top-level path on those
49
+ * builds. A browser too old to send Sec-Fetch-Dest gets Lax and the
50
+ * frame fails exactly as it always did — Open remains the escape.
51
+ * AND THE PARTITIONING CLAIM HAS A BACKSTOP, because one browser class
52
+ * breaks it: Chromium 76–113 (and same-vintage webviews/forks) sends
53
+ * Sec-Fetch-Dest — so it takes this branch — while ignoring the
54
+ * `Partitioned` attribute it has never heard of, storing a PLAIN
55
+ * unpartitioned SameSite=None cookie: the exact CSRF hole the sentence
56
+ * above says partitioning prevents. So `crossSiteAbuse` refuses a
57
+ * GRANT-authenticated request that Fetch Metadata marks cross-site
58
+ * unless it is a top-level GET/HEAD navigation (the Open link, the
59
+ * frame's own src). Every browser in the vulnerable class sends
60
+ * Sec-Fetch-Site (it shipped alongside Dest), a browser sending
61
+ * neither never got a None cookie in the first place, and the password
62
+ * path is exempt — Basic auth is never attached cross-site by a
63
+ * browser, so curl and Playwright feel nothing.
64
+ * (b) THE FRAME POLICY. In grant mode the forwarded response's frame policy
65
+ * is REWRITTEN to `frame-ancestors 'self' <app origin>` (origin taken
66
+ * from `authorizeUrl`, the one app fact this gate already holds). That
67
+ * permits exactly ONE cross-origin framer — the app the viewer is
68
+ * already authenticated to — instead of switching clickjacking
69
+ * protection off for everyone; enforced `frame-ancestors` outranks
70
+ * `X-Frame-Options` in every current engine, and the stripped XFO is
71
+ * the belt-and-braces for the rest. Password-only mode rewrites
72
+ * NOTHING: there is no app origin to allow and no cookie that could
73
+ * authenticate a frame, so the origin's own policy stands verbatim.
39
74
  *
40
75
  * Three things this file gets wrong easily, all of them fixed here and all of
41
76
  * them worth keeping fixed:
@@ -155,6 +190,95 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
155
190
 
156
191
  const authed = (req) => credential(req) === 'ok';
157
192
 
193
+ /**
194
+ * The CSRF backstop for the partitioned cookie (header rule 4a): a browser
195
+ * old enough to ignore `Partitioned` while honouring `SameSite=None` will
196
+ * attach the grant to cross-site requests, so a request that FETCH METADATA
197
+ * says is cross-site is refused unless it is a plain navigation GET/HEAD —
198
+ * the two shapes this product legitimately serves cross-site (the Open
199
+ * link, the Workbench frame's own src; a rendered attacker frame is then
200
+ * stopped by the frame-ancestors rewrite). Applies ONLY to grant-cookie
201
+ * auth: a password in an Authorization header is never attached cross-site
202
+ * by a browser, and clients that send no Sec-Fetch headers never held a
203
+ * None cookie, so absence stays permitted.
204
+ */
205
+ const crossSiteAbuse = (req) => {
206
+ if (String(req.headers['sec-fetch-site'] || '').toLowerCase() !== 'cross-site') return false;
207
+ const mode = String(req.headers['sec-fetch-mode'] || '').toLowerCase();
208
+ // Cross-site fetch/XHR/img/script with the cookie — nothing legitimate
209
+ // looks like this: the framed app's own subresources are same-origin.
210
+ if (mode && mode !== 'navigate') return true;
211
+ // A cross-site POST navigation is the classic auto-submitted CSRF form.
212
+ return !(req.method === 'GET' || req.method === 'HEAD');
213
+ };
214
+
215
+ /** True when THIS request authenticated with the password rather than the
216
+ * cookie — the credential class CSRF cannot ride. */
217
+ const viaPassword = (req) => sameSecret(req.headers['authorization'], expected);
218
+
219
+ /** The one app fact this gate holds: the origin allowed to frame us. Derived
220
+ * from `authorizeUrl` (server-built from PUBLIC_APP_URL) rather than a new
221
+ * wire field, so an older server changes nothing and no floor is needed. */
222
+ let appOrigin = null;
223
+ try {
224
+ if (grants) appOrigin = new URL(authorizeUrl).origin;
225
+ } catch {
226
+ appOrigin = null;
227
+ }
228
+
229
+ /**
230
+ * Replace the origin's frame policy with ours: `frame-ancestors 'self'
231
+ * <app origin>`. Rules that must survive any edit:
232
+ * - REWRITE the directive inside an existing enforced CSP, never append a
233
+ * second policy beside it — multiple CSP headers intersect, so an origin
234
+ * `frame-ancestors 'none'` would still win over anything we added.
235
+ * - APPEND a policy holding only our directive when the origin stated no
236
+ * frame-ancestors at all — a public tunnel hostname deserves a frame
237
+ * policy even when localhost never needed one.
238
+ * - LEAVE Report-Only alone: browsers ignore frame-ancestors there, and
239
+ * rewriting a report channel would be editing the driver's telemetry.
240
+ * - DELETE X-Frame-Options: an enforced frame-ancestors makes every current
241
+ * engine ignore it anyway; deleting is for the stragglers.
242
+ */
243
+ const framePolicy = () => `frame-ancestors 'self' ${appOrigin}`;
244
+ const rewriteFramePolicy = (headers) => {
245
+ if (!grants || !appOrigin) return headers;
246
+ const out = { ...headers };
247
+ delete out['x-frame-options'];
248
+ let sawDirective = false;
249
+ // Node joins duplicate response headers with ', ', so one string can hold
250
+ // SEVERAL policies (comma-separated), each holding several directives
251
+ // (semicolon-separated). Split on BOTH levels or replacing a directive
252
+ // eats the tail of a neighbouring policy — CSP source lists never contain
253
+ // a comma, so the outer split is safe.
254
+ const rewriteOne = (v) =>
255
+ String(v)
256
+ .split(',')
257
+ .map((policy) =>
258
+ policy
259
+ .split(';')
260
+ .map((part) => {
261
+ if (/^\s*frame-ancestors(\s|$)/i.test(part)) {
262
+ sawDirective = true;
263
+ return ` ${framePolicy()}`;
264
+ }
265
+ return part;
266
+ })
267
+ .join(';')
268
+ )
269
+ .join(',');
270
+ const csp = out['content-security-policy'];
271
+ if (csp !== undefined) {
272
+ out['content-security-policy'] = Array.isArray(csp) ? csp.map(rewriteOne) : rewriteOne(csp);
273
+ }
274
+ if (!sawDirective) {
275
+ const existing = out['content-security-policy'];
276
+ if (existing === undefined) out['content-security-policy'] = framePolicy();
277
+ else out['content-security-policy'] = Array.isArray(existing) ? [...existing, framePolicy()] : [existing, framePolicy()];
278
+ }
279
+ return out;
280
+ };
281
+
158
282
  // The gate credential is OURS and stops here. Everything else is passed
159
283
  // through untouched: the origin is the driver's own dev server and rewriting
160
284
  // its request would be us editing their app's input.
@@ -234,12 +358,18 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
234
358
  if (!r.ok) return challenge(res);
235
359
  const to = safeRelative(u.searchParams.get('to'));
236
360
  const maxAge = Math.max(0, r.payload.exp - Math.floor(Date.now() / 1000));
361
+ // FRAMED means Partitioned (header rule 4a). Sec-Fetch-Dest survives the
362
+ // whole redirect chain (it describes the navigation, not the hop), so the
363
+ // callback sees `iframe` exactly when the Workbench is the one asking.
364
+ const dest = String(req.headers['sec-fetch-dest'] || '').toLowerCase();
365
+ const framed = dest === 'iframe' || dest === 'frame' || dest === 'embed' || dest === 'object';
366
+ const site = framed ? 'SameSite=None; Partitioned' : 'SameSite=Lax';
237
367
  // The immediate 302 to a clean path is MANDATORY, not cosmetic: it takes
238
368
  // `?g=` out of the address bar, out of the Referer every subresource would
239
369
  // carry, and out of browser history. It cannot take it out of cloudflared's
240
370
  // access log — which is why the grant is short-lived and share-bound.
241
371
  res.writeHead(302, {
242
- 'Set-Cookie': `${GRANT_COOKIE}=${r.raw}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`,
372
+ 'Set-Cookie': `${GRANT_COOKIE}=${r.raw}; Path=/; Secure; HttpOnly; ${site}; Max-Age=${maxAge}`,
243
373
  Location: to,
244
374
  ...noStore,
245
375
  });
@@ -262,8 +392,18 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
262
392
  if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
263
393
  return challenge(res);
264
394
  }
395
+ // 4. The grant is a cookie, and a cookie can be ridden (header rule 4a's
396
+ // backstop). Never a bounce — the caller HAS a credential; the request
397
+ // SHAPE is what is refused.
398
+ if (grants && crossSiteAbuse(req) && !viaPassword(req)) {
399
+ res.writeHead(403, { 'Content-Type': 'text/plain', ...noStore });
400
+ res.end('cross-site request refused');
401
+ return;
402
+ }
265
403
  const proxyReq = request(forwardOpts(req), (proxyRes) => {
266
- res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
404
+ // The one RESPONSE rewrite this gate performs (header rule 4b): the
405
+ // frame policy. Everything else is the driver's own app talking.
406
+ res.writeHead(proxyRes.statusCode || 502, rewriteFramePolicy(proxyRes.headers));
267
407
  proxyRes.pipe(res);
268
408
  });
269
409
  proxyReq.on('error', () => {
@@ -296,6 +436,14 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
296
436
  socket.destroy();
297
437
  return;
298
438
  }
439
+ // The same cross-site backstop as the request path: a cookie-ridden
440
+ // cross-site handshake (Sec-Fetch-Mode: websocket) is refused; the framed
441
+ // app's own HMR socket is same-origin and never trips it.
442
+ if (grants && crossSiteAbuse(req) && !viaPassword(req)) {
443
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
444
+ socket.destroy();
445
+ return;
446
+ }
299
447
  const proxyReq = request(forwardOpts(req));
300
448
  proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
301
449
  const headerLines = Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`);
@@ -136,6 +136,40 @@ 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
+ /** Collapse a name or slug for loose comparison: "My Project", "my-project"
140
+ * and "myproject" all become "myproject". */
141
+ function normalizeName(s) {
142
+ return String(s ?? '')
143
+ .toLowerCase()
144
+ .replace(/[^a-z0-9]/g, '');
145
+ }
146
+
147
+ /**
148
+ * WHICH stored project most likely goes with this repo — a HINT for the
149
+ * picker's default, and DELIBERATELY not a licence to serve it. `resolveStored
150
+ * Credential` matches by repo PATH and refuses to guess past that, because
151
+ * serving a project the path did not name is the "it said skadooble in my
152
+ * calendar repo" surprise this whole file exists to prevent. A name that
153
+ * matches the repo's folder or its github repo-name is softer evidence — good
154
+ * enough to put the cursor on that row so the answer is one keypress, never
155
+ * good enough to skip the human. Two names can collide (a project "api" and a
156
+ * repo "api"), which is exactly why this only pre-selects.
157
+ *
158
+ * Returns the index of a UNIQUE match, or -1 when nothing matches or more than
159
+ * one does — an ambiguous hint is not a hint, and a default that is as likely
160
+ * wrong as right is worse than starting at the top.
161
+ */
162
+ export function likelyChoiceIndex(choices, { repoBasename, repoSlugName } = {}) {
163
+ const wants = new Set([normalizeName(repoBasename), normalizeName(repoSlugName)].filter(Boolean));
164
+ if (wants.size === 0) return -1;
165
+ const hits = [];
166
+ choices.forEach((e, i) => {
167
+ const n = normalizeName(e?.name);
168
+ if (n && wants.has(n)) hits.push(i);
169
+ });
170
+ return hits.length === 1 ? hits[0] : -1;
171
+ }
172
+
139
173
  function mutate(fn) {
140
174
  const f = readFile() ?? {};
141
175
  if (!f.projects || typeof f.projects !== 'object') f.projects = {};
package/bin/lib/fleet.mjs CHANGED
@@ -831,6 +831,24 @@ export async function runFleetDaemon() {
831
831
  getBaseRef,
832
832
  getMcpUrl: () => mcpUrl,
833
833
  getLeaseTtl: () => leaseTtlSeconds,
834
+ /**
835
+ * "THE REPO JUST CHANGED — look again."
836
+ *
837
+ * `maybeReportRepoState` is on its own 60s wall clock and nothing ever
838
+ * reset it, so every Flowviant action that alters the branch/worktree
839
+ * picture — a ship deleting the merged branch, retirement removing a
840
+ * directory and pruning, a tab being cut — left the rail's Repository block
841
+ * listing things that no longer exist for up to a minute. It is the same
842
+ * rule the diffstat just learned: an action that changes what the machine
843
+ * would measure must cause a new measurement.
844
+ *
845
+ * A callback rather than an export because work.mjs is imported BY this
846
+ * file, so it cannot import back. Clearing the timestamp is enough — the
847
+ * next reconcile does the scan, on the beat it already runs.
848
+ */
849
+ onRepoChanged: () => {
850
+ repoStateScanAt = 0;
851
+ },
834
852
  });
835
853
  workShutdown = shutdownWork; // teardown can now reach the live session CLIs
836
854
 
@@ -58,6 +58,26 @@ import { platform } from 'node:os';
58
58
  import { rssBytes } from './processes.mjs';
59
59
  import { sep } from 'node:path';
60
60
 
61
+ /**
62
+ * INFRASTRUCTURE CHILDREN ARE NOT LISTENERS. cloudflared opens its own local
63
+ * metrics socket, and its cwd is inherited from the daemon — inside the
64
+ * checkout — so a live share put a `cloudflared` row in the repo place's list:
65
+ * Flowviant's own plumbing offered back to the operator as a thing to share or
66
+ * stop. Same rule as the `process.pid` exclusion below: a process that IS
67
+ * Flowviant is not a process Flowviant reports. `preview.mjs` registers each
68
+ * tunnel child here as it spawns it (that import direction already exists;
69
+ * the reverse would be a cycle). In-process is enough: a PEER daemon's tunnel
70
+ * lives in a different checkout, so cwd attribution already excludes it, and
71
+ * a crashed daemon's orphan is reaped at the next startup.
72
+ */
73
+ const infraPids = new Set();
74
+ export const noteInfraPid = (pid) => {
75
+ if (Number.isInteger(pid) && pid > 0) infraPids.add(pid);
76
+ };
77
+ export const forgetInfraPid = (pid) => {
78
+ infraPids.delete(pid);
79
+ };
80
+
61
81
  /** A box with more processes than this is not one we walk per sweep. The scan
62
82
  * is one readlink per pid and runs every reconcile; this is the runaway
63
83
  * bound, not a capacity statement. */
@@ -261,6 +281,15 @@ function scanLinux(worktree) {
261
281
  continue; // not ours, or gone
262
282
  }
263
283
  if (!inside(cwd)) continue;
284
+ // NEVER THE DAEMON ITSELF. `startAuthProxy` binds a loopback port IN THIS
285
+ // PROCESS to gate a preview, and the daemon's own cwd is inside the
286
+ // checkout — so the gate was attributed to the repo place and surfaced as
287
+ // a listener the operator could click Stop on. Stopping it would take down
288
+ // the door in front of a live share, and it is not a thing anybody
289
+ // started: it is us. Same rule `processes.mjs` keeps by never reporting the
290
+ // group leader, for the same reason — a process that IS Flowviant is not a
291
+ // process Flowviant reports.
292
+ if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
264
293
 
265
294
  let fds;
266
295
  try {
@@ -334,8 +363,12 @@ function scanDarwin(worktree) {
334
363
  } catch {
335
364
  /* no memory on this box — rows simply carry no rss */
336
365
  }
366
+ const self = String(process.pid);
337
367
  for (const line of lsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']).split('\n')) {
338
- if (line.startsWith('p')) pid = line.slice(1);
368
+ // The daemon's own preview gate is not something the driver started — see
369
+ // the linux branch for the whole argument. Nor its cloudflared children.
370
+ if (line.startsWith('p'))
371
+ pid = line.slice(1) === self || infraPids.has(Number(line.slice(1))) ? null : line.slice(1);
339
372
  else if (line.startsWith('n') && pid) {
340
373
  const m = /:(\d+)$/.exec(line.slice(1));
341
374
  if (!m) continue;
@@ -46,7 +46,7 @@ import {
46
46
  import { join } from 'node:path';
47
47
  import { homedir, platform, arch } from 'node:os';
48
48
  import { startAuthProxy } from './authproxy.mjs';
49
- import { isListening } from './listeners.mjs';
49
+ import { forgetInfraPid, isListening, noteInfraPid } from './listeners.mjs';
50
50
 
51
51
  // ── cloudflared: pinned, verified, or not fetched at all ───────────────────
52
52
 
@@ -389,6 +389,7 @@ export async function openTunnel({
389
389
  }
390
390
  }
391
391
  forgetPreviewPid(tunnel.pid);
392
+ forgetInfraPid(tunnel.pid);
392
393
  }
393
394
  };
394
395
 
@@ -428,6 +429,9 @@ export async function openTunnel({
428
429
  // word would let a recycled pid land on an operator's own unrelated
429
430
  // cloudflared and group-SIGKILL it.
430
431
  recordPreviewPid(tunnel.pid, `--url http://localhost:${gate.port}`);
432
+ // Keep our own plumbing out of the listeners measurement (see listeners.mjs:
433
+ // cloudflared's metrics socket lives in the checkout's cwd).
434
+ noteInfraPid(tunnel.pid);
431
435
 
432
436
  return new Promise((resolve) => {
433
437
  let settled = false;
package/bin/lib/tty.mjs CHANGED
@@ -109,3 +109,126 @@ export async function askWithTimeout(query, timeoutMs) {
109
109
  process.off('SIGTTOU', noop);
110
110
  }
111
111
  }
112
+
113
+ /**
114
+ * Can we draw an arrow-navigable menu? Raw mode is what turns ↑/↓ into
115
+ * keystrokes we receive one at a time; without `setRawMode` (a pipe, a
116
+ * terminal that refuses raw input) there is nothing to drive, and the caller
117
+ * falls back to the numeric prompt rather than drawing a menu nobody can move.
118
+ * `canPrompt()` still gates whether we ask AT ALL — this only decides HOW.
119
+ */
120
+ export function menuSupported() {
121
+ return Boolean(
122
+ process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === 'function'
123
+ );
124
+ }
125
+
126
+ /**
127
+ * The menu's key handling, PURE so it can be tested without a terminal. Given a
128
+ * keypress and the current cursor, returns exactly one intent:
129
+ * { index } move the highlight (arrows wrap; k/j vim-style; g/G ends)
130
+ * { choose } take a row (Enter takes the highlight; 1–9 jump-and-take)
131
+ * { cancel } Esc, q, or Ctrl-C — nothing chosen
132
+ * null a key we ignore
133
+ * A number past the end is ignored, not clamped: pressing 9 in a 3-row list
134
+ * must not silently select row 3.
135
+ */
136
+ export function menuKey(key, { index, count }) {
137
+ if (key === '\x03' || key === '\x1b' || key === 'q' || key === 'Q') return { cancel: true };
138
+ if (key === '\r' || key === '\n') return { choose: index };
139
+ if (key === '\x1b[A' || key === 'k') return { index: (index - 1 + count) % count };
140
+ if (key === '\x1b[B' || key === 'j') return { index: (index + 1) % count };
141
+ if (key === '\x1b[H' || key === 'g') return { index: 0 };
142
+ if (key === '\x1b[F' || key === 'G') return { index: count - 1 };
143
+ if (/^[1-9]$/.test(key)) {
144
+ const n = Number(key) - 1;
145
+ return n < count ? { choose: n } : null;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ const MENU_FOOTER = '↑/↓ move · enter select · 1–9 jump · esc cancel';
151
+
152
+ /**
153
+ * An arrow-navigable picker for the start path. Resolves one of:
154
+ * { index } the row taken
155
+ * { cancelled } Esc/q/Ctrl-C
156
+ * { timedOut } nobody drove it within timeoutMs
157
+ * { unsupported } no raw mode — the caller uses the numeric prompt instead
158
+ *
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.
165
+ */
166
+ export async function selectMenu({ options, defaultIndex = 0, timeoutMs }) {
167
+ if (!menuSupported()) return { unsupported: true };
168
+ const stdin = process.stdin;
169
+ const out = process.stdout;
170
+ const count = options.length;
171
+ if (count === 0) return { unsupported: true };
172
+ let index = Math.min(Math.max(defaultIndex | 0, 0), count - 1);
173
+
174
+ const width = Math.max(24, (out.columns || 80) - 2);
175
+ const clip = (s) => (s.length > width ? s.slice(0, width - 1) + '…' : s);
176
+ const frame = () =>
177
+ options
178
+ .map((o, i) => (i === index ? `\x1b[7m> ${clip(o)}\x1b[0m` : ` ${clip(o)}`))
179
+ .join('\n') +
180
+ '\n' +
181
+ `\x1b[2m ${MENU_FOOTER}\x1b[0m`;
182
+ const lineCount = count + 1; // rows + footer
183
+
184
+ let rendered = false;
185
+ const draw = () => {
186
+ if (rendered) out.write(`\x1b[${lineCount}A`); // back to the top of our block
187
+ out.write('\r\x1b[0J'); // clear from here to the end of the screen
188
+ out.write(frame() + '\n');
189
+ rendered = true;
190
+ };
191
+
192
+ const noop = () => {};
193
+ return await new Promise((resolve) => {
194
+ let done = false;
195
+ const finish = (result) => {
196
+ if (done) return;
197
+ done = true;
198
+ clearTimeout(timer);
199
+ stdin.removeListener('data', onData);
200
+ try {
201
+ stdin.setRawMode(false);
202
+ } catch {
203
+ /* already restored / gone */
204
+ }
205
+ stdin.pause();
206
+ process.off('SIGTTIN', noop);
207
+ process.off('SIGTTOU', noop);
208
+ out.write('\x1b[?25h'); // show the cursor again
209
+ resolve(result);
210
+ };
211
+ const onData = (buf) => {
212
+ const action = menuKey(buf.toString('utf8'), { index, count });
213
+ if (!action) return;
214
+ if (action.cancel) return finish({ cancelled: true });
215
+ if (action.choose !== undefined) return finish({ index: action.choose });
216
+ if (action.index !== undefined) {
217
+ index = action.index;
218
+ draw();
219
+ }
220
+ };
221
+ process.on('SIGTTIN', noop);
222
+ process.on('SIGTTOU', noop);
223
+ const timer = setTimeout(() => finish({ timedOut: true }), timeoutMs);
224
+ try {
225
+ stdin.setRawMode(true);
226
+ } catch {
227
+ return finish({ unsupported: true });
228
+ }
229
+ stdin.resume();
230
+ out.write('\x1b[?25l'); // hide the cursor while we own the block
231
+ draw();
232
+ stdin.on('data', onData);
233
+ });
234
+ }
package/bin/lib/work.mjs CHANGED
@@ -107,7 +107,15 @@ function brainFor(job) {
107
107
  return out;
108
108
  }
109
109
 
110
- export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, getLeaseTtl }) {
110
+ export function createWorkManager({
111
+ repoRoot,
112
+ baseDir,
113
+ getBaseRef,
114
+ getMcpUrl,
115
+ getLeaseTtl,
116
+ /** "The repo picture changed — look again." See the caller in fleet.mjs. */
117
+ onRepoChanged = () => {},
118
+ }) {
111
119
  /**
112
120
  * WHERE SHIP LANDS, read fresh every time rather than captured at startup.
113
121
  *
@@ -319,6 +327,19 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
319
327
  // The report has landed, so the idempotency path no longer needs the
320
328
  // branch to exist. See `sweepMergedSessionBranch`.
321
329
  sweepMergedSessionBranch(sessionId);
330
+ // AND THE DIFFSTAT IS NOW WRONG BY DEFINITION. A ship folds base in,
331
+ // merges the tip out and deletes the branch, so a fresh measurement reads
332
+ // `ahead: 0` with an empty diffstat — and without this the rail keeps
333
+ // rendering the ENTIRE pre-ship diff while the transcript two hundred
334
+ // pixels away says "Shipped to main". The ship button re-arms over a
335
+ // branch that is already merged. Same rule the kill path just learned:
336
+ // an action that changes what the machine would measure must cause a new
337
+ // measurement, and the 60s sweep is not that.
338
+ void reportPlaceWorktrees(sessionId).catch(() => {});
339
+ // …and the REPO picture changed too: the session branch is gone and base
340
+ // moved. Without this the Repository block keeps counting a branch the
341
+ // ship just deleted.
342
+ onRepoChanged();
322
343
  }
323
344
  return r;
324
345
  };
@@ -591,11 +612,43 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
591
612
  ...(title ? { title } : {}),
592
613
  };
593
614
  };
594
- /** One session, now — called after its turn settles. */
615
+ /** One session, now. */
595
616
  const reportSessionWorktree = async (sessionId) => {
596
617
  const r = sessionWorktreeReport(sessionId);
597
618
  if (r) await postWorktrees([r]);
598
619
  };
620
+
621
+ /**
622
+ * …AND EVERY OTHER TAB STANDING IN THE SAME DIRECTORY.
623
+ *
624
+ * One tab is not one directory any more. Since tabs moved into the driver's
625
+ * own folder, every tab a person owns resolves to the SAME place — so a turn
626
+ * in tab A changed the directory tab B is also describing, and only tab A was
627
+ * re-measured. Tab B went on rendering its pre-turn `+A −D` for up to a
628
+ * minute, which makes the tab strip visibly disagree with itself about one
629
+ * directory. That is the "why are two tabs showing one dev server" confusion
630
+ * the places readout exists to END, arriving through the diffstat instead.
631
+ *
632
+ * It fires on EVERY turn, every ship and every stop, which is what made this
633
+ * the most-hit instance of the rule and the least visible: nothing is wrong
634
+ * on the tab you are looking at.
635
+ *
636
+ * Bounded by the live set the roster last handed us, and the reports go in
637
+ * ONE post — the endpoint is already batched, and a tab per request would
638
+ * turn a five-tab place into five round trips on every settle.
639
+ */
640
+ const reportPlaceWorktrees = async (sessionId) => {
641
+ const place = placeOf(sessionId);
642
+ const ids = [sessionId];
643
+ // `worktreeSeen` is the roster's own live set, pruned to `activeWorkSessions`
644
+ // on every sweep — so this can never report a tab that has closed, and it
645
+ // needs no second source of truth about which tabs exist.
646
+ for (const id of worktreeSeen) {
647
+ if (id !== sessionId && placeOf(id) === place) ids.push(id);
648
+ }
649
+ const reports = ids.map(sessionWorktreeReport).filter(Boolean);
650
+ if (reports.length) await postWorktrees(reports);
651
+ };
599
652
  /** Every live session, throttled — called from the reconcile loop. */
600
653
  /**
601
654
  * A SESSION NOBODY HAS MEASURED YET JUMPS THE SWEEP (2026-08-26).
@@ -1154,7 +1207,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1154
1207
  */
1155
1208
  const remeasureAfterKill = async (sessionId) => {
1156
1209
  try {
1157
- await reportSessionWorktree(sessionId);
1210
+ await reportPlaceWorktrees(sessionId);
1158
1211
  } catch {
1159
1212
  /* the 60s sweep still carries it — this only makes it prompt */
1160
1213
  }
@@ -1444,6 +1497,9 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1444
1497
  git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
1445
1498
  } catch {
1446
1499
  git(['worktree', 'prune'], repoRoot);
1500
+ // A directory and a branch just stopped existing. The Repository block
1501
+ // would otherwise keep listing both until its own 60s scan came round.
1502
+ onRepoChanged();
1447
1503
  try {
1448
1504
  // The branch may already exist (a retired directory's work) — attach.
1449
1505
  git(['worktree', 'add', wt, branch], repoRoot);
@@ -2535,7 +2591,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
2535
2591
  // written half a file, and the tab should show that honestly). NOT
2536
2592
  // awaited: this runs inside the session's chain, and a slow POST
2537
2593
  // would delay the next turn of that tab behind a readout.
2538
- void reportSessionWorktree(job.sessionId).catch(() => {});
2594
+ void reportPlaceWorktrees(job.sessionId).catch(() => {});
2539
2595
  }
2540
2596
  });
2541
2597
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.71.1",
3
+ "version": "0.73.0",
4
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": {