flowviant 0.53.0 → 0.54.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -18,29 +19,35 @@
18
19
  * `npx flowviant` can reuse a stale cache). A running daemon also self-updates
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).
22
+ * `flowviant stop` stops every daemon on this box — the answer to "is one even
23
+ * running?", which otherwise ends in a pid hunt through `ps`.
21
24
  *
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.
25
+ * The daemon: install ONCE with a machine credential, then work entirely from
26
+ * Flowviant. It polls GET /api/v2/fleet/agents, and the roster hands it the
27
+ * project's SESSIONS the Workbench's tabs. Each session gets one persistent
28
+ * git worktree on its own `session/<id>` branch, held across turns (never reset
29
+ * to base: the branch outlives the tab). A turn spawns the session's CLI with a
30
+ * short-lived per-session MCP token, relays what it prints back to the tab,
31
+ * reports the worktree's branch and diffstat when it settles, and answers the
32
+ * odd side job the roster carries — a commit's patch, a preview share, a wiki
33
+ * regen. When you say ship, the daemon merges that branch into base `--no-ff`.
29
34
  *
30
35
  * Env:
31
- * FLOWVIANT_TOKEN / FLOWVIANT_TOKENS / FLOWVIANT_FLEET (one of) credentials.
36
+ * FLOWVIANT_FLEET the machine credential (or use `flowviant login`).
32
37
  * FLOWVIANT_API_URL default https://api.flowviant.com/api/v2
33
38
  * FLOWVIANT_MCP_URL default <API_URL>/mcp
34
39
  * 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)
40
+ * RECONCILE_SECONDS roster poll cadence (default 10)
38
41
  * FLOWVIANT_SAFE=1 restrict the toolset instead of running unattended.
39
42
  *
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`.
43
+ * Requires one of `claude` / `codex` / `agy` on PATH, plus `git`; run from
44
+ * inside the git repo you want worked. `gh` is optional.
42
45
  *
43
- * Implementation lives in ./lib/: config, ui, claude, git, fleet, single.
46
+ * Implementation lives in ./lib/: config, ui, preflight, install, update,
47
+ * instance, login, mcp-cli; fleet (the roster loop) and work (session turns);
48
+ * claude + runtimes + prompts + stream (spawning a CLI and reading its events);
49
+ * git + worktreeDiff + patch; localSessions, listeners, preview + authproxy;
50
+ * env + env-cli + vault, resources, deploy, shot.
44
51
  */
45
52
  import { FLEET_TOKEN } from './lib/config.mjs';
46
53
  import { runFleetDaemon } from './lib/fleet.mjs';
@@ -132,21 +139,46 @@ if (process.argv[2] === 'clean') {
132
139
  }
133
140
 
134
141
  // `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.
142
+ // page. A session's agent shells out to this to SEE the change it just made.
143
+ // Self-contained + graceful (no browser → exit 1, and the agent carries on in
144
+ // text); needs no credential, so it runs before the auth gate.
138
145
  if (process.argv[2] === 'shot') {
139
146
  const { runShot } = await import('./lib/shot.mjs');
140
147
  await runShot(process.argv.slice(3));
141
148
  process.exit(0);
142
149
  }
143
150
 
151
+ // `flowviant stop` — stop every flowviant daemon on this machine.
152
+ //
153
+ // THE FRICTION IT REMOVES is not knowing whether one is running. So you run
154
+ // `flowviant`, get a refusal naming a pid in a directory you do not recognise,
155
+ // and go hunting through `ps`. This asks no question and takes no argument: it
156
+ // sweeps every credential's lock file, not just the one this checkout keys to,
157
+ // because a stop command with a scope is one you have to be sure about before
158
+ // you can use it — and being unsure is the whole reason you typed it.
159
+ //
160
+ // It identifies each holder before signalling it and says so when it cannot
161
+ // (see stopAllDaemons); it needs NO credential and NO network — it reads lock
162
+ // files under ~/.flowviant and signals pids — so it runs BEFORE the auth gate,
163
+ // like `shot`. "I don't know what is running" is not a state in which we should
164
+ // also be asking someone to log in.
165
+ //
166
+ // EXIT 0 when it stopped something AND when it found nothing: "no flowviant
167
+ // daemon is running on this machine." is the answer the asker came for, not an
168
+ // error. Non-zero only when something was alive and could not be stopped.
169
+ if (process.argv[2] === 'stop') {
170
+ const { stopAllDaemons } = await import('./lib/instance.mjs');
171
+ const { failed } = stopAllDaemons({ log: (m) => console.log(m) });
172
+ process.exit(failed > 0 ? 1 : 0);
173
+ }
174
+
144
175
  // `flowviant env <import|set|show>` — the CLI half of team env sync. Values
145
176
  // are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
146
177
  // the browser); `show` decrypts locally — it only works on an ENROLLED machine.
147
178
  // `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.
179
+ // the terminal. Mints a `cli` credential: a separate principal from the
180
+ // per-session tokens, with only the management tools and no way to work or ship
181
+ // a card.
150
182
  if (process.argv[2] === 'mcp') {
151
183
  const { runMcpCommand } = await import('./lib/mcp-cli.mjs');
152
184
  await runMcpCommand(process.argv.slice(3));
@@ -164,7 +196,7 @@ if (!FLEET_TOKEN) {
164
196
  'error: no credential found. Easiest:\n' +
165
197
  ' flowviant login (approve in the app — recommended)\n' +
166
198
  'Or set:\n' +
167
- ' FLOWVIANT_FLEET=fft_… (fleet token, manage machines in Flowviant)'
199
+ ' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
168
200
  );
169
201
  process.exit(1);
170
202
  }
@@ -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"
@@ -266,25 +270,60 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
266
270
  }
267
271
  }
268
272
 
273
+ /**
274
+ * A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
275
+ *
276
+ * The daemon is a PULL client — the /fleet/stream socket is a one-way wake
277
+ * nudge with no server→daemon request path — so "stop this machine" can never
278
+ * be a request the server makes of us. It rides the roster RESPONSE instead, on
279
+ * the same `daemon` object the version signal already travels on, which is why
280
+ * it needs no new endpoint and no version floor: an older daemon reads an
281
+ * unknown key as nothing and keeps running, and fail-open is the safe direction
282
+ * for a switch whose failure mode is "your machine went dark".
283
+ *
284
+ * The server decides whether a stop is LIVE — it stamps the credential and only
285
+ * sends the key inside a short honor window — and the daemon does NOT re-derive
286
+ * that. The key's PRESENCE is the command. Evaluating the same TTL on both
287
+ * sides would make clock skew the arbiter of whether a machine may run, and get
288
+ * it wrong in the direction that bricks the box: a relaunch that re-reads an
289
+ * old timestamp and stops itself again, forever.
290
+ *
291
+ * Pure and exported so the decision can be proved without a credential or a
292
+ * live server. `null` means keep running.
293
+ */
294
+ export function shouldStop(rosterDaemon) {
295
+ const stop = rosterDaemon?.stop;
296
+ // An OBJECT, and not an array: `typeof [] === 'object'`, so the plain typeof
297
+ // guard let `stop: []` — an empty list, which is how this codebase spells "no
298
+ // jobs" on every other roster key — read as a live stop with no reason. A
299
+ // switch that kills a machine gets the narrow test.
300
+ if (!stop || typeof stop !== 'object' || Array.isArray(stop)) return null;
301
+ // Re-sanitized HERE even though the server wrote it: this string is operator
302
+ // prose typed into a SQL UPDATE and then printed straight to a terminal, so
303
+ // control bytes would let a stop reason repaint the console it is being read
304
+ // on, and an unbounded one would bury the line that matters. The WORDING is
305
+ // untouched — the operator's own sentence is the whole point of the field,
306
+ // and paraphrasing it would leave the person at the keyboard guessing.
307
+ const reason = String(stop.reason ?? '')
308
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
309
+ .replace(/\s+/g, ' ')
310
+ .trim()
311
+ .slice(0, 300);
312
+ return { stop: true, reason };
313
+ }
314
+
269
315
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
270
316
  // base between tasks (fresh conversation), resume in place while on a blocker.
271
317
 
