flowviant 0.53.0 → 0.54.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
@@ -1,15 +1,18 @@
1
1
  # flowviant
2
2
 
3
- Run your own [Claude Code](https://claude.com/claude-code) as headless build agents for [Flowviant](https://flowviant.com). You manage a team of agents in the app; this daemon runs them on your machine, on your own credentials — Flowviant never sees your Claude or GitHub logins.
3
+ Run your own coding CLIs as build agents for [Flowviant](https://flowviant.com) [Claude Code](https://claude.com/claude-code), Codex or Antigravity, on your own credentials. This daemon holds your sessions, keeps a worktree per tab, and ships branches on your word. Flowviant never sees your Claude, Codex or GitHub logins.
4
4
 
5
5
  ```bash
6
- npx flowviant login # approve the code in Flowviant → connected
7
- npx flowviant # run your fleet
6
+ npx flowviant@latest login # approve the code in Flowviant → connected
8
7
  ```
9
8
 
9
+ Login keeps going straight into the daemon — there is no second command to run.
10
+
10
11
  ## What it does
11
12
 
12
- You create named agents in Flowviant and dispatch work to them. This daemon, running on a machine you control, gives each agent its own git worktree and drives **your** locally-authenticated `claude` to do the work it claims a task, works it, captures evidence for each acceptance criterion, opens a pull request, and routes any question it can't answer back to you as a blocker. You review and merge in the app.
13
+ A Flowviant project has ONE machine, and this is it: one box, running one Claude (or Codex, or Antigravity) account, serving the whole team's sessions so nobody has to set up their own. Solo and team are the same architecture at N=1 and N>1.
14
+
15
+ You work in the **Workbench**, where your sessions are TABS — each one a held context plus a persistent git worktree on its own `session/<id>` branch. The browser is a terminal projected onto this machine, so you reach the same session from any device. When you say ship, the tab merges its own branch into base with `--no-ff` — no squash, so the commit shas the work reported still exist on main.
13
16
 
14
17
  Because it drives the CLIs you're already logged into, **the cost is yours** (your Claude subscription, your GitHub) and **the daemon never handles a credential** — it shells out to tools you authenticated yourself.
15
18
 
@@ -17,9 +20,9 @@ Because it drives the CLIs you're already logged into, **the cost is yours** (yo
17
20
 
18
21
  On the machine that runs the daemon:
19
22
 
20
- - **[Claude Code](https://claude.com/claude-code)** installed and signed in (`claude`)
21
- - **[GitHub CLI](https://cli.github.com)** authenticated (`gh auth login`) — for opening PRs
23
+ - **at least one coding CLI** installed and signed in — [Claude Code](https://claude.com/claude-code) (`claude`), Codex (`codex`) or Antigravity (`agy`)
22
24
  - **git**, and **Node 20+**
25
+ - **[GitHub CLI](https://cli.github.com)** (`gh`) — optional; the daemon offers to fetch an isolated copy, and `flowviant gh-auth` signs it in
23
26
  - run it from inside the git repository you want worked
24
27
 
25
28
  ## Connecting
@@ -27,53 +30,44 @@ On the machine that runs the daemon:
27
30
  The easy way — device login, like `gh auth login`:
28
31
 
29
32
  ```bash
30
- npx flowviant login
33
+ npx flowviant@latest login
31
34
  ```
32
35
 
33
- It shows a short code; enter it in Flowviant under **Agents Connect a machine**. The credential is stored at `~/.flowviant/credentials.json`, and from then on `npx flowviant` just runs.
36
+ It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `npx flowviant@latest` just runs.
34
37
 
35
- Prefer an explicit token? Create a fleet credential in the app and pass it directly:
38
+ Prefer an explicit token? Create a machine credential in the app and pass it directly:
36
39
 
37
40
  ```bash
38
- FLOWVIANT_FLEET=fva_… npx flowviant
41
+ FLOWVIANT_FLEET=fva_… npx flowviant@latest
39
42
  ```
40
43
 
41
- ## Live mode (the default)
42
-
43
- Each task runs a **persistent** Claude session you can talk to mid-task from the app: the agent streams its work into the task's conversation, you `@`-mention it to steer or answer questions, and it resumes in place. Blockers park the session at zero cost until you answer. When it finishes, it posts a delivery card (summary + checklist self-report) in the thread — a human confirms done by merging there.
44
+ Launch with `@latest` so each start pulls the newest published version — a bare `npx flowviant` can reuse a stale cache. A running daemon also self-updates at startup and when idle (`FLOWVIANT_NO_UPDATE=1` makes it nag-only; `flowviant update` updates now).
44
45
 
45
- Prefer the legacy one-shot poll mode (no streaming, no previews)? Escape hatch:
46
+ ## Sessions
46
47
 
47
- ```bash
48
- FLOWVIANT_POLL=1 npx flowviant
49
- ```
48
+ Each tab in the Workbench is a persistent Claude session with its own worktree, and it stays where you left it — the branch outlives the tab. The daemon runs each turn in event mode and relays what the CLI is printing (thinking, reads, greps, commands) back to the tab, reports the worktree's branch and diffstat after every turn, and fetches a commit's patch when you click a sha in the app.
50
49
 
51
- ### Live previews
50
+ Nothing starts work except you opening a tab and typing in it.
52
51
 
53
- For UI/API tasks, the daemon can start the branch's dev server in the agent's worktree and open a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel so you can drive the real running change during review — no Cloudflare account needed (it's auto-fetched if missing). Configure it once per repo, or let it infer common setups:
52
+ ## Sharing a preview
54
53
 
55
- ```json
56
- // .flowviant/preview.json
57
- { "ui": { "cmd": "npm run dev", "port": 5173 } }
58
- ```
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.
59
55
 
60
- Flowviant only stores the tunnel URL; your browser talks to it directly.
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.
61
57
 
62
58
  ## Modes
63
59
 
64
60
  | Env | What runs |
65
61
  | --- | --- |
66
- | _(stored login)_ or `FLOWVIANT_FLEET` | **Fleet daemon** — one worktree + worker per agent on your roster, managed in the app |
67
- | `FLOWVIANT_TOKEN` | a single agent in the current checkout |
68
- | `FLOWVIANT_TOKENS=a,b,c` | a static fleet, one worktree each |
62
+ | _(stored login)_ or `FLOWVIANT_FLEET` | **the daemon** — the project's machine, serving its sessions |
69
63
  | `FLOWVIANT_SAFE=1` | restrict the toolset instead of running unattended |
70
64
 
71
65
  ## Security posture
72
66
 
73
- Every project member with edit access can run turns on this machine —
74
- Workbench tabs and @-dispatches both execute a coding agent with the daemon's
75
- own OS permissions. Membership is the consent boundary, the same trust plane
76
- as the shared repository: invite people you would give a shell to.
67
+ Every project member with edit access can run turns on this machine — a
68
+ Workbench tab executes a coding agent with the daemon's own OS permissions.
69
+ Membership is the consent boundary, the same trust plane as the shared
70
+ repository: invite people you would give a shell to.
77
71
 
78
72
  Two knobs bound the blast radius, and both are worth setting on a shared box:
79
73
 
package/bin/cli.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * flowviant — run your own Claude Code as headless Flowviant build agents.
3
+ * flowviant — run your own coding CLI (Claude Code, Codex, Antigravity) as the
4
+ * machine behind a Flowviant project.
4
5
  *
5
6
  * ONE mode, one credential:
6
7
  *
7
- * FLOWVIANT_FLEET=fft_… npx flowviant@latest # the fleet daemon
8
+ * FLOWVIANT_FLEET=fva_… npx flowviant@latest # the machine daemon
8
9
  *
9
10
  * `FLOWVIANT_TOKEN` (one worker, current checkout) and `FLOWVIANT_TOKENS` (a
10
11
  * comma list, one worktree each) stood beside it until 2026-08-19. Both ran the
@@ -19,28 +20,32 @@
19
20
  * on its own — at startup and when idle — so it stays current without restarts
20
21
  * (FLOWVIANT_NO_UPDATE=1 makes it nag-only; `flowviant update` updates now).
21
22
  *
22
- * Fleet daemon: install ONCE with a fleet credential, then manage everything
23
- * from Flowviant. The daemon polls GET /api/v2/fleet/agents, reconciles one
24
- * persistent git worktree + worker loop per roster agent, rotates each worker's
25
- * short-lived MCP token, and only spawns Claude when an agent has work. Add/remove
26
- * agents in the app the daemon picks up the change on its next poll. Each worker
27
- * claims its work, resets its worktree to base per task (fresh Claude conversation),
28
- * opens one PR per intent, and routes questions back as blockers.
23
+ * The daemon: install ONCE with a machine credential, then work entirely from
24
+ * Flowviant. It polls GET /api/v2/fleet/agents, and the roster hands it the
25
+ * project's SESSIONS the Workbench's tabs. Each session gets one persistent
26
+ * git worktree on its own `session/<id>` branch, held across turns (never reset
27
+ * to base: the branch outlives the tab). A turn spawns the session's CLI with a
28
+ * short-lived per-session MCP token, relays what it prints back to the tab,
29
+ * reports the worktree's branch and diffstat when it settles, and answers the
30
+ * odd side job the roster carries — a commit's patch, a preview share, a wiki
31
+ * regen. When you say ship, the daemon merges that branch into base `--no-ff`.
29
32
  *
30
33
  * Env:
31
- * FLOWVIANT_TOKEN / FLOWVIANT_TOKENS / FLOWVIANT_FLEET (one of) credentials.
34
+ * FLOWVIANT_FLEET the machine credential (or use `flowviant login`).
32
35
  * FLOWVIANT_API_URL default https://api.flowviant.com/api/v2
33
36
  * FLOWVIANT_MCP_URL default <API_URL>/mcp
34
37
  * FLOWVIANT_FLEET_URL default <API_URL>/fleet/agents
35
- * POLL_SECONDS gap between turns while waiting on a blocker (default 20)
36
- * IDLE_SECONDS gap between work checks when idle (default 30)
37
- * RECONCILE_SECONDS fleet roster poll cadence (default 10)
38
+ * RECONCILE_SECONDS roster poll cadence (default 10)
38
39
  * FLOWVIANT_SAFE=1 restrict the toolset instead of running unattended.
39
40
  *
40
- * Requires the `claude` CLI (and `gh` for PRs) on PATH; run from inside the git
41
- * repo you want worked. Fleet & static-fleet modes also require `git`.
41
+ * Requires one of `claude` / `codex` / `agy` on PATH, plus `git`; run from
42
+ * inside the git repo you want worked. `gh` is optional.
42
43
  *
43
- * Implementation lives in ./lib/: config, ui, claude, git, fleet, single.
44
+ * Implementation lives in ./lib/: config, ui, preflight, install, update,
45
+ * instance, login, mcp-cli; fleet (the roster loop) and work (session turns);
46
+ * claude + runtimes + prompts + stream (spawning a CLI and reading its events);
47
+ * git + worktreeDiff + patch; localSessions, listeners, preview + authproxy;
48
+ * env + env-cli + vault, resources, deploy, shot.
44
49
  */
45
50
  import { FLEET_TOKEN } from './lib/config.mjs';
46
51
  import { runFleetDaemon } from './lib/fleet.mjs';
@@ -132,9 +137,9 @@ if (process.argv[2] === 'clean') {
132
137
  }
133
138
 
134
139
  // `flowviant shot <url>` — capture a headless-browser screenshot of a running
135
- // page. Build agents shell out to this to attach REAL visual evidence to the
136
- // delivery card. Self-contained + graceful (no browser → exit 1, agent falls
137
- // back to text evidence); needs no credential, so it runs before the auth gate.
140
+ // page. A session's agent shells out to this to SEE the change it just made.
141
+ // Self-contained + graceful (no browser → exit 1, and the agent carries on in
142
+ // text); needs no credential, so it runs before the auth gate.
138
143
  if (process.argv[2] === 'shot') {
139
144
  const { runShot } = await import('./lib/shot.mjs');
140
145
  await runShot(process.argv.slice(3));
@@ -145,8 +150,9 @@ if (process.argv[2] === 'shot') {
145
150
  // are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
146
151
  // the browser); `show` decrypts locally — it only works on an ENROLLED machine.
147
152
  // `flowviant mcp` — connect YOUR Claude to Flowviant so you can file work from
148
- // the terminal. Mints a `cli` credential: a separate principal from the build
149
- // workers, with only the management tools and no way to claim or ship work.
153
+ // the terminal. Mints a `cli` credential: a separate principal from the
154
+ // per-session tokens, with only the management tools and no way to work or ship
155
+ // a card.
150
156
  if (process.argv[2] === 'mcp') {
151
157
  const { runMcpCommand } = await import('./lib/mcp-cli.mjs');
152
158
  await runMcpCommand(process.argv.slice(3));
@@ -164,7 +170,7 @@ if (!FLEET_TOKEN) {
164
170
  'error: no credential found. Easiest:\n' +
165
171
  ' flowviant login (approve in the app — recommended)\n' +
166
172
  'Or set:\n' +
167
- ' FLOWVIANT_FLEET=fft_… (fleet token, manage machines in Flowviant)'
173
+ ' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
168
174
  );
169
175
  process.exit(1);
170
176
  }
@@ -44,11 +44,6 @@ function argFlag(name) {
44
44
  return i >= 0 ? process.argv[i + 1] : undefined;
45
45
  }
46
46
 
47
- /** A bare boolean flag (no value follows it). */
48
- function hasFlag(name) {
49
- return process.argv.includes(name);
50
- }
51
-
52
47
  const API_BASE = process.env.FLOWVIANT_API_URL || 'https://api.flowviant.com/api/v2';
53
48
  export const MCP_URL = process.env.FLOWVIANT_MCP_URL || `${API_BASE}/mcp`;
54
49
  export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/agents`;
@@ -149,22 +144,6 @@ export const AUTO_UPDATE = process.env.FLOWVIANT_NO_UPDATE !== '1';
149
144
  // path (one-shot `claude -p` turns) survives behind FLOWVIANT_POLL=1 as the
150
145
  // escape hatch; FLOWVIANT_LIVE=1 is still honored for old scripts.
151
146
  export const LIVE = process.env.FLOWVIANT_POLL !== '1';
152
- /**
153
- * Does this machine accept PATCHES — commits cherry-picked straight into your
154
- * working checkout, with no PR and no review?
155
- *
156
- * Patch placement is chosen by a model, and any teammate who @mentions one of
157
- * your agents can trigger it, so whether it happens at all belongs to whoever
158
- * owns the checkout. Turning it off does not lose the work: the task falls back
159
- * to branch placement and arrives as a PR like anything else.
160
- *
161
- * On by default — the guard that actually protects you (never touching a file
162
- * you have uncommitted edits in) is enforced at apply time, and the whole point
163
- * of patches is to spare you a review cycle for a nine-character diff.
164
- * `--no-patches` or FLOWVIANT_PATCHES=0 to refuse them.
165
- */
166
- export const ALLOW_PATCHES =
167
- !hasFlag('--no-patches') && process.env.FLOWVIANT_PATCHES !== '0';
168
147
  // Sent on the daemon's own HTTP calls so Cloudflare Bot Fight Mode doesn't 403
169
148
  // them (Node's default UA is treated as a bot). Claude Code sends its own UA.
170
149
  export const USER_AGENT = `flowviant/${VERSION}`;
package/bin/lib/fleet.mjs CHANGED
@@ -31,7 +31,6 @@ import {
31
31
  REFRESH_BEFORE_SECONDS,
32
32
  LIVE,
33
33
  AUTO_UPDATE,
34
- ALLOW_PATCHES,
35
34
  } from './config.mjs';
36
35
  import { handleVersionSignal } from './update.mjs';
37
36
  import {
@@ -78,7 +77,7 @@ import { detectRuntimes, knownSkills, pickRuntimeFor, RUNTIMES } from './runtime
78
77
  import { createWorkManager } from './work.mjs';
79
78
  import { scanLocalSessions } from './localSessions.mjs';
80
79
 
81
- async function fetchRoster(haveIds, livePreviewSessionIds = []) {
80
+ async function fetchRoster(haveIds, livePreviewSessionIds = [], heldSessionIds = []) {
82
81
  const url = new URL(FLEET_URL);
83
82
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
84
83
  // What this machine will run at once. The server grows lanes to meet waiting
@@ -108,6 +107,11 @@ async function fetchRoster(haveIds, livePreviewSessionIds = []) {
108
107
  // the server still calls live is a 530 on somebody's phone. Always set, even
109
108
  // empty: '' means "serving none", absent would mean "an older daemon".
110
109
  url.searchParams.set('pv', livePreviewSessionIds.join(','));
110
+ // The sessions this daemon holds a worktree for. Its LEASE on each renews
111
+ // here — one beat, no extra endpoint, and the server can tell "this daemon is
112
+ // still serving that tab" from "it went away" within a reconcile interval
113
+ // instead of minutes. Always set, even empty: '' means "holding none".
114
+ url.searchParams.set('ws', heldSessionIds.join(','));
111
115
  // WHICH CLIs this machine actually has, so the app can stop guessing.
112
116
  //
113
117
  // Until now every surface that listed Gemini or Codex said "not wired up yet"
@@ -271,20 +275,13 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
271
275
 
272
276
  export async function runFleetDaemon() {
273
277
  console.log('');
274
- console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`fleet daemon · v${VERSION}`)}`);
278
+ console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
275
279
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
276
280
  const repoRoot = repoRootOrDie();
277
281
  const baseRef = detectBaseRef(repoRoot);
278
282
  info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
279
283
  info(`repo · ${repoRoot}`);
280
284
  info(`base · ${baseRef}`);
281
- // Stated out loud because it is the one setting that lets something else write
282
- // into the checkout you are sitting in.
283
- info(
284
- ALLOW_PATCHES
285
- ? 'patches· accepted — small changes land in your checkout for Keep/Revert (--no-patches to refuse)'
286
- : 'patches· refused — everything arrives as a branch + PR'
287
- );
288
285
  info(`server · ${FLEET_URL}`);
289
286
  console.log('');
290
287
 
@@ -295,18 +292,42 @@ export async function runFleetDaemon() {
295
292
  // the SAME project served twice, and the worst version of this: their session
296
293
  // worktrees are in different directories, so the per-turn lock cannot even see
297
294
  // across them. See instance.mjs for why that lock is not enough on its own.
298
- const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot);
295
+ // Same repo -> this run replaces whatever was serving it. Different repo ->
296
+ // refused, and nothing is signalled. See instance.mjs's header for the rule.
297
+ const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot, {
298
+ takeover:
299
+ process.argv.includes('--takeover') || process.argv.includes('--takeover-downgrade'),
300
+ noTakeover:
301
+ process.argv.includes('--no-takeover') || process.env.FLOWVIANT_NO_TAKEOVER === '1',
302
+ allowDowngrade: process.argv.includes('--takeover-downgrade'),
303
+ log: (m) => info(m),
304
+ });
299
305
  if (!instance.ok) {
300
306
  const h = instance.holder;
301
307
  console.log('');
302
- fail('a flowviant daemon is already running for this credential.');
308
+ // Two different refusals, because they are two different mistakes and the
309
+ // fix is not the same. Same CREDENTIAL: one project is being served twice.
310
+ // Same REPO under another credential: two daemons in one working tree,
311
+ // which the credential-keyed lock cannot see on its own.
312
+ if (instance.takeoverFailed) {
313
+ fail(`could not replace the running daemon: ${instance.takeoverFailed}`);
314
+ } else if (instance.sameRepo) {
315
+ fail('a flowviant daemon is already running in this repo.');
316
+ } else {
317
+ fail('a flowviant daemon is already running for this credential.');
318
+ }
303
319
  if (h?.pid) info(`holder · pid ${h.pid}${h.repoRoot ? ` in ${h.repoRoot}` : ''}`);
304
320
  // The two-checkouts case is the one nobody spots on their own: both tabs
305
321
  // look healthy, and the damage is doubled cards and doubled edits in a repo
306
322
  // you are not looking at. Name the other repo when it is a different one.
307
- if (h?.repoRoot && h.repoRoot !== repoRoot)
323
+ if (!instance.sameRepo && h?.repoRoot && h.repoRoot !== repoRoot) {
308
324
  warn('that is a DIFFERENT checkout — one credential serves one project, so both would answer the same tabs.');
309
- note('stop the other one first, or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
325
+ // Not offered lightly: that daemon is serving other work, and this
326
+ // command was run somewhere else. Replacing it is a decision, not a
327
+ // restart, so it takes a word.
328
+ note('run with --takeover to stop it and serve this repo instead.');
329
+ }
330
+ note('or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
310
331
  console.log('');
311
332
  process.exit(1);
312
333
  }
@@ -606,6 +627,7 @@ export async function runFleetDaemon() {
606
627
  processWorkTurns,
607
628
  processShipJobs,
608
629
  processDiffJobs,
630
+ heldSessionIds,
609
631
  processPreviewJobs,
610
632
  livePreviewIds,
611
633
  retirePreviews,
@@ -873,20 +895,42 @@ export async function runFleetDaemon() {
873
895
 
874
896
  // Changed files of a (merged) PR, for the re-ground prompt. Capped so a huge
875
897
  // PR can't blow up the prompt. prUrl was already validated before the merge.
876
- // Returns null on a gh FAILURE (network/auth) distinct from a PR that
877
- // genuinely changed nothing — so the caller can retry instead of silently
878
- // consuming the durable job with no re-ground run.
879
- const changedFilesForPr = (prUrl) => {
880
- try {
881
- const out = execFileSync('gh', ['pr', 'view', prUrl, '--json', 'files'], {
882
- cwd: repoRoot,
883
- encoding: 'utf8',
884
- stdio: ['ignore', 'pipe', 'pipe'],
885
- });
886
- return (JSON.parse(out).files ?? []).map((f) => f.path).filter(Boolean).slice(0, 60);
887
- } catch {
888
- return null;
898
+ // WHICH FILES A SHIP CHANGED, read from the commits it landed.
899
+ //
900
+ // This asked `gh pr view <prUrl> --json files` until 2026-08-22, and `prUrl`
901
+ // has been null by construction since dispatch was deleted on 2026-08-19 —
902
+ // the server writes null and says so in a comment. Node threw on the null
903
+ // argument, the catch below read it as "gh failed", and the re-ground retried
904
+ // three times and gave up. Every post-ship re-ground for three months did
905
+ // that silently, while the spec said ship re-grounds the wiki.
906
+ //
907
+ // Returns null when it learned NOTHING (no shas, or none of them resolvable),
908
+ // which the caller still treats as retryable — distinct from a ship that
909
+ // genuinely changed no files.
910
+ const changedFilesForShas = (shas) => {
911
+ if (!Array.isArray(shas) || shas.length === 0) return null;
912
+ const files = new Set();
913
+ for (const sha of shas.slice(0, 50)) {
914
+ if (!/^[0-9a-f]{7,40}$/i.test(String(sha))) continue;
915
+ try {
916
+ const out = execFileSync(
917
+ 'git',
918
+ ['show', '--name-only', '--pretty=format:', String(sha)],
919
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
920
+ );
921
+ for (const line of out.split('\n')) {
922
+ const f = line.trim();
923
+ if (f) files.add(f);
924
+ if (files.size >= 60) break;
925
+ }
926
+ } catch {
927
+ // One unreachable commit is not a failed re-ground — the ship merged
928
+ // to main and the rest of the shas still name real files. Only an
929
+ // EMPTY result is treated as "we learned nothing".
930
+ }
931
+ if (files.size >= 60) break;
889
932
  }
933
+ return files.size ? [...files] : null;
890
934
  };
891
935
  const regroundAttempts = new Map(); // intentId -> gh-failure count
892
936
 
@@ -1042,7 +1086,7 @@ export async function runFleetDaemon() {
1042
1086
  warn('wiki sweep ended without WIKI_DONE — partial pages synced; retry from the app.');
1043
1087
  await runSync(complete);
1044
1088
  } else {
1045
- const files = changedFilesForPr(task.prUrl);
1089
+ const files = changedFilesForShas(task.shas);
1046
1090
  if (files === null) {
1047
1091
  // gh failed (network/auth) — retry via the durable job a couple
1048
1092
  // of times before consuming it, so a transient outage doesn't
@@ -1050,11 +1094,11 @@ export async function runFleetDaemon() {
1050
1094
  const n = (regroundAttempts.get(task.intentId) ?? 0) + 1;
1051
1095
  regroundAttempts.set(task.intentId, n);
1052
1096
  if (n < 3) {
1053
- warn(`wiki re-ground for "${task.title}": gh failed — will retry (${n}/3)`);
1097
+ warn(`wiki re-ground for "${task.title}": no changed files resolved — will retry (${n}/3)`);
1054
1098
  groundedIntents.delete(task.intentId); // let the roster re-offer it
1055
1099
  continue;
1056
1100
  }
1057
- warn(`wiki re-ground for "${task.title}": gh failed ${n} times — giving up (heals on the next full sweep)`);
1101
+ warn(`wiki re-ground for "${task.title}": could not resolve changed files ${n} times — giving up (heals on the next full sweep)`);
1058
1102
  } else if (files.length === 0) {
1059
1103
  note(`${c.cyan('wiki')} ${c.dim(`— "${task.title}": no changed files to re-ground`)}`);
1060
1104
  } else {
@@ -1170,7 +1214,7 @@ export async function runFleetDaemon() {
1170
1214
  for (;;) {
1171
1215
  let roster;
1172
1216
  try {
1173
- roster = await fetchRoster(buildHave(), livePreviewIds());
1217
+ roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds());
1174
1218
  } catch (e) {
1175
1219
  if (e.auth) {
1176
1220
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
@@ -1237,7 +1281,16 @@ export async function runFleetDaemon() {
1237
1281
  // in a directory that no longer exists — a human is shown the wrong thing
1238
1282
  // and nothing errors anywhere.
1239
1283
  retirePreviews(roster.activeWorkSessions);
1240
- retireWorkSessions(roster.activeWorkSessions);
1284
+ // A session another daemon on this credential is serving is NOT a closed
1285
+ // tab. Without this the daemon that lost the lease removes the worktree the
1286
+ // winner is working in — absence would mean "somebody else won" instead of
1287
+ // "the tab closed".
1288
+ retireWorkSessions(
1289
+ Array.isArray(roster.activeWorkSessions)
1290
+ ? roster.activeWorkSessions
1291
+ : roster.activeWorkSessions,
1292
+ roster.sessionsHeldElsewhere
1293
+ );
1241
1294
  // Diffs somebody has open and is waiting on. Project-scoped rather than
1242
1295
  // per-session: `git show` runs from the repo ROOT, which can see a closed
1243
1296
  // tab's branch and a shipped commit on main alike.
@@ -1262,24 +1315,23 @@ export async function runFleetDaemon() {
1262
1315
  const sig = [...rosterIds].sort().join(',');
1263
1316
  if (sig !== rosterSig) {
1264
1317
  rosterSig = sig;
1265
- if (rosterIds.size === 0) {
1266
- // `agents` is permanently [] the lanes it counted died with dispatch
1267
- // and the array survives only as wire compat. So this branch is the one
1268
- // that always runs, and it used to point at the Cockpit, a surface
1269
- // deleted 2026-08-04 that now redirects to the Board. Say what is
1270
- // actually true instead: the machine is up, and work starts in a tab.
1271
- info('Machine online. Open a tab in Flowviant → Workbench to start working.');
1272
- } else {
1273
- note(`Roster: ${c.bold(String(rosterIds.size))} agent${rosterIds.size === 1 ? '' : 's'}.`);
1274
- }
1318
+ // `agents` is permanently [] — the lanes it counted died with dispatch
1319
+ // and the array survives only as wire compat, so this runs once, on the
1320
+ // first poll. It used to point at the Cockpit, a surface deleted
1321
+ // 2026-08-04 that now redirects to the Board. Say what is actually true
1322
+ // instead: the machine is up, and work starts in a tab.
1323
+ info('Machine online. Open a tab in Flowviant Workbench to start working.');
1275
1324
  }
1276
- // Heartbeat so a quiet/empty daemon visibly stays alive.
1277
- if (rosterIds.size === 0 && Date.now() - idleBeatAt > 60_000) {
1325
+ // Heartbeat so a quiet daemon visibly stays alive. Gated on REAL work —
1326
+ // `rosterIds` is built from `roster.agents`, which the server sends
1327
+ // permanently empty, so gating on it printed "waiting" once a minute even
1328
+ // while a tab's turn was running. `workBusy()` is the honest question: are
1329
+ // there session turns, ships or unsettled reports in flight?
1330
+ if (!workBusy() && Date.now() - idleBeatAt > 60_000) {
1278
1331
  idleBeatAt = Date.now();
1279
- info('idlewaiting for agents…');
1332
+ info('machine online nothing running right now.');
1280
1333
  }
1281
1334
 
1282
-
1283
1335
  // Living-wiki work (runs under its own minted wiki token — no agent
1284
1336
  // needed). enqueueSweep queues a Regenerate; regroundJobs re-offers merged
1285
1337
  // deliveries whose re-ground never ran (e.g. we restarted between merge and
@@ -1295,13 +1347,18 @@ export async function runFleetDaemon() {
1295
1347
 
1296
1348
  // Env sync tick: register/bootstrap/wrap/rotate/sync as the roster block
1297
1349
  // dictates (self-guarded — one operation at a time, errors retry next
1298
- // poll). A fresh bundle rematerializes every AGENT worktree; the wiki
1299
- // worktree NEVER gets env (the cartographer doesn't need secrets).
1350
+ // poll). A fresh bundle rematerializes every SESSION worktree this daemon
1351
+ // holds read off the sessions directory, the same fact retirement acts
1352
+ // on; the wiki worktree NEVER gets env (the cartographer doesn't need
1353
+ // secrets). This used to iterate the dispatch-era `workers` map, which
1354
+ // nothing has ever `.set()`, so a rotation reached no worktree at all.
1355
+ // Safe mid-turn by construction: materializeInto refuses to write anything
1356
+ // git does not ignore, so it cannot dirty a tree and block a ship.
1300
1357
  void handleRosterEnv(roster.env, { projectId: roster.project?.id }).then(({ changed }) => {
1301
1358
  if (!changed) return;
1302
- for (const [, w] of workers) {
1359
+ for (const id of heldSessionIds()) {
1303
1360
  try {
1304
- materializeInto(w.wt);
1361
+ materializeInto(join(baseDir, 'sessions', id));
1305
1362
  } catch {
1306
1363
  /* best-effort */
1307
1364
  }
package/bin/lib/git.mjs CHANGED
@@ -35,7 +35,7 @@ export function repoRootOrDie() {
35
35
  try {
36
36
  return git(['rev-parse', '--show-toplevel'], process.cwd());
37
37
  } catch {
38
- console.error('error: fleet mode must run inside a git repo.');
38
+ console.error('error: the flowviant daemon must run inside a git repo.');
39
39
  process.exit(1);
40
40
  }
41
41
  }
@@ -1,11 +1,24 @@
1
1
  /**
2
- * ONE DAEMON PER CREDENTIAL, refused at startup.
2
+ * ONE DAEMON PER REPO (and per credential), ARBITRATED at startup — a second
3
+ * run in the same repo takes the first one's place rather than being turned
4
+ * away. See "WHAT A SECOND RUN DOES" below for the whole rule.
3
5
  *
4
- * WHY THIS EXISTS. Nothing stopped two daemons before, and the server hands
5
- * work out by READING, never claiming: `listWorkTurnJobs` selects every pending
6
- * turn for the fleet token, `listShipJobs` reads a flag. So two daemons on one
7
- * credential are offered the SAME turn — and the ProjectRoom nudges every
8
- * connected daemon socket at once, so they do not even drift out of phase.
6
+ * WHY THIS EXISTS. Nothing stopped two daemons before, and the server USED to
7
+ * hand work out by READING, never claiming: `listWorkTurnJobs` selected every
8
+ * pending turn for the machine credential, `listShipJobs` read a flag. So two
9
+ * daemons on one credential were offered the SAME turn — and the ProjectRoom
10
+ * nudges every connected daemon socket at once, so they did not even drift out
11
+ * of phase.
12
+ *
13
+ * That half is fixed on the server now: since 0.53.0 each SESSION is leased to
14
+ * one daemon INSTANCE nonce — `di`, regenerated every start (config.mjs) and
15
+ * sent on every poll beside `ws`, the list of sessions this daemon holds a
16
+ * worktree for — so a turn is handed to the instance holding that session and
17
+ * to no one else. It does NOT retire this lock. The lease fails OPEN when no
18
+ * instance is reported (an older daemon cannot name itself), and it arbitrates
19
+ * only what rides a session: the wiki sweep, env materialization, previews,
20
+ * deploys and every worktree operation the server never sees are still first
21
+ * come, first served.
9
22
  *
10
23
  * The per-worktree `flowviant-turn.lock` cannot save it. That lock is written
11
24
  * AFTER the work token is minted and the attachments are fetched — a window
@@ -14,27 +27,70 @@
14
27
  * (its own comment says so, work.mjs), where the holder is already live when
15
28
  * the successor looks; it was never a concurrency primitive.
16
29
  *
17
- * What the duplicate run costs, all of it invisible in the tab: two Claudes
18
- * editing one worktree, two cards from one `file_card` (no idempotency key),
19
- * the session write budget spent twice, quota spent twice — and then exactly
20
- * ONE answer survives, because `settleWorkTurn` is atomic. The side effects
21
- * land twice and the transcript shows one turn.
30
+ * What a duplicate run cost before the session lease, all of it invisible in
31
+ * the tab: two Claudes editing one worktree, two cards from one `file_card` (no
32
+ * idempotency key), the session write budget spent twice, quota spent twice —
33
+ * and then exactly ONE answer surviving, because `settleWorkTurn` is atomic.
34
+ * The side effects landed twice and the transcript showed one turn. Two daemons
35
+ * in one checkout still cost the un-leased half of that: two `git fetch`, two
36
+ * worktree sweeps, and the collisions listed under ONE DAEMON PER REPO below.
22
37
  *
23
- * KEYED ON THE CREDENTIAL, NOT THE REPO. The credential is stored once, at
38
+ * KEYED ON THE CREDENTIAL — and, as the next paragraph adds, on the REPO as
39
+ * well; both checks run, and either one is enough. The credential is stored once, at
24
40
  * ~/.flowviant/credentials.json, so `flowviant` in two DIFFERENT checkouts is
25
41
  * still one project served twice — and that case is strictly worse, because the
26
42
  * two daemons have different worktree roots and the turn lock cannot even see
27
43
  * across them. Keying on the token catches both, and still lets a second
28
44
  * credential run a second project on the same machine.
29
45
  *
46
+ * ...AND ONE DAEMON PER REPO, which is NOT the same statement. The lock above
47
+ * is keyed on the credential, and the two coincide only while one credential
48
+ * serves one project — which is the product's law but not a thing this file can
49
+ * assume. Two DIFFERENT credentials pointing at one checkout both acquired
50
+ * happily (measured), giving two daemons in one working tree: two `git fetch`,
51
+ * two worktree sweeps, `retireWorkSessions` in one removing directories the
52
+ * other is serving, and a ship in one racing a rebase in the other. No server
53
+ * lease can arbitrate any of that, because the server never sees a directory.
54
+ * So the repo is checked too, across every credential's lock.
55
+ *
56
+ * WHAT A SECOND RUN DOES, and this is the whole rule:
57
+ *
58
+ * SAME REPO -> the new run WINS. The holder is asked to stand down and
59
+ * this daemon takes its place. Re-running `flowviant` in a
60
+ * directory you are working in means "serve this repo", and
61
+ * the process already serving it is by definition the one
62
+ * you are replacing. That is a restart, and a restart
63
+ * should not require you to go and find a pid.
64
+ *
65
+ * DIFFERENT REPO -> REFUSED, and nothing is signalled. That daemon is serving
66
+ * other work; killing it because you happened to run this
67
+ * command elsewhere is not a restart, it is collateral.
68
+ * `--takeover` overrides, deliberately explicitly.
69
+ *
70
+ * One rule, and it is the invariant stated as behaviour: one daemon per repo.
71
+ * `--no-takeover` (or FLOWVIANT_NO_TAKEOVER=1) makes even the same-repo case
72
+ * refuse, for anyone who wants the old ceremony.
73
+ *
30
74
  * IT FAILS OPEN. A home directory we cannot write to is not a reason to refuse
31
75
  * to start; it is a reason to say so and carry on unguarded.
32
76
  */
33
77
 
34
- import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs';
35
- import { homedir } from 'node:os';
78
+ import { execFileSync } from 'node:child_process';
79
+ import {
80
+ closeSync,
81
+ mkdirSync,
82
+ openSync,
83
+ readdirSync,
84
+ readFileSync,
85
+ realpathSync,
86
+ rmSync,
87
+ writeFileSync,
88
+ writeSync,
89
+ } from 'node:fs';
90
+ import { homedir, platform } from 'node:os';
36
91
  import { join } from 'node:path';
37
92
  import { createHash } from 'node:crypto';
93
+ import { VERSION } from './config.mjs';
38
94
 
39
95
  /** Deliberately a HASH: a credential must never become a filename. */
40
96
  export function instanceLockPath(fleetToken) {
@@ -42,6 +98,20 @@ export function instanceLockPath(fleetToken) {
42
98
  return join(homedir(), '.flowviant', `daemon-${key}.lock`);
43
99
  }
44
100
 
101
+ /** Numeric dotted compare, -1/0/1. Unparsable compares EQUAL, so a version we
102
+ * cannot read never silently authorises a downgrade. */
103
+ function cmpVersion(a, b) {
104
+ const x = String(a).split('.').map((n) => Number.parseInt(n, 10));
105
+ const y = String(b).split('.').map((n) => Number.parseInt(n, 10));
106
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
107
+ const p = x[i] ?? 0;
108
+ const q = y[i] ?? 0;
109
+ if (Number.isNaN(p) || Number.isNaN(q)) return 0;
110
+ if (p !== q) return p > q ? 1 : -1;
111
+ }
112
+ return 0;
113
+ }
114
+
45
115
  /** Signal 0 — a liveness probe, not a kill. EPERM means alive and not ours. */
46
116
  function alive(pid) {
47
117
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -63,7 +133,192 @@ function readHolder(path) {
63
133
  }
64
134
 
65
135
  const record = (repoRoot) =>
66
- JSON.stringify({ pid: process.pid, repoRoot, startedAt: new Date().toISOString() });
136
+ JSON.stringify({
137
+ pid: process.pid,
138
+ repoRoot,
139
+ startedAt: new Date().toISOString(),
140
+ // The script we were started from, and what we are. A takeover matches the
141
+ // live command line against `entry` before signalling anything — a lock
142
+ // records a PID, and a crashed daemon's PID can be reused by anything.
143
+ entry: process.argv[1] || '',
144
+ version: VERSION,
145
+ });
146
+
147
+ /** Same directory, whatever it is spelled as — symlinks and trailing slashes
148
+ * included. A repo compared by string would let `/repo` and `/repo/` past. */
149
+ function samePath(a, b) {
150
+ if (!a || !b) return false;
151
+ const norm = (v) => {
152
+ try {
153
+ return realpathSync(v);
154
+ } catch {
155
+ return String(v).replace(/\/+$/, '');
156
+ }
157
+ };
158
+ return norm(a) === norm(b);
159
+ }
160
+
161
+ /**
162
+ * A LIVE daemon in this same checkout, under a DIFFERENT credential.
163
+ *
164
+ * The lock above cannot see one: it is keyed on the credential, so a second
165
+ * token in the same directory opens its own file and takes it. Every other
166
+ * lock file on this machine is ours to read, so read them.
167
+ *
168
+ * Returns the holder, or null. A stale file never blocks — it is cleared by
169
+ * whichever acquire owns it, and blocking on a corpse would be worse than the
170
+ * thing this prevents.
171
+ */
172
+ /** Which lock file a neighbour holder was read from — takeOverFrom waits on it. */
173
+ const NEIGHBOUR_PATHS = new WeakMap();
174
+ function neighbourLockPath(holder, fallback) {
175
+ return NEIGHBOUR_PATHS.get(holder) ?? fallback;
176
+ }
177
+
178
+ export function daemonInSameRepo(repoRoot, ownPath) {
179
+ const dir = join(homedir(), '.flowviant');
180
+ let files;
181
+ try {
182
+ files = readdirSync(dir).filter((f) => /^daemon-[0-9a-f]{12}\.lock$/.test(f));
183
+ } catch {
184
+ return null;
185
+ }
186
+ for (const f of files) {
187
+ const path = join(dir, f);
188
+ if (path === ownPath) continue; // our own credential — the lock above owns that question
189
+ const holder = readHolder(path);
190
+ if (!holder || !alive(holder.pid)) continue;
191
+ if (holder.pid === process.ppid) continue; // ourselves mid self-update re-exec
192
+ if (samePath(holder.repoRoot, repoRoot)) {
193
+ NEIGHBOUR_PATHS.set(holder, path);
194
+ return holder;
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ /**
201
+ * IS THIS PID STILL THE DAEMON THAT TOOK THE LOCK?
202
+ *
203
+ * `process.kill(pid, 0)` says "a process exists", which is not the same claim,
204
+ * and the difference matters the moment we are about to signal it. Matched on
205
+ * the holder's own recorded ENTRYPOINT, never on the word "flowviant": a
206
+ * command line merely CONTAINING it matches a shell, an editor, or a test
207
+ * runner living under a `…-flowviant/` directory. That last one is not
208
+ * hypothetical — a looser version of this check SIGTERMed one.
209
+ *
210
+ * A lock with no `entry` predates this and is never signalled.
211
+ */
212
+ function stillTheHolder(holder) {
213
+ const want = typeof holder?.entry === 'string' ? holder.entry : null;
214
+ if (!want) return false;
215
+ try {
216
+ if (platform() === 'linux') {
217
+ return readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ').includes(want);
218
+ }
219
+ return execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
220
+ encoding: 'utf8',
221
+ stdio: ['ignore', 'pipe', 'ignore'],
222
+ timeout: 3000,
223
+ }).includes(want);
224
+ } catch {
225
+ return false; // gone, or unreadable — not something we signal
226
+ }
227
+ }
228
+
229
+ /** Blocking, because this runs before there is an event loop worth yielding to
230
+ * and the caller cannot proceed until it knows whether the holder is gone. */
231
+ const sleep = (ms) => {
232
+ try {
233
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
234
+ } catch {
235
+ /* no SharedArrayBuffer — check again immediately */
236
+ }
237
+ };
238
+
239
+ /** How long the outgoing daemon gets to stand down cleanly. Its SIGTERM handler
240
+ * kills the CLI children it spawned and stops its preview tunnels; both are
241
+ * why we ask before we insist. */
242
+ const TAKEOVER_GRACE_MS = 20_000;
243
+
244
+ /**
245
+ * Ask the holder to stand down, then take its place.
246
+ *
247
+ * SIGTERM FIRST, and not out of politeness: the daemon's handler runs its
248
+ * teardown — it kills the CLI children it spawned and stops its preview
249
+ * tunnels, which are DETACHED and would otherwise keep a public hostname
250
+ * serving a worktree until the box reboots.
251
+ *
252
+ * WAIT ON THE LOCK FILE, not the pid. A departing daemon's release() removes it
253
+ * on exit, so the file changing IS the handover. `kill(pid, 0)` cannot see it:
254
+ * a process that exited but has not been reaped is a ZOMBIE and answers signal
255
+ * 0 exactly like a living one — measured, a peer that exited cleanly still read
256
+ * as alive for the full grace window.
257
+ *
258
+ * And "gone" is NOT "the file stopped naming our pid". It can stop naming it
259
+ * because the holder SELF-UPDATED: update.mjs re-execs and the successor adopts
260
+ * this same lock through the ppid branch. Treating that as free steals a live
261
+ * daemon's lock and leaves it running unguarded — measured doing exactly that.
262
+ */
263
+ function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
264
+ if (!holder?.pid || !alive(holder.pid)) return null; // already gone
265
+ if (!stillTheHolder(holder)) {
266
+ return { failed: `pid ${holder.pid} is no longer the daemon that took this lock — refusing to signal it` };
267
+ }
268
+ if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
269
+ return {
270
+ failed: `the running daemon is ${holder.version} and this one is ${VERSION} — refusing to replace a newer daemon with an older one (--takeover-downgrade if you mean it)`,
271
+ };
272
+ }
273
+
274
+ log?.(`asking daemon pid ${holder.pid} to stand down…`);
275
+ try {
276
+ process.kill(holder.pid, 'SIGTERM');
277
+ } catch {
278
+ return { failed: `could not signal pid ${holder.pid}` };
279
+ }
280
+
281
+ const standing = () => {
282
+ const now = readHolder(path);
283
+ if (!now || !alive(now.pid)) return null;
284
+ return now;
285
+ };
286
+ const deadline = Date.now() + TAKEOVER_GRACE_MS;
287
+ for (;;) {
288
+ const now = standing();
289
+ if (!now) break;
290
+ if (now.pid !== holder.pid) {
291
+ return {
292
+ failed: `the daemon handed over to pid ${now.pid}${now.version ? ` (${now.version})` : ''} while we waited — it is mid-update, so try again in a moment`,
293
+ };
294
+ }
295
+ if (Date.now() >= deadline) {
296
+ log?.(`pid ${holder.pid} did not stand down within ${TAKEOVER_GRACE_MS / 1000}s — forcing it.`);
297
+ try {
298
+ process.kill(holder.pid, 'SIGKILL');
299
+ } catch {
300
+ /* exited in the gap */
301
+ }
302
+ sleep(600);
303
+ const after = standing();
304
+ if (after && after.pid !== holder.pid) {
305
+ return { failed: `the daemon handed over to pid ${after.pid} — try again in a moment` };
306
+ }
307
+ if (after) return { failed: `pid ${holder.pid} would not stop` };
308
+ break;
309
+ }
310
+ sleep(400);
311
+ }
312
+
313
+ // A SIGKILLed daemon never ran its release(), so clear what it left.
314
+ try {
315
+ rmSync(path, { force: true });
316
+ } catch {
317
+ return { failed: 'could not clear the lock file' };
318
+ }
319
+ log?.(`daemon pid ${holder.pid} stopped — taking over.`);
320
+ return null;
321
+ }
67
322
 
68
323
  /**
69
324
  * Take the lock, or report who holds it.
@@ -75,7 +330,8 @@ const record = (repoRoot) =>
75
330
  * `wx` is the whole guarantee: create-exclusive is one atomic syscall, which is
76
331
  * the property the turn lock's check-then-write does not have.
77
332
  */
78
- export function acquireInstanceLock(fleetToken, repoRoot) {
333
+ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
334
+ const { takeover: force = false, noTakeover = false, allowDowngrade = false, log } = opts;
79
335
  if (process.env.FLOWVIANT_ALLOW_MULTI === '1') return { ok: true, release: () => {} };
80
336
  const path = instanceLockPath(fleetToken);
81
337
  try {
@@ -84,6 +340,20 @@ export function acquireInstanceLock(fleetToken, repoRoot) {
84
340
  return { ok: true, release: () => {}, unguarded: true };
85
341
  }
86
342
 
343
+ // ONE DAEMON PER REPO, checked across every credential — see the header. This
344
+ // runs BEFORE we take our own lock, so a refusal leaves nothing behind.
345
+ const neighbour = daemonInSameRepo(repoRoot, path);
346
+ if (neighbour) {
347
+ // Same working tree, another credential. Under "one daemon per repo" the
348
+ // new run wins here too — but it is signalling a process that belongs to a
349
+ // DIFFERENT project, so it is worth saying out loud rather than doing
350
+ // quietly.
351
+ if (noTakeover) return { ok: false, holder: neighbour, sameRepo: true };
352
+ log?.(`another project's daemon is serving this repo (pid ${neighbour.pid}).`);
353
+ const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log);
354
+ if (bad) return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed };
355
+ }
356
+
87
357
  // Two passes at most: one to clear a stale holder, one to take the lock. A
88
358
  // loop here would spin against a peer that keeps re-taking it.
89
359
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -118,7 +388,16 @@ export function acquireInstanceLock(fleetToken, repoRoot) {
118
388
  }
119
389
  return { ok: true, release: makeRelease(path) };
120
390
  }
121
- return { ok: false, holder };
391
+ // THE RULE. Same repo -> this run replaces it; different repo -> refuse
392
+ // and signal nothing, unless --takeover says otherwise. See the header.
393
+ const here = samePath(holder.repoRoot, repoRoot);
394
+ const wanted = force || (here && !noTakeover);
395
+ if (wanted) {
396
+ const bad = takeOverFrom(holder, path, log, { allowDowngrade });
397
+ if (bad) return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here };
398
+ continue; // the file is gone — the next pass takes it
399
+ }
400
+ return { ok: false, holder, sameRepo: here };
122
401
  }
123
402
  try {
124
403
  writeSync(fd, record(repoRoot));
package/bin/lib/login.mjs CHANGED
@@ -56,7 +56,11 @@ export async function runLogin({ thenStart = false } = {}) {
56
56
  }
57
57
  const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
58
58
  const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
59
- console.log(` 1. Open ${c.cyan(APP_URL)} your project the ${c.bold('Agents')} panel → ${c.bold('Connect a machine')}.`);
59
+ // Where the control ACTUALLY is. It was "the Agents panel", a settings
60
+ // section deleted 2026-08-17; connecting a machine is offered on the surface
61
+ // you are on when it matters, and for a new operator that is the Workbench —
62
+ // the project's empty state says so before it can show you any sessions.
63
+ console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Workbench')} → ${c.bold('Connect a machine')}.`);
60
64
  console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
61
65
  info('waiting for you to approve…');
62
66
 
@@ -2,17 +2,18 @@
2
2
  * `flowviant mcp` — connect YOUR Claude to Flowviant so you can file work from
3
3
  * the terminal ("stick that on the board", "file a task for this TODO").
4
4
  *
5
- * This mints a `cli` credential, which is a different principal from the worker
6
- * tokens the daemon rotates for builds. That separation is the point, not
7
- * bookkeeping: a worker reads untrusted repo, PR and issue text all day, so
8
- * giving THAT principal tools that write to your workspace would mean a hostile
9
- * string in a README could file work as you. The cli credential sees only the
10
- * management tools and can never claim or complete work; the worker can never
11
- * reach create_task.
5
+ * This mints a `cli` credential, which is a different principal from the
6
+ * per-session `work` tokens the daemon mints for Workbench tabs. That
7
+ * separation is the point, not bookkeeping: a session's agent reads untrusted
8
+ * repo and issue text all day, so giving THAT principal tools that write to
9
+ * your workspace would mean a hostile string in a README could file work as
10
+ * you. The cli credential sees only the management tools and can never work or
11
+ * ship a card; a session token can never reach create_task.
12
12
  *
13
13
  * There is deliberately no invite capability on it. Invites grant access to a
14
- * paid workspace and are guarded by a human browser session; you ask Flowvy in
15
- * the app for those, and approve the card.
14
+ * paid workspace and are guarded by a human browser session they are sent
15
+ * from the workspace card's gear menu in the app, and never from a CLI
16
+ * credential sitting on a shared machine.
16
17
  */
17
18
 
18
19
  import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
@@ -77,7 +78,7 @@ export async function runMcpCommand(args = []) {
77
78
  console.log(` ${cmd}`);
78
79
  console.log('');
79
80
  console.log('Then, in any Claude session: "file a task in Flowviant for …".');
80
- console.log('Tasks land as drafts — nothing runs until you open one in the');
81
- console.log('app and @mention an agent.');
81
+ console.log('Tasks land as drafts on the board — nothing runs until you open');
82
+ console.log('a session tab in the Workbench and type in it.');
82
83
  console.log('');
83
84
  }
@@ -91,20 +91,23 @@ export async function preflight({ needGit = true } = {}) {
91
91
  }
92
92
  }
93
93
 
94
- // gh — needed to open PRs. Offer to fetch the isolated binary (yes-default:
95
- // low-risk, no login carried by the install itself).
94
+ // gh — OPTIONAL. The daemon pushes and merges with plain `git`; nothing in
95
+ // the product opens a pull request any more, so gh is a convenience for the
96
+ // agent, not a requirement. Hence the offer no longer defaults to yes: an
97
+ // install nobody asked for, defaulted on, is how a preflight installs
98
+ // software on a machine whose operator only wanted a status line.
96
99
  if (gh && ghAuthed()) {
97
100
  ok('gh authenticated');
98
101
  } else if (gh) {
99
102
  warn('gh not signed in — run: gh auth login');
100
103
  } else {
101
- warn('gh NOT found — needed to open PRs.');
102
- if (await promptYesNo('Install GitHub CLI (gh) now?', true)) {
104
+ info('gh not found (optional).');
105
+ if (await promptYesNo('Install GitHub CLI (gh) now?', false)) {
103
106
  if (await installGh((m) => info(m))) gh = present('gh');
104
107
  }
105
108
  gh
106
109
  ? ok('gh installed to ~/.flowviant/bin — authenticate with: flowviant gh-auth')
107
- : warn('install gh manually: https://cli.github.com, then run: gh auth login');
110
+ : info(c.dim('or install it later: https://cli.github.com, then: gh auth login'));
108
111
  }
109
112
 
110
113
  if (needGit) (git ? ok('git installed') : warn('git NOT found — install git'));
@@ -687,9 +687,18 @@ export const RUNTIMES = {
687
687
  // turn reports failure having touched nothing. Caught only because an
688
688
  // end-to-end test had to add the flag by hand to work.
689
689
  //
690
- // NOT `--sandbox` here, unlike wiki: a build has to `git push` and run
691
- // `gh pr create`, so it needs the network by definition. The containment
692
- // is the worktree, as it is for every runtime.
690
+ // NOT `--sandbox` here, unlike wiki: a build has to `git push`, so it
691
+ // needs the network by definition.
692
+ //
693
+ // AND NOTHING ELSE CONTAINS IT EITHER. This comment used to end "the
694
+ // containment is the worktree, as it is for every runtime", which was
695
+ // false and is the kind of false that stops people looking: `cwd` is a
696
+ // starting directory, not a jail. A build turn runs with the operator's
697
+ // full user permissions — it can write outside the worktree, to their
698
+ // home directory, to their other checkouts. Claude gets
699
+ // --dangerously-skip-permissions, codex gets --sandbox
700
+ // danger-full-access, and agy gets no sandbox flag at all. The only
701
+ // real boundary today is that the person driving the tab is trusted.
693
702
  //
694
703
  // FLOWVIANT_SAFE HAS NO EXPRESSION ON THIS RUNTIME. Claude narrows to an
695
704
  // allowlist and Codex to `workspace-write`; agy's only per-invocation
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Fleet daemon push channel — the best-practice endgame for dispatch latency.
2
+ * Machine daemon push channel — the best-practice endgame for pickup latency.
3
3
  *
4
- * Holds a hibernatable WebSocket open to the server. When a job lands (a wiki
5
- * regen, a dispatch, a merge request, an @mention), the server pushes a
6
- * `{type:'wake'}` frame and the daemon reconciles IMMEDIATELY — its normal
7
- * roster fetch — instead of waiting out the poll. That collapses pickup latency
8
- * from ≤RECONCILE_SECONDS to ~a round trip.
4
+ * Holds a hibernatable WebSocket open to the server. When a job lands a
5
+ * session turn typed into a tab, a diff request, a preview, an env sync, a
6
+ * deploy, a wiki regen — the server pushes a `{type:'wake'}` frame and the
7
+ * daemon reconciles IMMEDIATELY — its normal roster fetch — instead of waiting
8
+ * out the poll. That collapses pickup latency from ≤RECONCILE_SECONDS to ~a
9
+ * round trip, which is what makes a message typed in the browser start on the
10
+ * machine at once.
9
11
  *
10
12
  * The socket carries NO authority: it's a dumb nudge, the roster HTTP fetch is
11
13
  * the source of truth (notify-then-reconcile, à la k8s watch). So a dropped or
@@ -74,9 +76,9 @@ export function connectStream({ onWake, isAlive }) {
74
76
  lastRxAt = Date.now();
75
77
  if (announcedDown) {
76
78
  announcedDown = false;
77
- info(c.dim('push channel reconnected — instant dispatch back on'));
79
+ info(c.dim('push channel reconnected — turns start instantly again'));
78
80
  } else {
79
- note(c.dim('push channel connected — instant dispatch on'));
81
+ note(c.dim('push channel connected — turns start instantly'));
80
82
  }
81
83
  clearPing();
82
84
  pingTimer = setInterval(() => {
@@ -114,10 +114,11 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
114
114
 
115
115
  if (wantInstall && !npx) {
116
116
  if (!safeToUpdate) {
117
- // Outdated but an agent is mid-task — wait for idle. Nag once meanwhile.
117
+ // Outdated but a turn is running — wait until the machine is quiet. Nag
118
+ // once meanwhile.
118
119
  if (naggedFor !== target) {
119
120
  naggedFor = target;
120
- note(`flowviant ${cur} → ${target} available — self-updating once agents go idle.`);
121
+ note(`flowviant ${cur} → ${target} available — self-updating once no turn is running.`);
121
122
  }
122
123
  return false;
123
124
  }
package/bin/lib/work.mjs CHANGED
@@ -799,9 +799,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
799
799
  'Content-Type': 'application/json',
800
800
  },
801
801
  signal: AbortSignal.timeout(30_000),
802
- body: JSON.stringify({ sessionId }),
802
+ // The instance is what CLAIMS the session lease server-side. Two
803
+ // daemons share one fleet credential, so the token cannot say which of
804
+ // us is serving this tab — and the mint is the moment that matters:
805
+ // there is one work-token row per session and minting ROTATES it, so a
806
+ // second mint revokes the first daemon's live secret mid-turn.
807
+ body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
803
808
  });
804
809
  if (res.status === 404) return { gone: true };
810
+ // 409 — another daemon on this credential holds the session. Not ours to
811
+ // serve and not a retry: stand down and let the holder answer.
812
+ if (res.status === 409) return { heldElsewhere: true };
805
813
  if (!res.ok) return null;
806
814
  const token = (await res.json().catch(() => null))?.data?.token ?? null;
807
815
  if (!token) return null;
@@ -1168,8 +1176,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1168
1176
  * machines. When the roster omits the field entirely (older server),
1169
1177
  * absence of signal is not a close — retire nothing.
1170
1178
  */
1171
- const retireWorkSessions = (activeIds) => {
1179
+ /** Every session this daemon currently has a worktree for — what renews our
1180
+ * lease on the poll. Read off the directory rather than a map, so it is the
1181
+ * same fact retirement acts on. */
1182
+ const heldSessionIds = () => {
1183
+ const dir = join(baseDir, 'sessions');
1184
+ try {
1185
+ return readdirSync(dir).filter(isSafePathSegment).slice(0, 50);
1186
+ } catch {
1187
+ return [];
1188
+ }
1189
+ };
1190
+
1191
+ const retireWorkSessions = (activeIds, heldElsewhere) => {
1172
1192
  if (!Array.isArray(activeIds)) return;
1193
+ // Sessions ANOTHER daemon on this credential is serving. They are absent
1194
+ // from activeWorkSessions for us and present for them, and removing their
1195
+ // worktree would pull the directory out from under a running turn. Absence
1196
+ // means "the tab closed"; this is the one other thing it can mean.
1197
+ const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
1173
1198
  const dir = join(baseDir, 'sessions');
1174
1199
  if (!existsSync(dir)) return;
1175
1200
  let ids;
@@ -1182,6 +1207,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1182
1207
  let removed = 0;
1183
1208
  for (const id of ids) {
1184
1209
  if (live.has(id)) continue;
1210
+ if (peers.has(id)) continue; // another daemon's tab — not ours to retire
1185
1211
  if (workChains.has(id) || shipping.has(id)) continue; // still draining here
1186
1212
  const wt = join(dir, id);
1187
1213
  try {
@@ -1412,6 +1438,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1412
1438
  if (!plainTab) {
1413
1439
  mint = await mintWorkToken(job.sessionId);
1414
1440
  if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
1441
+ // Another daemon on this credential holds the session. Return
1442
+ // WITHOUT settling: the holder is answering this same turn, and
1443
+ // settling it here — even as a failure — would race the real
1444
+ // answer and could win. Dropping it means the turn stays pending
1445
+ // and the holder's answer lands, which is the whole point.
1446
+ if (mint?.heldElsewhere) {
1447
+ workAnswering.delete(job.id);
1448
+ return;
1449
+ }
1415
1450
  if (mint?.gone) {
1416
1451
  await settleWorkTurn(job.id, {
1417
1452
  ok: false,
@@ -2064,6 +2099,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2064
2099
  processWorkTurns,
2065
2100
  processShipJobs,
2066
2101
  processDiffJobs,
2102
+ heldSessionIds,
2067
2103
  processPreviewJobs,
2068
2104
  livePreviewIds,
2069
2105
  retirePreviews,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
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": {