flowviant 0.70.0 → 0.71.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/bin/lib/fleet.mjs CHANGED
@@ -815,6 +815,7 @@ export async function runFleetDaemon() {
815
815
  processWorkTurns,
816
816
  processShipJobs,
817
817
  processDiffJobs,
818
+ processKillJobs,
818
819
  heldSessionIds,
819
820
  processPreviewJobs,
820
821
  livePreviewIds,
@@ -1586,6 +1587,11 @@ export async function runFleetDaemon() {
1586
1587
  // credential are both handed this array, and both opening a tunnel strands
1587
1588
  // a public hostname nobody can settle.
1588
1589
  processPreviewJobs(roster.previewJobs);
1590
+ // One measured process a human asked to stop. Claimed before acted on for
1591
+ // the same reason a share is, and re-verified against the kernel inside —
1592
+ // the pid on this job is a request, never an authority, because pids are
1593
+ // recycled and the row the browser clicked is up to a sweep old.
1594
+ processKillJobs(roster.killJobs);
1589
1595
  // …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
1590
1596
  // Throttled inside, never awaited — a `git status` the human cannot run
1591
1597
  // themselves from a browser, relayed. After retirement so a directory that
@@ -24,21 +24,59 @@
24
24
  * reports NOTHING and says so through the empty array — the same answer
25
25
  * `stillOurs` gives, and the same rule the rest of the product keeps: an
26
26
  * unmeasured thing renders nothing rather than rendering "none".
27
+ *
28
+ * CHOSEN vs KERNEL-ASSIGNED, and why this is a measurement rather than a guess.
29
+ * `wrangler dev` opens NINE listening sockets; exactly one of them is the URL a
30
+ * person opens. The other eight were opened on port 0 and given whatever the
31
+ * kernel had free, and nobody will ever type one into a browser. The box itself
32
+ * says where that range is (`/proc/sys/net/ipv4/ip_local_port_range`, or
33
+ * `sysctl net.inet.ip.portrange.*`), so "was this port CHOSEN" is a fact we can
34
+ * read rather than a heuristic about anybody's stack — the same character as
35
+ * `bind`, and it needs no allowlist, no framework detection and no probe.
36
+ *
37
+ * IT IS THE ONE DISCRIMINATOR A PROBE COULD NOT PROVIDE. Connecting to each
38
+ * port and keeping the ones that answer HTTP is the obvious alternative and it
39
+ * is WRONG, not merely rude: under `wrangler dev` the app's internal entry
40
+ * socket returns byte-for-byte what the dev URL returns, because the dev URL
41
+ * proxies straight to it. A probe promotes the wrong port. The free fact beats
42
+ * the expensive one on accuracy.
43
+ *
44
+ * WHAT THE ROW MAY CARRY, and the line that must not be crossed. `pid` and
45
+ * `rss` are numbers about a process; the LABEL is still only the basenames of
46
+ * argv[0] and argv[1], and argv[1] is skipped outright when it starts with `-`.
47
+ * That is why no row here is ever passed through `envScrub`: it cannot carry a
48
+ * secret, by construction rather than by filtering. Widening the label to "the
49
+ * first argument that is not a flag" would break exactly that — it reads a
50
+ * flag's VALUE, so `node --require hunter2 app.js` would relay `hunter2`. Do
51
+ * not widen it. Add fields that are numbers; never add argv.
27
52
  */
28
53
 
29
54
  import { execFileSync } from 'node:child_process';
30
55
  import { createConnection } from 'node:net';
31
56
  import { readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
32
57
  import { platform } from 'node:os';
58
+ import { rssBytes } from './processes.mjs';
33
59
  import { sep } from 'node:path';
34
60
 
35
61
  /** A box with more processes than this is not one we walk per sweep. The scan
36
62
  * is one readlink per pid and runs every reconcile; this is the runaway
37
63
  * bound, not a capacity statement. */
38
64
  const MAX_PIDS = 4000;
39
- /** Rows reported per session. A dev server, its HMR socket and an API is three;
40
- * twenty is somebody's docker-compose and the extra rows say nothing. */
41
- const MAX_ROWS = 8;
65
+ /**
66
+ * Rows reported per session.
67
+ *
68
+ * It was 8, and `wrangler dev` alone opens NINE — so the cap was silently
69
+ * eating a row on an ordinary stack while the wire carried no flag saying it
70
+ * had. Both halves of that are fixed: the number is 12, and `measureListeners`
71
+ * reports the TOTAL so a surface can say it is not showing everything.
72
+ *
73
+ * The cap is also no longer allowed to eat the row that matters. Sorting was by
74
+ * port ascending, which is arbitrary with respect to importance — a dev server
75
+ * on :8080 beside eight kernel-assigned sockets in the 30000s survives by luck,
76
+ * and one on :9000 would not. CHOSEN ports sort first now, so the cut falls on
77
+ * the ephemeral tail, which is the half nobody opens.
78
+ */
79
+ const MAX_ROWS = 12;
42
80
  /** Longest process label we relay. */
43
81
  const MAX_LABEL = 40;
44
82
 
@@ -120,6 +158,68 @@ export function labelFromArgv(argv) {
120
158
  return label.slice(0, MAX_LABEL) || null;
121
159
  }
122
160
 
161
+ /**
162
+ * THE KERNEL'S OWN EPHEMERAL PORT RANGE, or null where we cannot read it.
163
+ *
164
+ * A socket opened on port 0 is given a port out of this range; a port outside
165
+ * it was named by a person or their config. That is the whole of the CHOSEN
166
+ * test, and the reason it is honest: the range is READ FROM THIS BOX, never
167
+ * assumed. Linux's default is 32768-60999 and macOS's is 49152-65535, and both
168
+ * are tunable — hardcoding either would turn a measurement into a guess that is
169
+ * wrong on exactly the machines somebody bothered to tune.
170
+ *
171
+ * THREE STATES, as everywhere else here: `undefined` before we look, `null` for
172
+ * "this box would not say" (which must leave `chosen` OFF every row rather than
173
+ * defaulting it), and a pair once measured. Cached for the life of the process
174
+ * because the range does not move under a running kernel.
175
+ */
176
+ let EPHEMERAL;
177
+ function ephemeralRange() {
178
+ if (EPHEMERAL !== undefined) return EPHEMERAL;
179
+ EPHEMERAL = null;
180
+ try {
181
+ if (platform() === 'linux') {
182
+ const [lo, hi] = readFileSync('/proc/sys/net/ipv4/ip_local_port_range', 'utf8')
183
+ .trim()
184
+ .split(/\s+/)
185
+ .map(Number);
186
+ if (Number.isInteger(lo) && Number.isInteger(hi) && lo > 0 && hi >= lo) EPHEMERAL = [lo, hi];
187
+ } else if (platform() === 'darwin') {
188
+ const one = (k) =>
189
+ Number(
190
+ execFileSync('sysctl', ['-n', k], {
191
+ encoding: 'utf8',
192
+ stdio: ['ignore', 'pipe', 'ignore'],
193
+ timeout: 3000,
194
+ }).trim()
195
+ );
196
+ const lo = one('net.inet.ip.portrange.first');
197
+ const hi = one('net.inet.ip.portrange.last');
198
+ if (Number.isInteger(lo) && Number.isInteger(hi) && lo > 0 && hi >= lo) EPHEMERAL = [lo, hi];
199
+ }
200
+ } catch {
201
+ /* unreadable — stays null, and `chosen` is then absent rather than guessed */
202
+ }
203
+ return EPHEMERAL;
204
+ }
205
+
206
+ /**
207
+ * True/false once the range is known, `undefined` when it is not — so the
208
+ * caller can omit the key entirely rather than assert a default.
209
+ *
210
+ * Split from the cached reader so the RULE is testable without a kernel: the
211
+ * three-state answer is the part worth pinning, and it is the part a later
212
+ * refactor is most likely to flatten into a boolean.
213
+ */
214
+ export function isChosenPort(port, range) {
215
+ if (!range) return undefined;
216
+ return port < range[0] || port > range[1];
217
+ }
218
+
219
+ function chosenPort(port) {
220
+ return isChosenPort(port, ephemeralRange());
221
+ }
222
+
123
223
  function labelFor(pid) {
124
224
  try {
125
225
  const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
@@ -180,7 +280,20 @@ function scanLinux(worktree) {
180
280
  const hit = inodes.get(m[1]);
181
281
  if (!hit) continue;
182
282
  if (found.has(hit.port)) continue;
183
- found.set(hit.port, { port: hit.port, bind: hit.bind, label: labelFor(pid) });
283
+ const chosen = chosenPort(hit.port);
284
+ const rss = rssBytes(pid);
285
+ found.set(hit.port, {
286
+ port: hit.port,
287
+ bind: hit.bind,
288
+ label: labelFor(pid),
289
+ // The pid was always in hand here — it is what resolves the cwd — and
290
+ // was thrown away the moment the row was built. Keeping it is what lets
291
+ // a surface group nine sockets under the ONE program that opened them,
292
+ // and it is the only thing a kill could ever be aimed at.
293
+ pid: Number(pid),
294
+ ...(rss != null ? { rss } : {}),
295
+ ...(chosen === undefined ? {} : { chosen }),
296
+ });
184
297
  }
185
298
  }
186
299
  return [...found.values()];
@@ -205,6 +318,22 @@ function scanDarwin(worktree) {
205
318
  // Pass 1: every listening socket, as pid → ports.
206
319
  const byPid = new Map();
207
320
  let pid = null;
321
+ // RSS for every pid on the box, in one call. `lsof` cannot report memory and
322
+ // a per-pid `ps` would be one fork per row; this is one fork per sweep.
323
+ const rssByPid = new Map();
324
+ try {
325
+ for (const line of execFileSync('ps', ['-axo', 'pid=,rss='], {
326
+ encoding: 'utf8',
327
+ stdio: ['ignore', 'pipe', 'ignore'],
328
+ timeout: 5000,
329
+ maxBuffer: 4 * 1024 * 1024,
330
+ }).split('\n')) {
331
+ const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
332
+ if (m) rssByPid.set(m[1], Number(m[2]) * 1024); // ps reports KB
333
+ }
334
+ } catch {
335
+ /* no memory on this box — rows simply carry no rss */
336
+ }
208
337
  for (const line of lsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']).split('\n')) {
209
338
  if (line.startsWith('p')) pid = line.slice(1);
210
339
  else if (line.startsWith('n') && pid) {
@@ -235,7 +364,20 @@ function scanDarwin(worktree) {
235
364
  const cwd = line.slice(1);
236
365
  if (cwd !== root && !cwd.startsWith(prefix)) continue;
237
366
  for (const row of byPid.get(cur) || []) {
238
- if (!out.has(row.port)) out.set(row.port, { ...row, label: null });
367
+ if (out.has(row.port)) continue;
368
+ const chosen = chosenPort(row.port);
369
+ out.set(row.port, {
370
+ ...row,
371
+ // Still null on darwin: `lsof -F pn` is asked for name and pid, never
372
+ // a command, and adding `-F c` would relay a command name we have no
373
+ // scrub for. The CHOSEN test needs no label, which is the other
374
+ // reason it is the right discriminator — it is the only one that
375
+ // works identically on a Mac.
376
+ label: null,
377
+ pid: Number(cur),
378
+ ...(rssByPid.has(cur) ? { rss: rssByPid.get(cur) } : {}),
379
+ ...(chosen === undefined ? {} : { chosen }),
380
+ });
239
381
  }
240
382
  }
241
383
  }
@@ -245,19 +387,54 @@ function scanDarwin(worktree) {
245
387
  // ── public ─────────────────────────────────────────────────────────────────
246
388
 
247
389
  /**
248
- * Every TCP port in LISTEN held by a process whose cwd is inside `worktree`.
249
- * Smallest port first, capped. An empty array on an unsupported platform means
250
- * "we did not look" callers must not turn it into "nothing is running".
390
+ * ORDER, and why it is not the port number any more.
391
+ *
392
+ * CHOSEN ports first, each group by port ascending. Two reasons, and the second
393
+ * is the load-bearing one:
394
+ *
395
+ * · A person scanning this list is looking for the URL they are about to open,
396
+ * and that is always a port somebody named. The seven sockets `wrangler dev`
397
+ * was handed by the kernel are not candidates and should not be read past.
398
+ * · The list is CAPPED. Ordering by port number let the cap fall wherever the
399
+ * numbers happened to land, so a dev server on a high port could be the row
400
+ * that got dropped. Sorting by chosen-ness puts the cut on the tail nobody
401
+ * opens.
402
+ *
403
+ * Where the range could not be read `chosen` is absent on every row, `Number()`
404
+ * of undefined is NaN, and the comparison is false both ways — so the order
405
+ * degrades to the old port sort rather than to something arbitrary.
406
+ */
407
+ export function byChosenThenPort(a, b) {
408
+ const ac = a.chosen === true ? 0 : 1;
409
+ const bc = b.chosen === true ? 0 : 1;
410
+ return ac !== bc ? ac - bc : a.port - b.port;
411
+ }
412
+
413
+ /**
414
+ * Every TCP port in LISTEN held by a process whose cwd is inside `worktree`,
415
+ * with the TOTAL the cap was applied to.
416
+ *
417
+ * The total is the whole point of the pair. A list silently cut at twelve
418
+ * answers "what is running in here" with a number that is not true, and the
419
+ * Repository block already settled that trade for branches and worktrees: the
420
+ * capped list rides beside the count it was cut from. An empty array on an
421
+ * unsupported platform means "we did not look" — callers must not turn it into
422
+ * "nothing is running".
251
423
  */
252
- export function listenersIn(worktree) {
253
- if (!worktree) return [];
424
+ export function measureListeners(worktree) {
425
+ if (!worktree) return { rows: [], total: 0 };
254
426
  let rows;
255
427
  try {
256
428
  rows = platform() === 'linux' ? scanLinux(worktree) : platform() === 'darwin' ? scanDarwin(worktree) : [];
257
429
  } catch {
258
- return [];
430
+ return { rows: [], total: 0 };
259
431
  }
260
- return rows.sort((a, b) => a.port - b.port).slice(0, MAX_ROWS);
432
+ return { rows: rows.sort(byChosenThenPort).slice(0, MAX_ROWS), total: rows.length };
433
+ }
434
+
435
+ /** The capped rows alone, for callers with nowhere to put a total. */
436
+ export function listenersIn(worktree) {
437
+ return measureListeners(worktree).rows;
261
438
  }
262
439
 
263
440
  /** Does this platform measure listeners at all? The web must render no preview
@@ -80,6 +80,28 @@ function pgrpOf(pid) {
80
80
  return Number.isInteger(pgrp) && pgrp > 0 ? pgrp : null;
81
81
  }
82
82
 
83
+ /**
84
+ * Resident set size in BYTES for one pid, or null.
85
+ *
86
+ * Read from `VmRSS` in /proc/<pid>/status rather than from `statm`, whose
87
+ * second field is resident PAGES and would need a page size we would have to
88
+ * assume. 4096 is not universal — arm64 boxes run 16K pages — and a memory
89
+ * readout that is silently four times wrong on somebody's machine is worse than
90
+ * no readout at all. `status` states its own unit.
91
+ *
92
+ * Lives here rather than in `listeners.mjs` because it is a fact about a
93
+ * PROCESS; both scanners import it, so there is one implementation of the unit
94
+ * question and not two that can drift.
95
+ */
96
+ export function rssBytes(pid) {
97
+ try {
98
+ const m = /^VmRSS:\s+(\d+)\s+kB$/m.exec(readFileSync(`/proc/${pid}/status`, 'utf8'));
99
+ return m ? Number(m[1]) * 1024 : null;
100
+ } catch {
101
+ return null; // gone between the scan and the read — ordinary
102
+ }
103
+ }
104
+
83
105
  function cmdlineOf(pid) {
84
106
  try {
85
107
  const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
@@ -109,7 +131,8 @@ function scanLinux(pgids) {
109
131
  if (pid === pgrp) continue;
110
132
  const cmd = cmdlineOf(raw);
111
133
  if (!cmd) continue;
112
- out.push({ pid, pgid: pgrp, cmd });
134
+ const rss = rssBytes(raw);
135
+ out.push({ pid, pgid: pgrp, cmd, ...(rss != null ? { rss } : {}) });
113
136
  }
114
137
  return out;
115
138
  }
@@ -117,7 +140,7 @@ function scanLinux(pgids) {
117
140
  function scanDarwin(pgids) {
118
141
  let text;
119
142
  try {
120
- text = execFileSync('ps', ['-axo', 'pid=,pgid=,command='], {
143
+ text = execFileSync('ps', ['-axo', 'pid=,pgid=,rss=,command='], {
121
144
  encoding: 'utf8',
122
145
  stdio: ['ignore', 'pipe', 'ignore'],
123
146
  timeout: 5000,
@@ -128,13 +151,13 @@ function scanDarwin(pgids) {
128
151
  }
129
152
  const out = [];
130
153
  for (const line of text.split('\n')) {
131
- const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
154
+ const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line);
132
155
  if (!m) continue;
133
156
  const pid = Number(m[1]);
134
157
  const pgid = Number(m[2]);
135
158
  if (!pgids.has(pgid) || pid === pgid) continue;
136
- const cmd = m[3].trim();
137
- if (cmd) out.push({ pid, pgid, cmd });
159
+ const cmd = m[4].trim();
160
+ if (cmd) out.push({ pid, pgid, cmd, rss: Number(m[3]) * 1024 }); // ps reports KB
138
161
  }
139
162
  return out;
140
163
  }
@@ -146,18 +169,36 @@ function scanDarwin(pgids) {
146
169
  * "looked, found none" and is a different fact a surface renders differently.
147
170
  */
148
171
  export function processesInGroups(pgids, { scrub } = {}) {
172
+ const m = measureProcesses(pgids, { scrub });
173
+ return m === null ? null : m.rows;
174
+ }
175
+
176
+ /**
177
+ * …and the TOTAL the cap was applied to.
178
+ *
179
+ * The rows alone are a lie about scale the moment a group has more than twelve
180
+ * members: a dashboard summing them states an incomplete figure as a complete
181
+ * one. Same rule the listener list and the Repository block's worktree counts
182
+ * keep — the capped list rides beside the count it was cut from, or it answers
183
+ * "how much is in here" with a number that is not true.
184
+ */
185
+ export function measureProcesses(pgids, { scrub } = {}) {
149
186
  if (!processesSupported()) return null;
150
187
  const want = pgids instanceof Set ? pgids : new Set(pgids ?? []);
151
- if (want.size === 0) return [];
188
+ if (want.size === 0) return { rows: [], total: 0 };
152
189
  const rows = platform() === 'darwin' ? scanDarwin(want) : scanLinux(want);
153
190
  // Oldest first: a pid is monotonic, so the long-running watcher you started
154
191
  // an hour ago sorts above the `sh -c` spawned two seconds ago. Cutting from
155
192
  // the END keeps the durable processes and drops the churn.
156
193
  rows.sort((a, b) => a.pid - b.pid);
157
- return rows.slice(0, MAX_PROCS).map((r) => ({
194
+ const capped = rows.slice(0, MAX_PROCS).map((r) => ({
158
195
  pid: r.pid,
159
196
  cmd: String(scrub ? scrub(r.cmd) : r.cmd).slice(0, MAX_CMD),
197
+ // Absent rather than zero where it could not be read: a watcher using no
198
+ // memory is not a thing, so a 0 here would only ever mean "we failed".
199
+ ...(r.rss != null ? { rss: r.rss } : {}),
160
200
  }));
201
+ return { rows: capped, total: rows.length };
161
202
  }
162
203
 
163
204
  /**
@@ -32,7 +32,7 @@
32
32
  */
33
33
 
34
34
  import { execFileSync } from 'node:child_process';
35
- import { listenersIn, listenersSupported } from './listeners.mjs';
35
+ import { measureListeners, listenersSupported } from './listeners.mjs';
36
36
 
37
37
  /** Same cap the session diffstat uses: enough to see the shape, small enough
38
38
  * that one machine cannot flood a row. */
@@ -162,7 +162,13 @@ export function repoState(repoRoot, baseRef) {
162
162
  * Reported, not offered: this is a readout of what is up. Sharing one is a
163
163
  * separate act with its own gates (see previewJobs).
164
164
  */
165
- const listening = listenersIn(repoRoot);
165
+ // The TOTAL beside the capped rows, for the reason every other capped list
166
+ // here carries one: a list silently cut at twelve answers "what is running in
167
+ // the checkout" with a number that is not true, and `wrangler dev` alone
168
+ // opens nine.
169
+ const lis = measureListeners(repoRoot);
170
+ const listening = lis.rows;
171
+ const listeningTotal = lis.total;
166
172
  const wt = worktrees ?? [];
167
173
  const br = branches ?? [];
168
174
  const sessionRows = br.filter((b) => b.session);
@@ -194,6 +200,7 @@ export function repoState(repoRoot, baseRef) {
194
200
  ? { sessionBranchesUnshipped: sessionRows.filter((b) => b.ahead > 0).length }
195
201
  : {}),
196
202
  listening,
203
+ listeningTotal,
197
204
  // "Nothing is listening" and "this machine cannot look" (Windows, a failed
198
205
  // scan) are the same empty array without this — and the second must never
199
206
  // render as the first. Same field, same reason, as the session report.
package/bin/lib/work.mjs CHANGED
@@ -39,8 +39,9 @@ import {
39
39
  MODEL,
40
40
  } from './config.mjs';
41
41
  import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
42
- import { listenersIn, listenersSupported } from './listeners.mjs';
43
- import { processesInGroups, liveGroups, processesSupported } from './processes.mjs';
42
+ import { listenersIn, measureListeners, listenersSupported } from './listeners.mjs';
43
+ import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
44
+ import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
44
45
  import { createPlaceLock } from './placeLock.mjs';
45
46
  import { sweepMergedBranch } from './shipSweep.mjs';
46
47
  import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
@@ -125,6 +126,8 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
125
126
  const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
126
127
  const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
127
128
  const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
129
+ const KILL_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/kill-done');
130
+ const KILL_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/kill-claim');
128
131
  const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
129
132
  const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
130
133
  const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
@@ -149,11 +152,65 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
149
152
  */
150
153
  const sessionGroups = new Map(); // sessionId -> Set<pgid>
151
154
 
155
+ /**
156
+ * …AND IT SURVIVES A RESTART, which it did not until 2026-08-27.
157
+ *
158
+ * This map used to live only in this closure. The processes it tracks
159
+ * OUTLIVE the daemon on purpose — `shutdownWork` SIGTERMs the CLI child and
160
+ * never the group, precisely so an unattended auto-update does not kill the
161
+ * driver's dev server — so every daemon restart left a live watcher running
162
+ * with nothing left that knew whose it was. The tab reported `[]`, the web
163
+ * read that as "looked, found none", and the Running section went dark until
164
+ * some later turn happened to open a new group.
165
+ *
166
+ * That is not a small bug: AUTO_UPDATE is on by default, so it fired on every
167
+ * release, on every machine. And it broke the three-state rule this file
168
+ * states in its own header — the honest answer after a restart was "we have
169
+ * forgotten", and `[]` is not that. Persisting is what makes `[]` true again,
170
+ * which is why the fix is a disk write and not a fourth state.
171
+ *
172
+ * `procRegistry` is the right home and was sitting unused: it was built for
173
+ * exactly this ("the daemon spawns things that outlive it… the successor has
174
+ * to find them"), was orphaned when the dev-run system was deleted, and
175
+ * already does the atomic write, the stale-lock recovery, the entry cap and
176
+ * the dead-pid TTL. Its prune is deliberately LOOSE here — it keeps an entry
177
+ * whose leader is gone, because the leader is the CLI and it exits at the end
178
+ * of every turn while the watcher it started keeps running. `liveGroups` is
179
+ * the real prune, on every read, against the kernel.
180
+ */
181
+ const GROUPS_DIR = join(homedir(), '.flowviant');
182
+ const GROUPS_FILE = join(GROUPS_DIR, 'session-groups.json');
183
+ const GROUPS_LOCK = join(GROUPS_DIR, 'session-groups.lock');
184
+
185
+ const persistGroups = () => {
186
+ const flat = [];
187
+ for (const [sid, set] of sessionGroups)
188
+ for (const pgid of set) flat.push({ sessionId: sid, pid: pgid, startedAt: Date.now() });
189
+ try {
190
+ mutateRegistry(GROUPS_DIR, GROUPS_FILE, GROUPS_LOCK, () => flat);
191
+ } catch {
192
+ /* best-effort: losing the file costs a restart's visibility, never a turn */
193
+ }
194
+ };
195
+
196
+ try {
197
+ for (const e of readRegistry(GROUPS_FILE)) {
198
+ if (!e?.sessionId || !Number.isInteger(e?.pid)) continue;
199
+ const set = sessionGroups.get(e.sessionId) ?? new Set();
200
+ set.add(e.pid);
201
+ sessionGroups.set(e.sessionId, set);
202
+ }
203
+ } catch {
204
+ /* no registry yet — the ordinary first run */
205
+ }
206
+
152
207
  const noteSessionGroup = (sessionId, pgid) => {
153
208
  if (!sessionId || !pgid) return;
154
209
  const set = sessionGroups.get(sessionId) ?? new Set();
210
+ if (set.has(pgid)) return;
155
211
  set.add(pgid);
156
212
  sessionGroups.set(sessionId, set);
213
+ persistGroups();
157
214
  };
158
215
 
159
216
  /** This tab's live processes, or null where the machine cannot look. */
@@ -162,9 +219,24 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
162
219
  const known = sessionGroups.get(sessionId);
163
220
  if (!known || known.size === 0) return [];
164
221
  const alive = liveGroups(known);
222
+ // Only touch the disk when the set actually MOVED. This runs on every
223
+ // sweep, for every live tab, forever; an unconditional write would be a
224
+ // file rewrite a minute for the life of the daemon to restate what is
225
+ // already there — the same reasoning the auto-name relay uses for its
226
+ // unchanged-title check.
227
+ const changed = alive.size !== known.size;
165
228
  if (alive.size === 0) sessionGroups.delete(sessionId);
166
229
  else sessionGroups.set(sessionId, alive);
167
- return processesInGroups(alive, { scrub: envScrub });
230
+ if (changed) persistGroups();
231
+ return measureProcesses(alive, { scrub: envScrub });
232
+ };
233
+
234
+ /** Every process group this machine is tracking, across every tab — what a
235
+ * kill request is checked against before anything is signalled. */
236
+ const allKnownGroups = () => {
237
+ const all = new Set();
238
+ for (const set of sessionGroups.values()) for (const g of set) all.add(g);
239
+ return all;
168
240
  };
169
241
 
170
242
 
@@ -479,7 +551,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
479
551
  // report on an endpoint that already exists, so NO version floor, and
480
552
  // `processesSupported` keeps "cannot look" (Windows) apart from "looked and
481
553
  // found none", which renders differently.
482
- const processes = sessionProcesses(sessionId);
554
+ const proc = sessionProcesses(sessionId);
483
555
  // THE NAME CLAUDE ALREADY GAVE THIS CONVERSATION, relayed.
484
556
  //
485
557
  // Claude Code titles its own sessions; a Flowviant tab was born "session 3"
@@ -503,12 +575,18 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
503
575
  } catch {
504
576
  /* no marker yet — this tab has not spoken, or is not Claude */
505
577
  }
578
+ // The TOTAL rides beside the capped rows, because a list silently cut at
579
+ // twelve answers "what is running in here" with a number that is not true.
580
+ // `wrangler dev` alone opens nine, so the old cap of eight was already
581
+ // dropping a row on an ordinary stack with nothing on the wire to say so.
582
+ const lis = measureListeners(wt);
506
583
  return {
507
584
  sessionId,
508
585
  ...d,
509
- listening: listenersIn(wt),
586
+ listening: lis.rows,
587
+ listeningTotal: lis.total,
510
588
  listeningSupported: listenersSupported(),
511
- ...(processes === null ? {} : { processes }),
589
+ ...(proc === null ? {} : { processes: proc.rows, processesTotal: proc.total }),
512
590
  processesSupported: processesSupported(),
513
591
  ...(title ? { title } : {}),
514
592
  };
@@ -893,6 +971,211 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
893
971
  livePreviews.clear();
894
972
  };
895
973
 
974
+ // ── stopping one measured process ────────────────────────────────────────
975
+ //
976
+ // The driver points at a row the MACHINE reported and says stop. Flowviant
977
+ // never picks the target, never sweeps, never signals anything on its own
978
+ // initiative, and never signals a GROUP — teardown deliberately SIGTERMs the
979
+ // CLI child and not its group precisely so an unattended auto-update cannot
980
+ // take the driver's dev server with it, and a control that signalled groups
981
+ // would hand that outcome back one click at a time.
982
+ //
983
+ // WHY THIS EXISTS AT ALL, since "ask your Claude to kill it" looks like it
984
+ // already covers it. It does not, and it fails hardest in the case that
985
+ // motivates it: every tab one person owns shares ONE place, the cross-process
986
+ // turn lock is per-place and deliberately un-scoped, so while any other tab
987
+ // of yours is mid-turn a new turn does not spawn — it warns and waits. If the
988
+ // runaway process you want stopped is being held by a turn that is hung, the
989
+ // turn that would kill it never runs. (Two more: FLOWVIANT_SAFE=1 — which the
990
+ // README recommends on a shared box — has no kill, pkill, lsof or ss in its
991
+ // allowlist; and under the README's own top hardening tip, a daemon on its
992
+ // own OS user, the operator's dev server is EPERM to the agent.)
993
+ //
994
+ // A PID IS NOT AN IDENTITY, and this is the whole safety argument. Pids are
995
+ // recycled, the row the browser is looking at is up to a sweep old, and the
996
+ // instance lock already learned this the expensive way — its own comment says
997
+ // "a looser version of this check SIGTERMed one". So the pid the server sends
998
+ // is a REQUEST, never an authority: this re-derives attribution from the
999
+ // kernel immediately before signalling, and refuses unless the pid is STILL
1000
+ // in one of this tab's process groups or STILL holding a socket in this
1001
+ // tab's place. A recycled pid belonging to something else fails that, which
1002
+ // is the property a start-time witness would have bought at the cost of
1003
+ // another wire field.
1004
+ //
1005
+ // CLAIMED, not read, for the reason `processPreviewJobs` states: two daemons
1006
+ // legitimately share one credential and both are handed the same array.
1007
+ // Signalling twice is survivable; signalling twice with a recycle in between
1008
+ // is the failure this whole comment is about.
1009
+ const killing = new Set(); // job ids in flight on this tick
1010
+
1011
+ const postKill = async (body) => {
1012
+ try {
1013
+ await fetch(KILL_DONE_URL, {
1014
+ method: 'POST',
1015
+ headers: {
1016
+ Authorization: `Bearer ${FLEET_TOKEN}`,
1017
+ 'User-Agent': USER_AGENT,
1018
+ 'Content-Type': 'application/json',
1019
+ },
1020
+ signal: AbortSignal.timeout(30_000),
1021
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
1022
+ });
1023
+ } catch {
1024
+ /* unsettled, and the server expires it — the asker is told, never spun */
1025
+ }
1026
+ };
1027
+
1028
+ const claimKill = async (id) => {
1029
+ try {
1030
+ const res = await fetch(KILL_CLAIM_URL, {
1031
+ method: 'POST',
1032
+ headers: {
1033
+ Authorization: `Bearer ${FLEET_TOKEN}`,
1034
+ 'User-Agent': USER_AGENT,
1035
+ 'Content-Type': 'application/json',
1036
+ },
1037
+ signal: AbortSignal.timeout(15_000),
1038
+ body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
1039
+ });
1040
+ const j = await res.json().catch(() => null);
1041
+ return j?.data?.claimed === true;
1042
+ } catch {
1043
+ return false; // the peer may hold it; doing nothing is the safe answer
1044
+ }
1045
+ };
1046
+
1047
+ /**
1048
+ * Is this pid, RIGHT NOW, one of the things we told the browser about for
1049
+ * this session? Two lanes, matching the two lists a row can come from.
1050
+ *
1051
+ * Deliberately re-measured rather than read from anything cached: a cache is
1052
+ * exactly as old as the report the browser is acting on, and staleness is the
1053
+ * hazard.
1054
+ */
1055
+ const killTargetOk = (sessionId, pid) => {
1056
+ const groups = sessionGroups.get(sessionId);
1057
+ if (groups && groups.size) {
1058
+ const alive = liveGroups(groups);
1059
+ const rows = measureProcesses(alive)?.rows ?? [];
1060
+ if (rows.some((r) => r.pid === pid)) return true;
1061
+ }
1062
+ const wt = placeDir(sessionId);
1063
+ if (wt) {
1064
+ try {
1065
+ if (measureListeners(wt).rows.some((r) => r.pid === pid)) return true;
1066
+ } catch {
1067
+ /* unmeasurable → not verified → refused, which is the safe direction */
1068
+ }
1069
+ }
1070
+ return false;
1071
+ };
1072
+
1073
+ /**
1074
+ * How long to watch for the process to actually go before answering.
1075
+ *
1076
+ * SIGTERM is a REQUEST, not an event: a dev server traps it and tears down
1077
+ * its children, which takes a beat. Answering the instant the signal returns
1078
+ * would report "signalled" over a process that is about to die, and the
1079
+ * surface would then offer Force stop on something already on its way out.
1080
+ *
1081
+ * Four seconds is long enough for the ordinary teardown and short enough that
1082
+ * a person is still looking at the row. Past it the honest answer is that the
1083
+ * signal landed and the thing is still there — which is a real state, and the
1084
+ * one where escalating actually means something.
1085
+ */
1086
+ const KILL_GRACE_MS = 4000;
1087
+
1088
+ const waitForExit = async (pid) => {
1089
+ const until = Date.now() + KILL_GRACE_MS;
1090
+ while (Date.now() < until) {
1091
+ if (!processAlive(pid)) return true;
1092
+ await new Promise((r) => setTimeout(r, 200));
1093
+ }
1094
+ return !processAlive(pid);
1095
+ };
1096
+
1097
+ const runKill = async (job) => {
1098
+ const id = String(job.id);
1099
+ const sessionId = String(job.sessionId || '');
1100
+ const pid = Number(job.pid);
1101
+ const signal = job.signal === 'KILL' ? 'SIGKILL' : 'SIGTERM';
1102
+
1103
+ if (!processesSupported()) {
1104
+ await postKill({ id, outcome: 'unsupported' });
1105
+ return;
1106
+ }
1107
+ if (!killTargetOk(sessionId, pid)) {
1108
+ // Not a lie and not a failure: the process is genuinely no longer one of
1109
+ // this tab's, which is the common case when somebody clicks a row that
1110
+ // has since exited. The asker gets that sentence rather than a spinner —
1111
+ // and the RE-MEASURE below is what takes the stale row off their screen,
1112
+ // since a row you can click for something already gone is the readout
1113
+ // being behind, not the person being wrong.
1114
+ await postKill({ id, outcome: 'not_found' });
1115
+ await remeasureAfterKill(sessionId);
1116
+ return;
1117
+ }
1118
+ if (!(await claimKill(id))) return;
1119
+ try {
1120
+ process.kill(pid, signal);
1121
+ // WHAT HAPPENED, not what we did. "We sent a signal" is a fact about us;
1122
+ // "it stopped" is a fact about the machine, and the machine is standing
1123
+ // right here able to check. Reporting the weaker word would also make the
1124
+ // Force stop offer wrong for the whole window, since escalating only
1125
+ // means something while the process is genuinely still there.
1126
+ const gone = await waitForExit(pid);
1127
+ await postKill({ id, outcome: gone ? 'stopped' : 'signalled', signal });
1128
+ } catch (e) {
1129
+ // EPERM is the daemon-on-its-own-user posture doing exactly what it is
1130
+ // for. Report it as its own word: "we may not" and "it was gone" are
1131
+ // different sentences and the surface says which.
1132
+ await postKill({ id, outcome: e?.code === 'ESRCH' ? 'not_found' : 'error', detail: String(e?.code || e) });
1133
+ }
1134
+ await remeasureAfterKill(sessionId);
1135
+ };
1136
+
1137
+ /**
1138
+ * THE LIST THE PERSON IS LOOKING AT WAS MEASURED BEFORE ANY OF THIS.
1139
+ *
1140
+ * Without this the row survives the thing it describes: the panel renders the
1141
+ * last sweep's `listening`, the sweep is on a SIXTY-SECOND beat, and the
1142
+ * reported outcome sits next to a port row still claiming to be live. The
1143
+ * first person to use it said exactly that — "i clicked stop on the listening
1144
+ * but its still running… then it finally disappears".
1145
+ *
1146
+ * The rule it was missing is one this file already keeps everywhere else: an
1147
+ * action that changes what the machine would measure must cause a new
1148
+ * measurement. A turn settling does it; a kill did not. `reportSessionWorktree`
1149
+ * is the un-throttled per-session path built for precisely this and it was
1150
+ * being called from exactly one place.
1151
+ *
1152
+ * Never awaited by the caller's answer path: the outcome is posted first, so
1153
+ * a slow re-measure can delay the list but never the sentence.
1154
+ */
1155
+ const remeasureAfterKill = async (sessionId) => {
1156
+ try {
1157
+ await reportSessionWorktree(sessionId);
1158
+ } catch {
1159
+ /* the 60s sweep still carries it — this only makes it prompt */
1160
+ }
1161
+ };
1162
+
1163
+ const processKillJobs = (jobs) => {
1164
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
1165
+ for (const job of jobs.slice(0, 5)) {
1166
+ const id = String(job?.id || '');
1167
+ const pid = Number(job?.pid);
1168
+ // Bounded at the boundary the same way sessionId and port already are.
1169
+ // pid 1 is init and is never something a tab started; a signal there
1170
+ // would ask the kernel to shut the box down.
1171
+ if (!id || killing.has(id)) continue;
1172
+ if (!Number.isInteger(pid) || pid <= 1 || pid > 4_294_967_295) continue;
1173
+ if (!isSafePathSegment(String(job?.sessionId || ''))) continue;
1174
+ killing.add(id);
1175
+ void runKill(job).finally(() => killing.delete(id));
1176
+ }
1177
+ };
1178
+
896
1179
  const processDiffJobs = (jobs) => {
897
1180
  if (!Array.isArray(jobs) || jobs.length === 0) return;
898
1181
  for (const job of jobs.slice(0, 5)) {
@@ -2641,6 +2924,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
2641
2924
  processWorkTurns,
2642
2925
  processShipJobs,
2643
2926
  processDiffJobs,
2927
+ processKillJobs,
2644
2928
  heldSessionIds,
2645
2929
  processPreviewJobs,
2646
2930
  livePreviewIds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.70.0",
3
+ "version": "0.71.1",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {