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 +25 -31
- package/bin/cli.mjs +54 -22
- package/bin/lib/config.mjs +0 -21
- package/bin/lib/fleet.mjs +187 -50
- package/bin/lib/git.mjs +1 -1
- package/bin/lib/instance.mjs +691 -17
- package/bin/lib/login.mjs +5 -1
- package/bin/lib/mcp-cli.mjs +12 -11
- package/bin/lib/preflight.mjs +8 -5
- package/bin/lib/runtimes.mjs +12 -3
- package/bin/lib/stream.mjs +10 -8
- package/bin/lib/update.mjs +3 -2
- package/bin/lib/work.mjs +38 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
# flowviant
|
|
2
2
|
|
|
3
|
-
Run your own
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
## Sessions
|
|
46
47
|
|
|
47
|
-
|
|
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
|
-
|
|
50
|
+
Nothing starts work except you opening a tab and typing in it.
|
|
52
51
|
|
|
53
|
-
|
|
52
|
+
## Sharing a preview
|
|
54
53
|
|
|
55
|
-
|
|
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
|
-
|
|
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` | **
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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=
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
41
|
-
* repo you want worked.
|
|
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,
|
|
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.
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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
|
|
149
|
-
//
|
|
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=
|
|
199
|
+
' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
|
|
168
200
|
);
|
|
169
201
|
process.exit(1);
|
|
170
202
|
}
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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 =
|
|
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}":
|
|
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}":
|
|
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
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
|
1277
|
-
|
|
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('
|
|
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
|
|
1299
|
-
//
|
|
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
|
|
1439
|
+
for (const id of heldSessionIds()) {
|
|
1303
1440
|
try {
|
|
1304
|
-
materializeInto(
|
|
1441
|
+
materializeInto(join(baseDir, 'sessions', id));
|
|
1305
1442
|
} catch {
|
|
1306
1443
|
/* best-effort */
|
|
1307
1444
|
}
|