flowviant 0.56.1 → 0.57.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.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * RUNNING THE PROJECT'S DEV COMMAND IN A TAB'S WORKTREE.
3
+ *
4
+ * THIS FILE MUST NEVER READ A REPO FILE TO DECIDE WHAT TO EXECUTE. That is the
5
+ * one rule, and it is the whole difference between this and the live-preview
6
+ * target deleted in 2026-08-21, whose obituary is in `preview.mjs`: that one
7
+ * read a command out of `.flowviant/preview.json` — a file the BRANCH controls
8
+ * — or inferred one from package.json, then `spawn(cmd, {shell: true})` with
9
+ * `{...process.env}`, running `npm install` and its lifecycle scripts and
10
+ * handing the resulting internet-exposed process the daemon's own credential.
11
+ * One click behind a button and a hostile branch owned the machine.
12
+ *
13
+ * Here the argv arrives ON THE JOB, already parsed from a string a human
14
+ * approved once for this project and stored server-side. The branch cannot
15
+ * change it. This file re-validates the SHAPE at its own boundary — one place
16
+ * doing a check is one deploy away from being zero places — and spawns with
17
+ * `shell: false`.
18
+ *
19
+ * THE HONEST LIMIT, stated here rather than implied: pinning the command does
20
+ * not pin what the command does. `npm run dev` dereferences to `scripts.dev`,
21
+ * which the branch writes. What this buys is that a human chose the ENTRYPOINT
22
+ * in the open, plus a child environment that is a strict subset of what the
23
+ * agent's own `npm run dev` gets today. The child runs as the SAME UID as this
24
+ * daemon and `~/.flowviant/credentials.json` is 0600 and readable by it. The
25
+ * env allowlist is a control against ACCIDENT AND INHERITANCE — a crash
26
+ * reporter, an error page that dumps `process.env`, a build log — and it is NOT
27
+ * confinement. Nothing in the UI may say "sandboxed" or "isolated".
28
+ *
29
+ * NO PORT IS EVER SCRAPED. The deleted feature learned its port from the
30
+ * child's stdout and tunnelled a guess when it found none. A scraped port has
31
+ * no attribution behind it, and "this port was measured, by cwd, inside THIS
32
+ * worktree" is the only real security control this feature family has. The port
33
+ * here comes from `listenersIn` or it does not come at all — and a running
34
+ * server with no measured port is a REAL state that gets a sentence.
35
+ */
36
+
37
+ import { spawn } from 'node:child_process';
38
+ import { existsSync } from 'node:fs';
39
+ import { join } from 'node:path';
40
+ import { homedir } from 'node:os';
41
+ import { childEnv } from './childEnv.mjs';
42
+ import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
43
+ import { listenersIn } from './listeners.mjs';
44
+ import { scrub } from './env.mjs';
45
+
46
+ const FLOWVIANT_DIR = join(homedir(), '.flowviant');
47
+ const REGISTRY = join(FLOWVIANT_DIR, 'devruns.json');
48
+ const REGISTRY_LOCK = join(FLOWVIANT_DIR, 'devruns.lock');
49
+
50
+ /** Output we keep. Only the tail is ever uplinked, and it is scrubbed on the
51
+ * way out: a dev server routinely prints connection strings. */
52
+ const RING_BYTES = 512 * 1024;
53
+ const TAIL_BYTES = 4096;
54
+ /** How long we wait for the command to bind something inside the worktree
55
+ * before reporting it as running-but-unmeasured. */
56
+ const BIND_WATCH_MS = 45_000;
57
+ /** At most this many restarts inside the window, and only for a run that bound
58
+ * at least once — a server that never bound is a broken command, not a crash,
59
+ * and restarting it burns the box. */
60
+ const MAX_RESTARTS = 3;
61
+ const RESTART_WINDOW_MS = 10 * 60_000;
62
+ const BACKOFF_MS = [2_000, 8_000, 30_000];
63
+ const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
64
+
65
+ const remember = (entry) =>
66
+ mutateRegistry(FLOWVIANT_DIR, REGISTRY, REGISTRY_LOCK, (list) => [
67
+ ...list.filter((e) => e.pid !== entry.pid),
68
+ entry,
69
+ ]);
70
+ const forget = (pid) =>
71
+ mutateRegistry(FLOWVIANT_DIR, REGISTRY, REGISTRY_LOCK, (list) =>
72
+ list.filter((e) => e.pid !== pid)
73
+ );
74
+
75
+ /** SIGTERM the GROUP, wait, then SIGKILL it. `npm run dev` spawns grandchildren
76
+ * that outlive a kill of the parent, which is what `detached: true` and the
77
+ * negative pid are for. */
78
+ function killGroup(pid, graceMs = 8_000) {
79
+ const signal = (sig) => {
80
+ try {
81
+ process.kill(-pid, sig);
82
+ } catch {
83
+ try {
84
+ process.kill(pid, sig);
85
+ } catch {
86
+ /* already gone */
87
+ }
88
+ }
89
+ };
90
+ signal('SIGTERM');
91
+ setTimeout(() => {
92
+ if (processAlive(pid)) signal('SIGKILL');
93
+ }, graceMs).unref?.();
94
+ }
95
+
96
+ /**
97
+ * THE FRESH-WORKTREE ANSWER, measured rather than discovered as a bug.
98
+ *
99
+ * `ensureWorktree` is a bare `git worktree add`, so a new tab has source and no
100
+ * `node_modules`, and the first run there would fail with something unhelpful.
101
+ * This is a measurement, and it routes dependency installation to the one place
102
+ * that should own it: a turn in the tab, with a human asking and an audit row
103
+ * for it. The button is never silently broken and the remedy is one sentence.
104
+ */
105
+ export function missingDeps(worktree, argv) {
106
+ if (!PACKAGE_MANAGERS.has(argv[0])) return false;
107
+ return !existsSync(join(worktree, 'node_modules'));
108
+ }
109
+
110
+ /**
111
+ * Start the command and supervise it.
112
+ *
113
+ * `onState` is called with `{started, port, pid, error, endedReason, logTail,
114
+ * restarts}` at each transition; the caller reports it upward. Resolves once
115
+ * the first outcome is known — bound, or running-unmeasured, or failed — and
116
+ * keeps supervising after that.
117
+ */
118
+ export function startDevServer({ sessionId, worktree, argv, log, onState, onExit }) {
119
+ if (missingDeps(worktree, argv)) {
120
+ return Promise.resolve({
121
+ ok: false,
122
+ endedReason: 'no_deps',
123
+ error:
124
+ "No dependencies are installed in this tab's worktree. Ask your Claude to install them, then run dev again.",
125
+ });
126
+ }
127
+
128
+ let ring = '';
129
+ let child = null;
130
+ let stopped = false;
131
+ let everBound = false;
132
+ let restarts = 0;
133
+ const restartTimes = [];
134
+
135
+ const append = (buf) => {
136
+ ring = (ring + buf.toString('utf8')).slice(-RING_BYTES);
137
+ };
138
+ const tail = () => scrub(ring.slice(-TAIL_BYTES));
139
+
140
+ const spawnOnce = () => {
141
+ child = spawn(argv[0], argv.slice(1), {
142
+ cwd: worktree,
143
+ env: childEnv({ cwd: worktree }),
144
+ // Its own process group, so the whole tree can be reaped. Load-bearing:
145
+ // a package manager is a wrapper and the server is its grandchild.
146
+ detached: true,
147
+ shell: false,
148
+ stdio: ['ignore', 'pipe', 'pipe'],
149
+ });
150
+ // PERMANENT DRAIN LISTENERS, and this is not optional. A detached
151
+ // long-lived child on piped stdio with no reader BLOCKS ON WRITE once the
152
+ // pipe buffer fills, so a dev server would hang after a few minutes of HMR
153
+ // logs — the least diagnosable failure this feature could have.
154
+ child.stdout?.on('data', append);
155
+ child.stderr?.on('data', append);
156
+ return child;
157
+ };
158
+
159
+ return new Promise((resolve) => {
160
+ let settled = false;
161
+ const finish = (v) => {
162
+ if (settled) return;
163
+ settled = true;
164
+ resolve(v);
165
+ };
166
+
167
+ const attachExit = () => {
168
+ child.once('error', (e) => {
169
+ forget(child?.pid);
170
+ finish({ ok: false, endedReason: 'spawn_failed', error: String(e?.message || e) });
171
+ });
172
+ child.once('exit', (code, signal) => {
173
+ const pid = child?.pid;
174
+ forget(pid);
175
+ if (stopped) return;
176
+ // A command that NEVER bound is a broken command, not a crash. Restarting
177
+ // it would spin the box on somebody's typo.
178
+ const now = Date.now();
179
+ while (restartTimes.length && now - restartTimes[0] > RESTART_WINDOW_MS) restartTimes.shift();
180
+ if (everBound && restartTimes.length < MAX_RESTARTS) {
181
+ const delay = BACKOFF_MS[Math.min(restartTimes.length, BACKOFF_MS.length - 1)];
182
+ restartTimes.push(now);
183
+ restarts += 1;
184
+ log?.(`dev server exited (${signal || code}); restarting in ${delay / 1000}s`);
185
+ setTimeout(() => {
186
+ if (stopped) return;
187
+ spawnOnce();
188
+ attachExit();
189
+ remember(entryFor());
190
+ onState?.({ started: true, port: null, pid: child.pid, restarts, logTail: tail() });
191
+ }, delay).unref?.();
192
+ return;
193
+ }
194
+ onExit?.({
195
+ exitCode: typeof code === 'number' ? code : null,
196
+ signal: signal || null,
197
+ logTail: tail(),
198
+ endedReason: everBound ? 'crashed' : 'spawn_failed',
199
+ error: everBound
200
+ ? `the dev server exited (${signal || `code ${code}`})`
201
+ : `the command exited immediately (${signal || `code ${code}`}) without listening`,
202
+ });
203
+ finish({ ok: false, endedReason: everBound ? 'crashed' : 'spawn_failed' });
204
+ });
205
+ };
206
+
207
+ const entryFor = () => ({
208
+ sessionId,
209
+ pid: child.pid,
210
+ cwd: worktree,
211
+ startedAt: Date.now(),
212
+ owner: process.pid,
213
+ });
214
+
215
+ try {
216
+ spawnOnce();
217
+ } catch (e) {
218
+ finish({ ok: false, endedReason: 'spawn_failed', error: String(e?.message || e) });
219
+ return;
220
+ }
221
+ attachExit();
222
+ remember(entryFor());
223
+
224
+ // WATCH FOR THE BIND through `listenersIn` and nowhere else.
225
+ const deadline = Date.now() + BIND_WATCH_MS;
226
+ const poll = setInterval(() => {
227
+ if (stopped || !child || child.exitCode !== null) {
228
+ clearInterval(poll);
229
+ return;
230
+ }
231
+ const found = listenersIn(worktree)[0];
232
+ if (found) {
233
+ clearInterval(poll);
234
+ everBound = true;
235
+ onState?.({ started: true, port: found.port, pid: child.pid, restarts, logTail: tail() });
236
+ finish({ ok: true, port: found.port, pid: child.pid, stop, restarts });
237
+ return;
238
+ }
239
+ if (Date.now() > deadline) {
240
+ clearInterval(poll);
241
+ // RUNNING, NOTHING MEASURED. A real state and not a spinner: a
242
+ // `docker compose up` binds from inside a container whose cwd is not
243
+ // this worktree and will never be attributed.
244
+ onState?.({ started: true, port: null, pid: child.pid, restarts, logTail: tail() });
245
+ finish({ ok: true, port: null, pid: child.pid, stop, restarts });
246
+ }
247
+ }, 1000);
248
+ poll.unref?.();
249
+
250
+ function stop() {
251
+ stopped = true;
252
+ clearInterval(poll);
253
+ const pid = child?.pid;
254
+ if (pid) {
255
+ killGroup(pid);
256
+ forget(pid);
257
+ }
258
+ }
259
+ });
260
+ }
261
+
262
+ /**
263
+ * What survived a daemon restart.
264
+ *
265
+ * IDENTITY IS NOT THE COMMAND STRING. `stillOurs` in `preview.mjs` matches a
266
+ * substring of `/proc/<pid>/cmdline`, which cannot work here: `npm run dev` is
267
+ * a human-authored string identical across two tabs, two worktrees, and the
268
+ * driver's own hand-started server. Identity is the CWD — the same readlink
269
+ * `listeners.mjs` already does — plus the pid still being alive. Both must
270
+ * hold, and a could-not-measure is NOT a match and kills nothing.
271
+ */
272
+ export function adoptableDevRuns() {
273
+ const out = [];
274
+ for (const e of readRegistry(REGISTRY)) {
275
+ if (!e || typeof e.sessionId !== 'string' || !Number.isInteger(e.pid)) continue;
276
+ // Another LIVE daemon owns it — leave it alone entirely.
277
+ if (e.owner && e.owner !== process.pid && processAlive(e.owner)) continue;
278
+ if (!processAlive(e.pid)) continue;
279
+ out.push(e);
280
+ }
281
+ return out;
282
+ }
283
+
284
+ /** Kill a registry entry's process group and drop the row. Used when the
285
+ * session it belonged to is gone. */
286
+ export function killDevRunEntry(entry) {
287
+ if (!entry || !Number.isInteger(entry.pid)) return;
288
+ killGroup(entry.pid);
289
+ forget(entry.pid);
290
+ }
291
+
292
+ /** Adopt or reap what the previous process left behind. `activeIds` is the set
293
+ * of sessions still live on the server; a run whose session is gone is killed,
294
+ * and one whose session survives is handed back to the caller to re-supervise. */
295
+ export function reapOrphanDevRuns(activeIds, log) {
296
+ const adopt = [];
297
+ for (const e of adoptableDevRuns()) {
298
+ if (Array.isArray(activeIds) && !activeIds.includes(e.sessionId)) {
299
+ log?.(`dev server for a closed tab (pid ${e.pid}) — stopping it`);
300
+ killDevRunEntry(e);
301
+ continue;
302
+ }
303
+ adopt.push(e);
304
+ }
305
+ return adopt;
306
+ }
package/bin/lib/fleet.mjs CHANGED
@@ -76,11 +76,23 @@ import {
76
76
  } from './env.mjs';
