flowviant 0.71.1 → 0.72.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
 
@@ -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}`);
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/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.72.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": {