272
318
  export async function runFleetDaemon() {
273
319
  console.log('');
274
- console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`fleet daemon · v${VERSION}`)}`);
320
+ console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
275
321
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
276
322
  const repoRoot = repoRootOrDie();
277
323
  const baseRef = detectBaseRef(repoRoot);
278
324
  info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
279
325
  info(`repo · ${repoRoot}`);
280
326
  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
327
  info(`server · ${FLEET_URL}`);
289
328
  console.log('');
290
329
 
@@ -295,18 +334,50 @@ export async function runFleetDaemon() {
295
334
  // the SAME project served twice, and the worst version of this: their session
296
335
  // worktrees are in different directories, so the per-turn lock cannot even see
297
336
  // across them. See instance.mjs for why that lock is not enough on its own.
298
- const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot);
337
+ // Same repo -> this run replaces whatever was serving it. Different repo ->
338
+ // refused, and nothing is signalled. See instance.mjs's header for the rule.
339
+ const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot, {
340
+ takeover:
341
+ process.argv.includes('--takeover') || process.argv.includes('--takeover-downgrade'),
342
+ noTakeover:
343
+ process.argv.includes('--no-takeover') || process.env.FLOWVIANT_NO_TAKEOVER === '1',
344
+ allowDowngrade: process.argv.includes('--takeover-downgrade'),
345
+ log: (m) => info(m),
346
+ });
299
347
  if (!instance.ok) {
300
348
  const h = instance.holder;
301
349
  console.log('');
302
- fail('a flowviant daemon is already running for this credential.');
350
+ // Two different refusals, because they are two different mistakes and the
351
+ // fix is not the same. Same CREDENTIAL: one project is being served twice.
352
+ // Same REPO under another credential: two daemons in one working tree,
353
+ // which the credential-keyed lock cannot see on its own.
354
+ if (instance.takeoverFailed) {
355
+ fail(`could not replace the running daemon: ${instance.takeoverFailed}`);
356
+ } else if (instance.sameRepo) {
357
+ fail('a flowviant daemon is already running in this repo.');
358
+ } else {
359
+ fail('a flowviant daemon is already running for this credential.');
360
+ }
303
361
  if (h?.pid) info(`holder · pid ${h.pid}${h.repoRoot ? ` in ${h.repoRoot}` : ''}`);
304
362
  // The two-checkouts case is the one nobody spots on their own: both tabs
305
363
  // look healthy, and the damage is doubled cards and doubled edits in a repo
306
364
  // you are not looking at. Name the other repo when it is a different one.
307
- if (h?.repoRoot && h.repoRoot !== repoRoot)
365
+ if (!instance.sameRepo && h?.repoRoot && h.repoRoot !== repoRoot) {
308
366
  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.');
367
+ // Not offered lightly: that daemon is serving other work, and this
368
+ // command was run somewhere else. Replacing it is a decision, not a
369
+ // restart, so it takes a word.
370
+ note('run with --takeover to stop it and serve this repo instead.');
371
+ }
372
+ // WITHHELD when we could not identify the holder. ALLOW_MULTI runs this
373
+ // daemon unguarded beside one we just admitted we cannot see, and in the
374
+ // same repo that is two `git fetch`, two worktree sweeps, and one
375
+ // `retireWorkSessions` deleting directories the other is serving. Offering
376
+ // it as the way out of "I don't know what that process is" would be handing
377
+ // someone the worst option at the moment they have the least information.
378
+ if (!instance.unidentified) {
379
+ note('or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
380
+ }
310
381
  console.log('');
311
382
  process.exit(1);
312
383
  }
@@ -606,6 +677,7 @@ export async function runFleetDaemon() {
606
677
  processWorkTurns,
607
678
  processShipJobs,
608
679
  processDiffJobs,
680
+ heldSessionIds,
609
681
  processPreviewJobs,
610
682
  livePreviewIds,
611
683
  retirePreviews,
@@ -873,20 +945,42 @@ export async function runFleetDaemon() {
873
945
 
874
946
  // Changed files of a (merged) PR, for the re-ground prompt. Capped so a huge
875
947
  // 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;
948
+ // WHICH FILES A SHIP CHANGED, read from the commits it landed.
949
+ //
950
+ // This asked `gh pr view <prUrl> --json files` until 2026-08-22, and `prUrl`
951
+ // has been null by construction since dispatch was deleted on 2026-08-19 —
952
+ // the server writes null and says so in a comment. Node threw on the null
953
+ // argument, the catch below read it as "gh failed", and the re-ground retried
954
+ // three times and gave up. Every post-ship re-ground for three months did
955
+ // that silently, while the spec said ship re-grounds the wiki.
956
+ //
957
+ // Returns null when it learned NOTHING (no shas, or none of them resolvable),
958
+ // which the caller still treats as retryable — distinct from a ship that
959
+ // genuinely changed no files.
960
+ const changedFilesForShas = (shas) => {
961
+ if (!Array.isArray(shas) || shas.length === 0) return null;
962
+ const files = new Set();
963
+ for (const sha of shas.slice(0, 50)) {
964
+ if (!/^[0-9a-f]{7,40}$/i.test(String(sha))) continue;
965
+ try {
966
+ const out = execFileSync(
967
+ 'git',
968
+ ['show', '--name-only', '--pretty=format:', String(sha)],
969
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
970
+ );
971
+ for (const line of out.split('\n')) {
972
+ const f = line.trim();
973
+ if (f) files.add(f);
974
+ if (files.size >= 60) break;
975
+ }
976
+ } catch {
977
+ // One unreachable commit is not a failed re-ground — the ship merged
978
+ // to main and the rest of the shas still name real files. Only an
979
+ // EMPTY result is treated as "we learned nothing".
980
+ }
981
+ if (files.size >= 60) break;
889
982
  }
983
+ return files.size ? [...files] : null;
890
984
  };
891
985
  const regroundAttempts = new Map(); // intentId -> gh-failure count
892
986
 
@@ -1042,7 +1136,7 @@ export async function runFleetDaemon() {
1042
1136
  warn('wiki sweep ended without WIKI_DONE — partial pages synced; retry from the app.');
1043
1137
  await runSync(complete);
1044
1138
  } else {
1045
- const files = changedFilesForPr(task.prUrl);
1139
+ const files = changedFilesForShas(task.shas);
1046
1140
  if (files === null) {
1047
1141
  // gh failed (network/auth) — retry via the durable job a couple
1048
1142
  // of times before consuming it, so a transient outage doesn't
@@ -1050,11 +1144,11 @@ export async function runFleetDaemon() {
1050
1144
  const n = (regroundAttempts.get(task.intentId) ?? 0) + 1;
1051
1145
  regroundAttempts.set(task.intentId, n);
1052
1146
  if (n < 3) {
1053
- warn(`wiki re-ground for "${task.title}": gh failed — will retry (${n}/3)`);
1147
+ warn(`wiki re-ground for "${task.title}": no changed files resolved — will retry (${n}/3)`);
1054
1148
  groundedIntents.delete(task.intentId); // let the roster re-offer it
1055
1149
  continue;
1056
1150
  }
1057
- warn(`wiki re-ground for "${task.title}": gh failed ${n} times — giving up (heals on the next full sweep)`);
1151
+ warn(`wiki re-ground for "${task.title}": could not resolve changed files ${n} times — giving up (heals on the next full sweep)`);
1058
1152
  } else if (files.length === 0) {
1059
1153
  note(`${c.cyan('wiki')} ${c.dim(`— "${task.title}": no changed files to re-ground`)}`);
1060
1154
  } else {
@@ -1170,7 +1264,7 @@ export async function runFleetDaemon() {
1170
1264
  for (;;) {
1171
1265
  let roster;
1172
1266
  try {
1173
- roster = await fetchRoster(buildHave(), livePreviewIds());
1267
+ roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds());
1174
1268
  } catch (e) {
1175
1269
  if (e.auth) {
1176
1270
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
@@ -1196,6 +1290,36 @@ export async function runFleetDaemon() {
1196
1290
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
1197
1291
  if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
1198
1292
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
1293
+ // A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
1294
+ // this sits ABOVE the version signal rather than inside it. Both read the
1295
+ // same `roster.daemon` object, but `handleVersionSignal` can re-exec this
1296
+ // process into a newer build — so checked second, a machine somebody just
1297
+ // told to stop would come back up wearing a different version instead of
1298
+ // going away.
1299
+ const stopSignal = shouldStop(roster.daemon);
1300
+ if (stopSignal) {
1301
+ warn(
1302
+ stopSignal.reason
1303
+ ? `stopped by Flowviant — ${stopSignal.reason}`
1304
+ : 'stopped by Flowviant — no reason given.'
1305
+ );
1306
+ note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1307
+ // teardown() is NOT optional on this path. Detached preview tunnels
1308
+ // survive this process BY DESIGN, so exiting without it strands a public
1309
+ // hostname pointed into a worktree until somebody reboots the box — which
1310
+ // is precisely the state a remote stop is usually being used to end. It
1311
+ // also kills the session CLIs and the wiki Claude, which would otherwise
1312
+ // keep editing worktrees and burning quota for a machine nobody is
1313
+ // watching any more.
1314
+ teardown();
1315
+ // EXIT 0, and this is load-bearing: the stop was ASKED FOR, so it is not
1316
+ // a failure. Under `Restart=on-failure` a nonzero code has systemd
1317
+ // relaunch the daemon immediately, fighting the very command that stopped
1318
+ // it; exit 0 reads as "the job is done" and leaves it down. The server's
1319
+ // honor window is what makes the other half work — a deliberate relaunch
1320
+ // minutes later comes up clean instead of stopping itself forever.
1321
+ process.exit(0);
1322
+ }
1199
1323
  // Keep the daemon current. Safe = no worker mid-task (true at startup, since
1200
1324
  // no workers are spawned yet). If it self-updates it re-execs into the new
1201
1325
  // version and this process becomes a proxy — stop the loop.
@@ -1237,7 +1361,16 @@ export async function runFleetDaemon() {
1237
1361
  // in a directory that no longer exists — a human is shown the wrong thing
1238
1362
  // and nothing errors anywhere.
1239
1363
  retirePreviews(roster.activeWorkSessions);
1240
- retireWorkSessions(roster.activeWorkSessions);
1364
+ // A session another daemon on this credential is serving is NOT a closed
1365
+ // tab. Without this the daemon that lost the lease removes the worktree the
1366
+ // winner is working in — absence would mean "somebody else won" instead of
1367
+ // "the tab closed".
1368
+ retireWorkSessions(
1369
+ Array.isArray(roster.activeWorkSessions)
1370
+ ? roster.activeWorkSessions
1371
+ : roster.activeWorkSessions,
1372
+ roster.sessionsHeldElsewhere
1373
+ );
1241
1374
  // Diffs somebody has open and is waiting on. Project-scoped rather than
1242
1375
  // per-session: `git show` runs from the repo ROOT, which can see a closed
1243
1376
  // tab's branch and a shipped commit on main alike.
@@ -1262,24 +1395,23 @@ export async function runFleetDaemon() {
1262
1395
  const sig = [...rosterIds].sort().join(',');
1263
1396
  if (sig !== rosterSig) {
1264
1397
  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
- }
1398
+ // `agents` is permanently [] — the lanes it counted died with dispatch
1399
+ // and the array survives only as wire compat, so this runs once, on the
1400
+ // first poll. It used to point at the Cockpit, a surface deleted
1401
+ // 2026-08-04 that now redirects to the Board. Say what is actually true
1402
+ // instead: the machine is up, and work starts in a tab.
1403
+ info('Machine online. Open a tab in Flowviant Workbench to start working.');
1275
1404
  }
1276
- // Heartbeat so a quiet/empty daemon visibly stays alive.
1277
- if (rosterIds.size === 0 && Date.now() - idleBeatAt > 60_000) {
1405
+ // Heartbeat so a quiet daemon visibly stays alive. Gated on REAL work —
1406
+ // `rosterIds` is built from `roster.agents`, which the server sends
1407
+ // permanently empty, so gating on it printed "waiting" once a minute even
1408
+ // while a tab's turn was running. `workBusy()` is the honest question: are
1409
+ // there session turns, ships or unsettled reports in flight?
1410
+ if (!workBusy() && Date.now() - idleBeatAt > 60_000) {
1278
1411
  idleBeatAt = Date.now();
1279
- info('idlewaiting for agents…');
1412
+ info('machine online nothing running right now.');
1280
1413
  }
1281
1414
 
1282
-
1283
1415
  // Living-wiki work (runs under its own minted wiki token — no agent
1284
1416
  // needed). enqueueSweep queues a Regenerate; regroundJobs re-offers merged
1285
1417
  // deliveries whose re-ground never ran (e.g. we restarted between merge and
@@ -1295,13 +1427,18 @@ export async function runFleetDaemon() {
1295
1427
 
1296
1428
  // Env sync tick: register/bootstrap/wrap/rotate/sync as the roster block
1297
1429
  // 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).
1430
+ // poll). A fresh bundle rematerializes every SESSION worktree this daemon
1431
+ // holds read off the sessions directory, the same fact retirement acts
1432
+ // on; the wiki worktree NEVER gets env (the cartographer doesn't need
1433
+ // secrets). This used to iterate the dispatch-era `workers` map, which
1434
+ // nothing has ever `.set()`, so a rotation reached no worktree at all.
1435
+ // Safe mid-turn by construction: materializeInto refuses to write anything
1436
+ // git does not ignore, so it cannot dirty a tree and block a ship.
1300
1437
  void handleRosterEnv(roster.env, { projectId: roster.project?.id }).then(({ changed }) => {
1301
1438
  if (!changed) return;
1302
- for (const [, w] of workers) {
1439
+ for (const id of heldSessionIds()) {
1303
1440
  try {
1304
- materializeInto(w.wt);
1441
+ materializeInto(join(baseDir, 'sessions', id));
1305
1442
  } catch {
1306
1443
  /* best-effort */
1307
1444
  }