77
77
  import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
78
78
  import { machineSnapshot } from './resources.mjs';
79
- import { detectRuntimes, knownSkills, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
79
+ import {
80
+ detectRuntimes,
81
+ knownSkills,
82
+ pickRuntimeFor,
83
+ probeSkillsOnce,
84
+ recordSkills,
85
+ RUNTIMES,
86
+ } from './runtimes.mjs';
80
87
  import { createWorkManager } from './work.mjs';
81
88
  import { scanLocalSessions } from './localSessions.mjs';
82
89
 
83
- async function fetchRoster(haveIds, livePreviewSessionIds = [], heldSessionIds = []) {
90
+ async function fetchRoster(
91
+ haveIds,
92
+ livePreviewSessionIds = [],
93
+ heldSessionIds = [],
94
+ liveDevRunSessionIds = []
95
+ ) {
84
96
  const url = new URL(FLEET_URL);
85
97
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
86
98
  // What this machine will run at once. The server grows lanes to meet waiting
@@ -110,6 +122,11 @@ async function fetchRoster(haveIds, livePreviewSessionIds = [], heldSessionIds =
110
122
  // the server still calls live is a 530 on somebody's phone. Always set, even
111
123
  // empty: '' means "serving none", absent would mean "an older daemon".
112
124
  url.searchParams.set('pv', livePreviewSessionIds.join(','));
125
+ // The dev runs this machine is still carrying. Same rule as `pv`: always set,
126
+ // even empty — '' means "running none", absent would mean "an older daemon",
127
+ // and a run the server still calls running with nothing behind it is a chip
128
+ // that says Serving over a dead port.
129
+ url.searchParams.set('dr', liveDevRunSessionIds.join(','));
113
130
  // The sessions this daemon holds a worktree for. Its LEASE on each renews
114
131
  // here — one beat, no extra endpoint, and the server can tell "this daemon is
115
132
  // still serving that tab" from "it went away" within a reconcile interval
@@ -440,6 +457,11 @@ export async function runFleetDaemon() {
440
457
  // Kill any preview dev-server/tunnel groups a previously-crashed daemon left
441
458
  // running (detached children survive an ungraceful exit) before we start fresh.
442
459
  reapOrphanPreviews((m) => info(m));
460
+ // Dev servers are ADOPTED rather than reaped when their session is still
461
+ // live: a self-update or a same-repo takeover leaves them running on purpose,
462
+ // and the successor must supervise them or the row says running while nothing
463
+ // owns the process. The activeWorkSessions list is not known yet at startup,
464
+ // so the first reconcile does the adopting — see `adoptDevRuns`.
443
465
 
444
466
  // Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
445
467
  // so Ctrl+C mid-task never loses local work. Keyed per repo path.
@@ -558,6 +580,25 @@ export async function runFleetDaemon() {
558
580
  // Detached tunnels survive our exit by design, so leaving them would strand
559
581
  // a public hostname until the box rebooted.
560
582
  shutdownPreviews();
583
+ // AND THE DEV SERVERS, on every path that reaches here — all four are a
584
+ // stand-down: Ctrl-C, a service manager's SIGTERM, a revoked credential, a
585
+ // commanded stop.
586
+ //
587
+ // A SAME-REPO TAKEOVER IS DELIBERATELY NOT DISTINGUISHED, and that is a
588
+ // stated limitation rather than an oversight. The takeover asks the holder
589
+ // to stand down with SIGTERM, which is byte-for-byte what `systemctl stop`
590
+ // sends, so this handler cannot tell "another daemon is about to serve this
591
+ // repo in one second" from "this box is going down". Killing is the safe
592
+ // reading: the wrong guess in the other direction leaves dev servers
593
+ // running with nothing supervising them until a reboot. The cost is that
594
+ // re-running `flowviant` in your own repo restarts the app you were
595
+ // watching, and the successor's `adoptDevRuns` finds nothing.
596
+ //
597
+ // The SELF-UPDATE re-exec — the common unattended case — does NOT reach
598
+ // teardown, so its runs survive and are adopted, which is the half of
599
+ // "consistently running" this delivers today. Distinguishing a takeover
600
+ // would need the successor to mark the lock before it signals.
601
+ shutdownDevRuns(true);
561
602
  };
562
603
  process.on('SIGINT', () => {
563
604
  console.log('');
@@ -734,6 +775,11 @@ export async function runFleetDaemon() {
734
775
  livePreviewIds,
735
776
  retirePreviews,
736
777
  shutdownPreviews,
778
+ processDevRunJobs,
779
+ liveDevRunIds,
780
+ retireDevRuns,
781
+ shutdownDevRuns,
782
+ adoptDevRuns,
737
783
  retireWorkSessions,
738
784
  reportWorktrees,
739
785
  shutdownWork,
@@ -1185,6 +1231,13 @@ export async function runFleetDaemon() {
1185
1231
  label: c.cyan('[wiki]'),
1186
1232
  streamJson: true,
1187
1233
  onActivity,
1234
+ // FREE SKILLS, off a turn that was running anyway. This stream is
1235
+ // already parsed and its init event already carries the CLI's own
1236
+ // resolved skill set — the same fact a tab turn teaches — so the
1237
+ // only thing missing was the handler. The wiki turn runs in a
1238
+ // detached worktree of THIS repo, so its `.claude/skills` and the
1239
+ // machine's personal ones resolve identically to a tab's.
1240
+ onInit: (i) => recordSkills(i.skills),
1188
1241
  onSpawn: (ch) => {
1189
1242
  wikiChild = ch;
1190
1243
  },
@@ -1229,6 +1282,8 @@ export async function runFleetDaemon() {
1229
1282
  label: c.cyan('[wiki]'),
1230
1283
  streamJson: true,
1231
1284
  onActivity,
1285
+ // Same free harvest as the sweep above.
1286
+ onInit: (i) => recordSkills(i.skills),
1232
1287
  onSpawn: (ch) => {
1233
1288
  wikiChild = ch;
1234
1289
  },
@@ -1330,7 +1385,7 @@ export async function runFleetDaemon() {
1330
1385
  for (;;) {
1331
1386
  let roster;
1332
1387
  try {
1333
- roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds());
1388
+ roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds(), liveDevRunIds());
1334
1389
  } catch (e) {
1335
1390
  if (e.auth) {
1336
1391
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
@@ -1449,6 +1504,18 @@ export async function runFleetDaemon() {
1449
1504
  // in a directory that no longer exists — a human is shown the wrong thing
1450
1505
  // and nothing errors anywhere.
1451
1506
  retirePreviews(roster.activeWorkSessions);
1507
+ // THEN the process the tunnel pointed at, and only then the worktree. A
1508
+ // viewer must not see a 502 from a gate whose origin vanished, and
1509
+ // `retireWorkSessions`'s dirty check inspects only TRACKED files — so
1510
+ // `git worktree remove` would happily pull the directory out from under a
1511
+ // running node process, which then serves bytes from open file handles in
1512
+ // a directory that no longer exists, with no error anywhere.
1513
+ // ADOPT BEFORE RETIRING. A run left alive by a self-update or a takeover
1514
+ // must be re-attached before the retire pass can decide it is unowned;
1515
+ // doing it the other way round would kill exactly the processes this
1516
+ // feature exists to keep.
1517
+ adoptDevRuns(roster.activeWorkSessions);
1518
+ retireDevRuns(roster.activeWorkSessions);
1452
1519
  // A session another daemon on this credential is serving is NOT a closed
1453
1520
  // tab. Without this the daemon that lost the lease removes the worktree the
1454
1521
  // winner is working in — absence would mean "somebody else won" instead of
@@ -1467,6 +1534,7 @@ export async function runFleetDaemon() {
1467
1534
  // credential are both handed this array, and both opening a tunnel strands
1468
1535
  // a public hostname nobody can settle.
1469
1536
  processPreviewJobs(roster.previewJobs);
1537
+ processDevRunJobs(roster.devRunJobs);
1470
1538
  // …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
1471
1539
  // Throttled inside, never awaited — a `git status` the human cannot run
1472
1540
  // themselves from a browser, relayed. After retirement so a directory that
@@ -1476,6 +1544,13 @@ export async function runFleetDaemon() {
1476
1544
  // the daemon's own worktrees are carved out (a session the daemon spawned
1477
1545
  // is already a tab, not something to offer adopting).
1478
1546
  void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
1547
+ // WHAT `/` CAN OFFER, on a machine no turn has taught yet. One-shot and
1548
+ // self-cancelling (it returns immediately if a turn has already reported),
1549
+ // never awaited, and it lands in the cache that the NEXT poll reads — so
1550
+ // nothing here waits on a child process. In the loop rather than at
1551
+ // startup on purpose: a daemon that has been up since before this release
1552
+ // gets measured too, without needing a restart to earn its own menu.
1553
+ probeSkillsOnce(repoRoot);
1479
1554
  processCleanupJobs(roster.cleanupJobs);
1480
1555
  const rosterIds = new Set(roster.agents.map((a) => a.agentId));
1481
1556
 
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A DISK REGISTRY OF DETACHED PROCESSES — one implementation, two users.
3
+ *
4
+ * The daemon spawns things that outlive it: cloudflared tunnels, and now dev
5
+ * servers. If the daemon dies without tearing them down, the successor has to
6
+ * find them — a public tunnel nobody is minding, or a dev server holding a port
7
+ * in a worktree about to be `git worktree remove`d, are both worse than the
8
+ * crash that caused them.
9
+ *
10
+ * Lifted verbatim out of `preview.mjs`, which grew all of this for the tunnel
11
+ * and now shares it rather than being copied. Two things were ADDED on the way
12
+ * out, because a dev server lives for hours where a tunnel lived for minutes
13
+ * and long-lived rows make both matter:
14
+ *
15
+ * - an entry CAP, so a registry cannot grow without bound;
16
+ * - a TTL sweep for entries whose pid is long dead, so a file nobody prunes
17
+ * does not become a file nobody can read.
18
+ */
19
+
20
+ import {
21
+ closeSync,
22
+ mkdirSync,
23
+ openSync,
24
+ readFileSync,
25
+ renameSync,
26
+ statSync,
27
+ unlinkSync,
28
+ writeFileSync,
29
+ } from 'node:fs';
30
+
31
+ const LOCK_STALE_MS = 15_000;
32
+ const MAX_ENTRIES = 32;
33
+ const ENTRY_TTL_MS = 7 * 24 * 60 * 60_000;
34
+
35
+ /**
36
+ * Best-effort exclusive lock. Returns a release function; on failure returns
37
+ * null and the caller proceeds UNLOCKED — losing an entry is bad, refusing to
38
+ * record one at all is worse.
39
+ */
40
+ export function acquireLock(dir, lockPath) {
41
+ try {
42
+ mkdirSync(dir, { recursive: true });
43
+ } catch {
44
+ return null;
45
+ }
46
+ for (let i = 0; i < 30; i++) {
47
+ try {
48
+ const fd = openSync(lockPath, 'wx');
49
+ closeSync(fd);
50
+ return () => {
51
+ try {
52
+ unlinkSync(lockPath);
53
+ } catch {
54
+ /* already released */
55
+ }
56
+ };
57
+ } catch {
58
+ // Held — unless it was left behind by something that died holding it.
59
+ try {
60
+ if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
61
+ unlinkSync(lockPath);
62
+ continue;
63
+ }
64
+ } catch {
65
+ continue;
66
+ }
67
+ const until = Date.now() + 20;
68
+ while (Date.now() < until) {
69
+ /* busy-wait: 20ms, 30 times, then give up entirely */
70
+ }
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ export function readRegistry(path) {
77
+ try {
78
+ const v = JSON.parse(readFileSync(path, 'utf8'));
79
+ return Array.isArray(v) ? v : [];
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ /** Atomic: write a sibling temp file and rename over the target, so a reader
86
+ * never sees a half-written array. */
87
+ export function writeRegistry(dir, path, list) {
88
+ try {
89
+ mkdirSync(dir, { recursive: true });
90
+ const tmp = `${path}.${process.pid}.tmp`;
91
+ writeFileSync(tmp, JSON.stringify(list));
92
+ renameSync(tmp, path);
93
+ } catch {
94
+ /* best-effort */
95
+ }
96
+ }
97
+
98
+ /** Signal-0 liveness. EPERM means alive and not ours, which is still alive. */
99
+ export function processAlive(pid) {
100
+ if (!Number.isInteger(pid) || pid <= 0) return false;
101
+ try {
102
+ process.kill(pid, 0);
103
+ return true;
104
+ } catch (e) {
105
+ return e.code === 'EPERM';
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Read-modify-write under the lock, then prune.
111
+ *
112
+ * The prune is here rather than at the call sites so it cannot be forgotten by
113
+ * one of them: an entry whose pid has been dead for a week is not a process
114
+ * anybody is going to reap, and keeping it only makes the next reader slower
115
+ * and the next pid collision more likely.
116
+ */
117
+ export function mutateRegistry(dir, path, lockPath, fn) {
118
+ const release = acquireLock(dir, lockPath);
119
+ try {
120
+ const next = fn(readRegistry(path));
121
+ const now = Date.now();
122
+ const pruned = next
123
+ .filter((e) => {
124
+ if (processAlive(e?.pid)) return true;
125
+ const started = Number(e?.startedAt ?? 0);
126
+ return started > 0 && now - started < ENTRY_TTL_MS;
127
+ })
128
+ .slice(-MAX_ENTRIES);
129
+ writeRegistry(dir, path, pruned);
130
+ return pruned;
131
+ } finally {
132
+ release?.();
133
+ }
134
+ }
@@ -36,9 +36,9 @@
36
36
  * machine HAVE" — activity, never capacity, and never a default we invented.
37
37
  */
38
38
 
39
- import { execFileSync } from 'node:child_process';
40
- import { mkdtempSync, writeFileSync } from 'node:fs';
41
- import { tmpdir } from 'node:os';
39
+ import { execFileSync, spawn } from 'node:child_process';
40
+ import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
41
+ import { homedir, tmpdir } from 'node:os';
42
42
  import { join } from 'node:path';
43
43
  import { SAFE, MODEL, USER_AGENT } from './config.mjs';
44
44
 
@@ -976,3 +976,191 @@ export function recordSkills(names) {
976
976
  export function knownSkills() {
977
977
  return skillsCache;
978
978
  }
979
+
980
+ /**
981
+ * LEARN WHAT `/` CAN OFFER, ON A MACHINE NO TURN HAS TAUGHT.
982
+ *
983
+ * WHY THIS EXISTS. `recordSkills` above is fed from the init event of a tab
984
+ * turn — authoritative and free, but with a hole nobody priced: THE FIRST THING
985
+ * ANYONE DOES IN A NEW TAB IS TYPE `/`, and that is by definition before that
986
+ * machine has run a turn. The menu was guaranteed empty exactly where it is
987
+ * first reached. Not a theoretical hole: checked against production on
988
+ * 2026-08-25, `agent_tokens.skills` was NULL for EVERY machine credential that
989
+ * has ever existed, because the only tab turns ever run predated the release
990
+ * that reports. The feature had never worked for anyone, once.
991
+ *
992
+ * IT COSTS ONE SMALL REQUEST, AND THAT IS THE HONEST NUMBER. This file used to
993
+ * forbid probing outright — "a `claude -p` run purely to populate a dropdown
994
+ * would spend the operator's quota on an affordance" — and that rule was
995
+ * written picturing a COMPLETED TURN. This is not one: Claude Code emits
996
+ * `system.init`, carrying its own fully resolved skill set, within ~0.5s and
997
+ * long before it finishes answering, so the child is killed the moment that
998
+ * event is read. But the request HAS gone out by then — measured on 2.1.245 by
999
+ * reading the transcript a killed probe left behind: 2 input tokens, 4 output,
1000
+ * ~6k cache-creation. Two zero-request routes were tried and both failed: an
1001
+ * empty prompt errors before init is emitted, and `--input-format stream-json`
1002
+ * emits nothing at all until a message arrives. So the cost is one cheap turn,
1003
+ * ONCE per daemon process, on the cheapest model, and only on a machine no turn
1004
+ * has taught. Do not let this grow into a per-tab or per-poll probe.
1005
+ *
1006
+ * `--model haiku` for that reason, and it is safe by this repo's own rule: a
1007
+ * name lives in AGENT_MODELS only once `claude --model <name>` is known to be
1008
+ * accepted on a real install. If it were ever refused the probe simply learns
1009
+ * nothing and the machine stays unmeasured — which is exactly today's state, so
1010
+ * the failure mode is the status quo rather than a regression.
1011
+ *
1012
+ * IT SCANS FOR THE INIT EVENT, never assuming it is line 1 — and that is not
1013
+ * defensive padding, it is measured. With `--model haiku` the CLI prints a
1014
+ * `system/status` line FIRST and init lands on line 2; with the default model
1015
+ * init is line 1. A first-line-only reader (the version this replaced) silently
1016
+ * learned nothing the moment a model flag was added.
1017
+ *
1018
+ * IT CLEANS UP AFTER ITSELF, and this is not optional. `claude -p` writes a
1019
+ * transcript to `~/.claude/projects/<munged-cwd>/<session-id>.jsonl` the moment
1020
+ * it starts, and `localSessions.mjs` reports the newest ENDED session per
1021
+ * directory to the Workbench as an ADOPTABLE row. Left behind, every daemon
1022
+ * start would put a phantom untitled session in the `+` menu offering to adopt
1023
+ * a conversation that never happened. The init event names its own session id,
1024
+ * so the file is ours by name and is removed by it.
1025
+ *
1026
+ * IT IS BEST-EFFORT IN EVERY DIRECTION. No claude, no PATH, a CLI that changed
1027
+ * its event shape, an unwritable home — all leave `skillsCache` exactly as it
1028
+ * was (null, "nobody looked"), which is the honest answer and the one the app
1029
+ * already renders correctly. Nothing here may throw, and nothing may block the
1030
+ * poll it is called from.
1031
+ */
1032
+
1033
+ /** Long enough for a cold CLI start on a slow box, short enough that a hung
1034
+ * child is not left holding a session for the life of the daemon. */
1035
+ const SKILL_PROBE_TIMEOUT_MS = 30_000;
1036
+
1037
+ /** Init arrives within the first couple of events or not at all; this is the
1038
+ * guard against parsing a whole turn's output looking for it. */
1039
+ const SKILL_PROBE_MAX_LINES = 20;
1040
+
1041
+ let skillProbeStarted = false;
1042
+
1043
+ /** Where Claude Code keeps a transcript for `cwd`: `/` and `.` both become `-`. */
1044
+ function transcriptCandidates(cwd, sessionId) {
1045
+ const base = join(homedir(), '.claude', 'projects');
1046
+ const out = [join(base, cwd.replace(/[/.]/g, '-'), `${sessionId}.jsonl`)];
1047
+ // The munge is Claude Code's, not ours, so a version that changes it must not
1048
+ // leave the file behind: fall back to finding our own session id by name.
1049
+ try {
1050
+ for (const d of readdirSync(base)) out.push(join(base, d, `${sessionId}.jsonl`));
1051
+ } catch {
1052
+ /* no project store — nothing was written either */
1053
+ }
1054
+ return out;
1055
+ }
1056
+
1057
+ function removeProbeTranscript(cwd, sessionId) {
1058
+ if (!sessionId || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) return;
1059
+ for (const f of transcriptCandidates(cwd, sessionId)) {
1060
+ try {
1061
+ if (existsSync(f)) {
1062
+ rmSync(f, { force: true });
1063
+ return;
1064
+ }
1065
+ } catch {
1066
+ /* best-effort */
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ /**
1072
+ * One line of the probe's stdout → the init event's payload, or null for "not
1073
+ * it, keep reading".
1074
+ *
1075
+ * SEPARATE FROM THE SCAN LOOP so the thing that actually broke can be tested
1076
+ * without spawning a CLI. The first version of this probe read line 1 and
1077
+ * stopped, which was measured-correct with the default model and silently
1078
+ * WRONG with `--model haiku`: that path prints a `system/status` line first and
1079
+ * puts init on line 2, so the probe learned nothing and reported nothing, which
1080
+ * is indistinguishable from the bug it was written to fix.
1081
+ *
1082
+ * A line that is not JSON is not a failure — a CLI warning on stdout is a line
1083
+ * to skip, not a reason to abandon the probe.
1084
+ */
1085
+ export function parseInitLine(line) {
1086
+ let ev;
1087
+ try {
1088
+ ev = JSON.parse(line);
1089
+ } catch {
1090
+ return null;
1091
+ }
1092
+ if (!ev || ev.type !== 'system' || ev.subtype !== 'init') return null;
1093
+ return {
1094
+ // An init event WITHOUT skills is still the init event: stop reading, but
1095
+ // record nothing. Conflating the two would keep the probe scanning a whole
1096
+ // turn's output on a CLI that does not report them.
1097
+ skills: Array.isArray(ev.skills) ? ev.skills : null,
1098
+ sessionId: typeof ev.session_id === 'string' ? ev.session_id : null,
1099
+ };
1100
+ }
1101
+
1102
+ /**
1103
+ * Kick off the one-shot probe. Returns immediately; the result lands in
1104
+ * `skillsCache` and rides the NEXT poll, so nothing waits on it.
1105
+ *
1106
+ * A no-op once a turn has taught us (`skillsCache !== null`) — a turn's init
1107
+ * event and this one say the same thing, and the turn is free.
1108
+ */
1109
+ export function probeSkillsOnce(cwd) {
1110
+ if (skillProbeStarted || skillsCache !== null) return;
1111
+ skillProbeStarted = true;
1112
+ let child;
1113
+ try {
1114
+ // Claude Code specifically, not the `wiki` profile's runtime pick: `skills`
1115
+ // is Claude Code's own field and the `/` tray only renders for claude tabs.
1116
+ child = spawn(
1117
+ RUNTIMES.claude.bin,
1118
+ ['-p', 'x', '--model', 'haiku', '--output-format', 'stream-json', '--verbose'],
1119
+ { cwd, stdio: ['ignore', 'pipe', 'ignore'] }
1120
+ );
1121
+ } catch {
1122
+ return; // no claude on PATH — the machine simply stays unmeasured
1123
+ }
1124
+ let buf = '';
1125
+ let lines = 0;
1126
+ let settled = false;
1127
+ const finish = (sessionId) => {
1128
+ if (settled) return;
1129
+ settled = true;
1130
+ clearTimeout(timer);
1131
+ try {
1132
+ child.kill('SIGKILL');
1133
+ } catch {
1134
+ /* already gone */
1135
+ }
1136
+ // AFTER the kill, and on a delay: the transcript is the CHILD's file, so
1137
+ // deleting it while the child still lives races a recreate.
1138
+ if (sessionId) setTimeout(() => removeProbeTranscript(cwd, sessionId), 750).unref?.();
1139
+ };
1140
+ const timer = setTimeout(() => finish(null), SKILL_PROBE_TIMEOUT_MS);
1141
+ timer.unref?.();
1142
+ child.on('error', () => finish(null));
1143
+ child.on('exit', () => finish(null));
1144
+ child.stdout.on('data', (d) => {
1145
+ if (settled) return;
1146
+ buf += d.toString();
1147
+ let nl;
1148
+ while (!settled && (nl = buf.indexOf('\n')) >= 0) {
1149
+ const line = buf.slice(0, nl);
1150
+ buf = buf.slice(nl + 1);
1151
+ if (!line.trim()) continue;
1152
+ if (++lines > SKILL_PROBE_MAX_LINES) {
1153
+ finish(null);
1154
+ return;
1155
+ }
1156
+ const init = parseInitLine(line);
1157
+ if (!init) continue;
1158
+ if (init.skills) recordSkills(init.skills);
1159
+ finish(init.sessionId);
1160
+ return;
1161
+ }
1162
+ // A single line this long is not an init event; stop buffering a whole
1163
+ // turn's output into memory waiting for one.
1164
+ if (!settled && buf.length > 1_000_000) finish(null);
1165
+ });
1166
+ }
package/bin/lib/work.mjs CHANGED
@@ -38,8 +38,9 @@ import {
38
38
  DAEMON_INSTANCE,
39
39
  } from './config.mjs';
40
40
  import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
41
- import { listenersIn } from './listeners.mjs';
41
+ import { listenersIn, listenersSupported } from './listeners.mjs';
42
42
  import { openTunnel } from './preview.mjs';
43
+ import { startDevServer, reapOrphanDevRuns, killDevRunEntry } from './devServer.mjs';
43
44
  import { c, note, ok, warn } from './ui.mjs';
44
45
  import { mcpFor, runTurn } from './claude.mjs';
45
46
  import {
@@ -102,6 +103,8 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
102
103
  const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
103
104
  const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
104
105
  const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
106
+ const DEV_RUN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-claim');
107
+ const DEV_RUN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-done');
105
108
  const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
106
109
  const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
107
110
  const workAnswering = new Set(); // turn ids currently queued/running here
@@ -350,7 +353,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
350
353
  // The browser NEVER names a directory and never names a port this did not
351
354
  // report: ports are global to a box and a worktree is not, so this
352
355
  // measurement is the security boundary for the whole preview feature.
353
- return { sessionId, ...d, listening: listenersIn(wt) };
356
+ // `listeningSupported` says whether this machine can measure AT ALL, which
357
+ // is a different fact from finding nothing. Windows reports nothing and a
358
+ // failed scan reports nothing, and both were indistinguishable from an idle
359
+ // worktree — harmless while the only consumer needed a NON-empty array, and
360
+ // a permanent `Starting…` the moment a Run dev offer hangs off an empty one.
361
+ return {
362
+ sessionId,
363
+ ...d,
364
+ listening: listenersIn(wt),
365
+ listeningSupported: listenersSupported(),
366
+ };
354
367
  };
355
368
  /** One session, now — called after its turn settles. */
356
369
  const reportSessionWorktree = async (sessionId) => {
@@ -634,6 +647,259 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
634
647
  /** The sessionIds this machine is still serving — sent on the poll so the
635
648
  * server can tell a live share from one whose machine went away. Silence
636
649
  * must never read as "live". */
650
+ /**
651
+ * The daemon's own shape check on an argv the server parsed.
652
+ *
653
+ * Deliberately a SHAPE check and not a re-parse: the server owns the policy
654
+ * (which argv[0] are allowed, the install refusal, the length caps) and the
655
+ * machine owns the refusal to EXECUTE something malformed. It is duplicated
656
+ * rather than imported because this package ships standalone and cannot
657
+ * depend on the monorepo — the mirror is small, and `devCommand.ts` is where
658
+ * the real rules live.
659
+ */
660
+ const isPlausibleDevArgv = (argv) =>
661
+ Array.isArray(argv) &&
662
+ argv.length > 0 &&
663
+ argv.length <= 8 &&
664
+ argv.every((a) => typeof a === 'string' && a.length > 0 && a.length <= 200) &&
665
+ !argv.some((a) => /[&|;<>`$(){}*?~\\]/.test(a));
666
+
667
+ // ── DEV RUNS ──────────────────────────────────────────────────────────
668
+ //
669
+ // The web asks for the PROJECT'S STORED command to run in a tab's worktree.
670
+ // The argv arrives on the job, already parsed from a string a human approved;
671
+ // nothing here reads a repo file to decide what executes. See devServer.mjs.
672
+ const liveDevRuns = new Map(); // sessionId -> { stop, pid }
673
+ const devRunClaiming = new Set();
674
+
675
+ const postDevRun = async (body) => {
676
+ try {
677
+ await fetch(DEV_RUN_DONE_URL, {
678
+ method: 'POST',
679
+ headers: {
680
+ Authorization: `Bearer ${FLEET_TOKEN}`,
681
+ 'User-Agent': USER_AGENT,
682
+ 'Content-Type': 'application/json',
683
+ },
684
+ signal: AbortSignal.timeout(30_000),
685
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
686
+ });
687
+ } catch {
688
+ /* the row stops being confirmed and reads as stopped — which is true */
689
+ }
690
+ };
691
+
692
+ const claimDevRun = async (sessionId) => {
693
+ try {
694
+ const res = await fetch(DEV_RUN_CLAIM_URL, {
695
+ method: 'POST',
696
+ headers: {
697
+ Authorization: `Bearer ${FLEET_TOKEN}`,
698
+ 'User-Agent': USER_AGENT,
699
+ 'Content-Type': 'application/json',
700
+ },
701
+ signal: AbortSignal.timeout(30_000),
702
+ body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
703
+ });
704
+ const j = await res.json().catch(() => ({}));
705
+ return Boolean(j?.data?.claimed);
706
+ } catch {
707
+ return false;
708
+ }
709
+ };
710
+
711
+ const stopDevRun = async (sessionId, reason) => {
712
+ const live = liveDevRuns.get(sessionId);
713
+ liveDevRuns.delete(sessionId);
714
+ try {
715
+ live?.stop?.();
716
+ } catch {
717
+ /* best-effort */
718
+ }
719
+ await postDevRun({ sessionId, ended: true, endedReason: reason });
720
+ };
721
+
722
+ const processDevRunJobs = (jobs) => {
723
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
724
+ for (const job of jobs.slice(0, 5)) {
725
+ const sessionId = String(job?.sessionId || '');
726
+ if (!isSafePathSegment(sessionId)) continue;
727
+
728
+ if (job?.action === 'stop') {
729
+ if (devRunClaiming.has(sessionId)) continue;
730
+ devRunClaiming.add(sessionId);
731
+ void stopDevRun(sessionId, 'stopped').finally(() => devRunClaiming.delete(sessionId));
732
+ continue;
733
+ }
734
+
735
+ // RE-VALIDATE THE SHAPE at this boundary. The server parsed the string
736
+ // and owns the policy; the machine owns the refusal to execute something
737
+ // malformed, because one place doing a check is one deploy away from
738
+ // being zero places.
739
+ const argv = Array.isArray(job?.argv) ? job.argv.map(String) : [];
740
+ if (!isPlausibleDevArgv(argv)) {
741
+ void postDevRun({
742
+ sessionId,
743
+ started: false,
744
+ endedReason: 'refused',
745
+ error: 'the machine did not recognise that command',
746
+ });
747
+ continue;
748
+ }
749
+ // Already serving this tab. Re-starting would kill a server somebody is
750
+ // looking at right now.
751
+ if (liveDevRuns.has(sessionId)) continue;
752
+ if (devRunClaiming.has(sessionId)) continue;
753
+ devRunClaiming.add(sessionId);
754
+
755
+ void (async () => {
756
+ try {
757
+ if (!(await claimDevRun(sessionId))) return; // somebody else has it
758
+ const wt = join(baseDir, 'sessions', sessionId);
759
+ const r = await startDevServer({
760
+ sessionId,
761
+ worktree: wt,
762
+ argv,
763
+ log: (m) => note(`dev ${sessionId.slice(0, 8)}: ${m}`),
764
+ onState: (st) => {
765
+ void postDevRun({ sessionId, ...st });
766
+ // Re-report the worktree at once so the chip flips within a
767
+ // second instead of waiting up to 60s for the sweep.
768
+ void reportSessionWorktree(sessionId).catch(() => undefined);
769
+ },
770
+ onExit: (ex) => {
771
+ liveDevRuns.delete(sessionId);
772
+ void postDevRun({ sessionId, ended: true, ...ex });
773
+ },
774
+ });
775
+ if (!r.ok) {
776
+ await postDevRun({
777
+ sessionId,
778
+ started: false,
779
+ endedReason: r.endedReason ?? 'spawn_failed',
780
+ error: r.error ?? 'the machine could not start it',
781
+ });
782
+ return;
783
+ }
784
+ liveDevRuns.set(sessionId, { stop: r.stop, pid: r.pid });
785
+ // THE AUDIT ROW. Without it a daemon-spawned dev server would be the
786
+ // ONLY execution on this box with no entry in the very surface built
787
+ // so an admin can answer "what ran on this machine" — and it would be
788
+ // missing precisely the execution whose provenance is most worth
789
+ // checking. `runtime: null` because no CLI ran it: the daemon did.
790
+ void fetch(SESSION_COMMANDS_URL, {
791
+ method: 'POST',
792
+ headers: {
793
+ Authorization: `Bearer ${FLEET_TOKEN}`,
794
+ 'User-Agent': USER_AGENT,
795
+ 'Content-Type': 'application/json',
796
+ },
797
+ signal: AbortSignal.timeout(30_000),
798
+ body: JSON.stringify({
799
+ sessionId,
800
+ cwd: wt,
801
+ commands: [{ command: argv.join(' '), at: new Date().toISOString() }],
802
+ }),
803
+ }).catch(() => {
804
+ /* best-effort — the audit records what reached it */
805
+ });
806
+ } finally {
807
+ devRunClaiming.delete(sessionId);
808
+ }
809
+ })();
810
+ }
811
+ };
812
+
813
+ /**
814
+ * ADOPT what the previous process left behind.
815
+ *
816
+ * This is the half of "consistently running" that actually delivers it. A
817
+ * self-update re-execs, and a same-repo takeover replaces this process — both
818
+ * leave dev servers alive on purpose (see `shutdownDevRuns`), and without
819
+ * adoption the successor would neither supervise them nor be able to stop
820
+ * them, so the row would say running while nothing owned the process.
821
+ *
822
+ * Identity is the CWD plus liveness, never the command string: `npm run dev`
823
+ * is identical across two tabs, two worktrees, and the driver's own
824
+ * hand-started server, so a cmdline match would be indistinguishable from a
825
+ * coincidence. A could-not-measure is NOT a match and kills nothing.
826
+ */
827
+ const adoptDevRuns = (activeIds) => {
828
+ let adopted = 0;
829
+ for (const entry of reapOrphanDevRuns(activeIds, note)) {
830
+ const wt = join(baseDir, 'sessions', entry.sessionId);
831
+ // The recorded cwd must still be this session's worktree. A recycled pid
832
+ // pointing anywhere else is somebody else's process.
833
+ if (entry.cwd !== wt) continue;
834
+ if (liveDevRuns.has(entry.sessionId)) continue;
835
+ liveDevRuns.set(entry.sessionId, {
836
+ pid: entry.pid,
837
+ stop: () => killDevRunEntry(entry),
838
+ });
839
+ adopted += 1;
840
+ // The output ring is EMPTY for an adopted run and the row says so rather
841
+ // than pretending to a tail it does not have.
842
+ void postDevRun({
843
+ sessionId: entry.sessionId,
844
+ started: true,
845
+ pid: entry.pid,
846
+ port: listenersIn(wt)[0]?.port ?? null,
847
+ logTail: '[reattached after the machine restarted — earlier output is not kept]',
848
+ });
849
+ }
850
+ if (adopted > 0) note(`re-attached ${adopted} dev server${adopted === 1 ? '' : 's'}`);
851
+ };
852
+
853
+ /** The sessionIds this machine is still running, sent on the poll so the
854
+ * server can tell a live run from one whose machine went away. */
855
+ const liveDevRunIds = () => [...liveDevRuns.keys()];
856
+
857
+ /** A tab closed. ORDER MATTERS and the caller keeps it: the tunnel is retired
858
+ * BEFORE the process it points at, so a viewer sees a dead link rather than
859
+ * a 502 from a gate whose origin vanished — and both happen before
860
+ * `retireWorkSessions` can `git worktree remove` the directory out from
861
+ * under a running node process, which it would do without complaint because
862
+ * its dirty check inspects only TRACKED files and node_modules is not one. */
863
+ const retireDevRuns = (activeIds) => {
864
+ if (!Array.isArray(activeIds)) return; // an older server, not a close
865
+ const live = new Set(activeIds);
866
+ for (const sessionId of [...liveDevRuns.keys()]) {
867
+ if (live.has(sessionId)) continue;
868
+ if (devRunClaiming.has(sessionId)) continue;
869
+ devRunClaiming.add(sessionId);
870
+ void stopDevRun(sessionId, 'tab_closed').finally(() => devRunClaiming.delete(sessionId));
871
+ }
872
+ };
873
+
874
+ /**
875
+ * Daemon shutdown, and the SPLIT is what delivers "consistently running".
876
+ *
877
+ * `kill: true` — a human hit Ctrl-C, the credential was revoked, or a stop was
878
+ * commanded. All three mean THIS MACHINE IS STANDING DOWN, and leaving dev
879
+ * servers behind would strand them.
880
+ *
881
+ * `kill: false` — a same-repo takeover, or a self-update re-exec. Both mean
882
+ * ANOTHER DAEMON IS ABOUT TO SERVE THIS REPO IN ONE SECOND, and killing would
883
+ * mean `flowviant` re-run in your own directory restarts the app you were
884
+ * watching, and a routine auto-update silently kills every dev server with
885
+ * nothing to bring them back — precisely the goal defeated. The registry rows
886
+ * are left for the successor to adopt.
887
+ */
888
+ const shutdownDevRuns = (kill) => {
889
+ if (!kill) {
890
+ liveDevRuns.clear();
891
+ return;
892
+ }
893
+ for (const [, live] of liveDevRuns) {
894
+ try {
895
+ live.stop?.();
896
+ } catch {
897
+ /* best-effort */
898
+ }
899
+ }
900
+ liveDevRuns.clear();
901
+ };
902
+
637
903
  const livePreviewIds = () => [...livePreviews.keys()];
638
904
 
639
905
  /**
@@ -2238,6 +2504,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2238
2504
  livePreviewIds,
2239
2505
  retirePreviews,
2240
2506
  shutdownPreviews,
2507
+ processDevRunJobs,
2508
+ liveDevRunIds,
2509
+ retireDevRuns,
2510
+ shutdownDevRuns,
2511
+ adoptDevRuns,
2241
2512
  retireWorkSessions,
2242
2513
  reportWorktrees,
2243
2514
  shutdownWork,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.56.1",
3
+ "version": "0.57.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": {