flowviant 0.103.0 → 0.104.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.
Files changed (140) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +183 -2383
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +2 -2
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,235 @@
1
+ /**
2
+ * THE PREVIEW TUNNEL REGISTRY — which cloudflared groups this box started, so
3
+ * a daemon that died ungracefully has its public tunnels reaped by the next.
4
+ *
5
+ * cloudflared is detached so we can kill its whole group — which also means it
6
+ * SURVIVES an ungraceful daemon death (SIGKILL, crash, box sleep), leaving a
7
+ * public hostname pointed at a worktree with nobody minding it. We record each
8
+ * group's pid + a signature and reap ours at the next start.
9
+ *
10
+ * The registry is a read-modify-write over one file in a directory that TWO
11
+ * daemons can legitimately share (the 0.51.2 instance lock is keyed on a
12
+ * CREDENTIAL, so two daemons serving two different projects are fine and both
13
+ * write here). It was unlocked, written back when previews were serial. A lost
14
+ * entry is precisely the case reaping exists for.
15
+ *
16
+ * ── ONE REGISTRY IMPLEMENTATION, NOT TWO (SOLID audit 2026-09-26, F006) ──
17
+ *
18
+ * `procRegistry.mjs` was lifted out of `preview.mjs` for the dev-run system and
19
+ * then the session process groups, and preview kept its ORIGINAL copy of the
20
+ * lock, the atomic read/write and the process start-time reader — which had
21
+ * drifted: no bound on the file at all, and a start time with no boot mark.
22
+ * This file is preview's POLICY over procRegistry's PRIMITIVES:
23
+ *
24
+ * - THE LOCK AND THE ATOMIC WRITE are procRegistry's, same paths and same
25
+ * stale-lock recovery as before.
26
+ * - THE PRUNE IS PREVIEW'S OWN and it NEVER DISCARDS AN UNREAPED TUNNEL.
27
+ * procRegistry's default prune caps the file at 32 rows and ages out dead
28
+ * ones after a week; a cap would drop a live public tunnel's only record
29
+ * the day a box had 33 of them. So a row survives exactly while its tunnel
30
+ * pid is alive (signal 0, EPERM counting as alive) — a dead tunnel has
31
+ * nothing left to reap, and the reap pass itself drops those anyway. The
32
+ * file is bounded by the tunnels that exist.
33
+ * - ONE PROCESS IDENTITY: procRegistry's `processStartTime`, which carries the
34
+ * boot mark, in a NEW field, `ownerMark`, which this code prefers.
35
+ *
36
+ * ── THE FILE IS SHARED ACROSS DAEMON VERSIONS, SO THE OLD FIELD KEEPS ITS
37
+ * OLD SPELLING ──
38
+ *
39
+ * The daemon is the one component a deploy cannot upgrade, and previews.json
40
+ * is shared by every daemon on the box: two projects, the tray's bundled WSL
41
+ * daemon beside a curl install, a self-update window. A published daemon
42
+ * compares `ownerStart` EXACTLY against its own reading (`l:<ticks>` on Linux,
43
+ * `d:<lstart>` on macOS) and SIGKILLs the tunnel when they differ. The first
44
+ * cut of this move wrote the boot-marked reading INTO `ownerStart`, and an
45
+ * older daemon starting beside a live new one read every new row as a dead
46
+ * owner's and killed the peer's public tunnels (review 2026-09-26, reproduced
47
+ * against 0.103.0's reap). So a row carries BOTH: `ownerStart` in the legacy
48
+ * spelling, derived from the one reader, for published readers; `ownerMark`,
49
+ * the boot-marked reading, for this one. A row with no `ownerMark` (written by
50
+ * an older daemon) is judged on its legacy `ownerStart`, field for field, so a
51
+ * peer that recorded its tunnels before this change is not reaped either.
52
+ */
53
+
54
+ import { readFileSync } from 'node:fs';
55
+ import { execFileSync } from 'node:child_process';
56
+ import { join } from 'node:path';
57
+ import { homedir, platform } from 'node:os';
58
+ import { mutateRegistry, processAlive, processStartTime, readRegistry } from './procRegistry.mjs';
59
+
60
+ const FLOWVIANT_DIR = join(homedir(), '.flowviant');
61
+ const PREVIEW_REGISTRY = join(FLOWVIANT_DIR, 'previews.json');
62
+ const REGISTRY_LOCK = join(FLOWVIANT_DIR, 'previews.lock');
63
+
64
+ /** A row lives while its tunnel does, uncapped — see the header. */
65
+ export const keepLiveTunnels = (list) => list.filter((e) => processAlive(e?.pid));
66
+
67
+ function mutatePreviews(fn) {
68
+ mutateRegistry(FLOWVIANT_DIR, PREVIEW_REGISTRY, REGISTRY_LOCK, fn, { prune: keepLiveTunnels });
69
+ }
70
+
71
+ /**
72
+ * The legacy spelling of a `processStartTime` reading, for `ownerStart` — the
73
+ * field published daemons compare exactly (see the header). Linux readings are
74
+ * `<boot mark>:<ticks>` and published readers wrote `l:<ticks>`; macOS readings
75
+ * are the raw `lstart` and published readers wrote `d:<lstart>`. Null where a
76
+ * published reader could not read a start time either.
77
+ */
78
+ export function legacyOwnerStart(reading, os = platform()) {
79
+ if (typeof reading !== 'string' || !reading) return null;
80
+ if (os === 'linux') return `l:${reading.slice(reading.lastIndexOf(':') + 1)}`;
81
+ if (os === 'darwin') return `d:${reading}`;
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Does `recorded` (an entry's `ownerMark`, else its `ownerStart`) name the
87
+ * process that holds `pid` NOW? `true`/`false`, or `null` when this box cannot
88
+ * read a start time — the caller's "cannot tell", never a mismatch.
89
+ *
90
+ * `ownerMark` is procRegistry's reading verbatim. A legacy `ownerStart` (the
91
+ * only mark on a row an older daemon wrote) is compared field for field:
92
+ * `l:<ticks>` against the ticks after the boot mark, `d:<lstart>` against the
93
+ * macOS reading, which never had a prefix in procRegistry.
94
+ */
95
+ export function sameOwnerStart(recorded, pid) {
96
+ const now = processStartTime(pid);
97
+ if (now == null) return null;
98
+ if (recorded.startsWith('l:')) return now.slice(now.lastIndexOf(':') + 1) === recorded.slice(2);
99
+ if (recorded.startsWith('d:')) return now === recorded.slice(2);
100
+ return now === recorded;
101
+ }
102
+
103
+ export function recordPreviewPid(pid, sig) {
104
+ if (!pid) return;
105
+ // `owner` is the DAEMON that spawned it. The registry is shared by design —
106
+ // two daemons serving two projects both write here — so without an owner a
107
+ // starting daemon reaped its PEER's live tunnels: killed them, wiped their
108
+ // entries, and the peer kept heartbeating a URL that 530s (its probe watches
109
+ // the origin port, which was still alive).
110
+ //
111
+ // `ownerStart` pins the owner to ONE process (audit 2026-09-24). A daemon that
112
+ // is SIGKILLed with a share open leaves cloudflared up; if its pid is reused
113
+ // by the next start — any process, another user's included, since EPERM
114
+ // counts as alive — a pid-only check read the entry as a live peer's and
115
+ // skipped it forever, leaving a public hostname aimed at a dead gate's
116
+ // ephemeral port for anything that binds it next.
117
+ //
118
+ // `ownerMark` is that pin in the one identity reader's spelling; `ownerStart`
119
+ // keeps the spelling published daemons compare — see the header.
120
+ const ownerMark = processStartTime(process.pid);
121
+ const ownerStart = legacyOwnerStart(ownerMark);
122
+ mutatePreviews((list) => [
123
+ ...list,
124
+ { pid, sig, owner: process.pid, ...(ownerStart ? { ownerStart } : {}), ...(ownerMark ? { ownerMark } : {}) },
125
+ ]);
126
+ }
127
+
128
+ export function forgetPreviewPid(pid) {
129
+ if (!pid) return;
130
+ mutatePreviews((list) => list.filter((e) => e.pid !== pid));
131
+ }
132
+
133
+ /**
134
+ * Only kill a pid we can VERIFY is still one of ours — its command line must
135
+ * still contain the signature we stored. A reused pid belonging to something
136
+ * unrelated won't match, so we never kill a stranger.
137
+ *
138
+ * MACOS READS IT THROUGH `ps`, because "Linux-only, elsewhere just clear the
139
+ * registry" was the worst of both. A macOS daemon killed ungracefully leaves
140
+ * cloudflared running — it is spawned detached — so the public hostname keeps
141
+ * resolving to this machine while the gate dies with the daemon. The reaper
142
+ * then verified nothing, killed nothing, and DELETED the entry, so no later run
143
+ * could ever find that process. The tunnel served 502 until anything else on
144
+ * the box bound the gate's old port, which `startAuthProxy` obtained with
145
+ * `listen(0)` and is therefore squarely inside the kernel's ephemeral reuse
146
+ * pool — at which point a live public hostname forwarded straight to an
147
+ * unrelated local service with no gate, no password and no grant check. Exactly
148
+ * what this file's header says the reap exists to prevent.
149
+ *
150
+ * Three states, not two: `true` (ours), `false` (verified NOT ours, or gone),
151
+ * and `null` (we could not look — the caller keeps the record rather than
152
+ * dropping it).
153
+ */
154
+ function stillOurs(pid, sig) {
155
+ if (typeof sig !== 'string' || sig.length === 0) return false;
156
+ if (platform() === 'linux') {
157
+ try {
158
+ const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
159
+ return cmd.includes(sig);
160
+ } catch {
161
+ return false; // process gone / unreadable
162
+ }
163
+ }
164
+ if (platform() === 'darwin') {
165
+ try {
166
+ const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
167
+ encoding: 'utf8',
168
+ timeout: 5_000,
169
+ });
170
+ return cmd.includes(sig);
171
+ } catch {
172
+ // `ps` exits non-zero when the pid is gone — which is a real answer.
173
+ return false;
174
+ }
175
+ }
176
+ // Windows: no way to check from here. UNKNOWN, never "not ours" — see the
177
+ // caller, which keeps the record so a later run on a platform that can look
178
+ // is still able to.
179
+ return null;
180
+ }
181
+
182
+ /** Reap tunnel process groups left behind by a previously-crashed daemon.
183
+ * Call once at daemon startup, before any work begins.
184
+ *
185
+ * ORPHANS ONLY: an entry whose owning daemon is STILL ALIVE belongs to a
186
+ * peer serving another project (or to the process we are replacing, whose
187
+ * own teardown handles it) — killing those and wiping their entries was a
188
+ * peer daemon's startup silently breaking every live share on the box. Only
189
+ * the entries this pass handled are removed; a peer's records survive. */
190
+ /** Is the daemon that recorded an entry still the process at that pid? With
191
+ * a recorded start time, only when the pid's start time still matches (a
192
+ * recycled pid is an orphaned entry); where this box cannot report a start
193
+ * time now, or the entry predates the field, signal 0 is all there is. */
194
+ function ownerStillRunning(owner, ownerStart, ownerMark) {
195
+ if (!processAlive(owner)) return false;
196
+ const recorded = typeof ownerMark === 'string' ? ownerMark : ownerStart;
197
+ if (typeof recorded !== 'string') return true;
198
+ return sameOwnerStart(recorded, owner) !== false;
199
+ }
200
+
201
+ export function reapOrphanPreviews(log) {
202
+ const list = readRegistry(PREVIEW_REGISTRY);
203
+ if (list.length === 0) return;
204
+ let killed = 0;
205
+ const handled = new Set();
206
+ for (const { pid, sig, owner, ownerStart, ownerMark } of list) {
207
+ if (Number.isInteger(owner) && owner !== process.pid && ownerStillRunning(owner, ownerStart, ownerMark)) continue;
208
+ const ours = stillOurs(pid, sig);
209
+ /**
210
+ * THE RECORD OUTLIVES A REAP THAT COULD NOT LOOK. `handled.add` ran BEFORE
211
+ * this check, so an entry was dropped whether or not anything was killed —
212
+ * and on any platform `stillOurs` could not read, that deleted the only
213
+ * trace of a tunnel still serving the public internet. Kept on `null`
214
+ * (unknown); removed on `true` (we killed it) and on `false` (the process
215
+ * is gone, or the pid now belongs to somebody else and the entry is stale
216
+ * either way).
217
+ */
218
+ if (ours === null) continue;
219
+ handled.add(pid);
220
+ if (!ours) continue;
221
+ try {
222
+ process.kill(-pid, 'SIGKILL'); // whole group
223
+ killed++;
224
+ } catch {
225
+ try {
226
+ process.kill(pid, 'SIGKILL');
227
+ killed++;
228
+ } catch {
229
+ /* already gone */
230
+ }
231
+ }
232
+ }
233
+ if (handled.size) mutatePreviews((cur) => cur.filter((e) => !handled.has(e.pid)));
234
+ if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
235
+ }
@@ -11,8 +11,8 @@
11
11
  * `evil‮txt.crt⁦` reads backwards on a bidi-aware terminal, so
12
12
  * "evil" can visually swap places with an extension that was never there.
13
13
  *
14
- * THIS IS A LEAF MODULE ON PURPOSE. `machines.mjs` and `fleet.mjs` both need
15
- * it, `machines.mjs` deliberately imports nothing from `fleet.mjs`'s world
14
+ * THIS IS A LEAF MODULE ON PURPOSE. `machineListing.mjs` and the daemon's
15
+ * holder/roster modules (`holder.mjs`, `fleetRoster.mjs`) all need it, `machineListing.mjs` deliberately imports nothing from `fleet.mjs`'s world
16
16
  * (its own header explains why: it runs before the auth gate, like `stop` and
17
17
  * `projects`, and must not pull in the daemon's whole CLI/worktree
18
18
  * machinery), and `credentials.mjs` is where `safeName` already lived. A leaf
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A DISK REGISTRY OF DETACHED PROCESSES — one implementation, two users.
2
+ * A DISK REGISTRY OF DETACHED PROCESSES — one implementation, every user.
3
3
  *
4
4
  * The daemon spawns things that outlive it: cloudflared tunnels, and now dev
5
5
  * servers. If the daemon dies without tearing them down, the successor has to
@@ -7,10 +7,11 @@
7
7
  * in a worktree about to be `git worktree remove`d, are both worse than the
8
8
  * crash that caused them.
9
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:
10
+ * Lifted verbatim out of `preview.mjs`, which grew all of this for the tunnel —
11
+ * and which kept its own drifted copy until 2026-09-26 (F006); the tunnel
12
+ * registry is now `previewRegistry.mjs`, a policy over these primitives. Two
13
+ * things were ADDED on the way out, because a dev server lives for hours where
14
+ * a tunnel lived for minutes and long-lived rows make both matter:
14
15
  *
15
16
  * - an entry CAP, so a registry cannot grow without bound;
16
17
  * - a TTL sweep for entries whose pid is long dead, so a file nobody prunes
@@ -171,26 +172,35 @@ export function processAlive(pid) {
171
172
  }
172
173
  }
173
174
 
175
+ /**
176
+ * THE DEFAULT PRUNE: a row whose pid is alive, or dead for less than a week,
177
+ * capped at the newest `MAX_ENTRIES`. An entry whose pid has been dead for a
178
+ * week is not a process anybody is going to reap, and keeping it only makes
179
+ * the next reader slower and the next pid collision more likely.
180
+ */
181
+ export function pruneAgedDead(list, now = Date.now()) {
182
+ return list
183
+ .filter((e) => {
184
+ if (processAlive(e?.pid)) return true;
185
+ const started = Number(e?.startedAt ?? 0);
186
+ return started > 0 && now - started < ENTRY_TTL_MS;
187
+ })
188
+ .slice(-MAX_ENTRIES);
189
+ }
190
+
174
191
  /**
175
192
  * Read-modify-write under the lock, then prune.
176
193
  *
177
194
  * The prune is here rather than at the call sites so it cannot be forgotten by
178
- * one of them: an entry whose pid has been dead for a week is not a process
179
- * anybody is going to reap, and keeping it only makes the next reader slower
180
- * and the next pid collision more likely.
195
+ * one of them. A registry whose rows must never be dropped on a count — the
196
+ * preview tunnels, where the only record of a live public hostname cannot be
197
+ * the 33rd row — passes its own `prune` and states its bound there
198
+ * (`previewRegistry.mjs`); everything else takes `pruneAgedDead`.
181
199
  */
182
- export function mutateRegistry(dir, path, lockPath, fn) {
200
+ export function mutateRegistry(dir, path, lockPath, fn, { prune = pruneAgedDead } = {}) {
183
201
  const release = acquireLock(dir, lockPath);
184
202
  try {
185
- const next = fn(readRegistry(path));
186
- const now = Date.now();
187
- const pruned = next
188
- .filter((e) => {
189
- if (processAlive(e?.pid)) return true;
190
- const started = Number(e?.startedAt ?? 0);
191
- return started > 0 && now - started < ENTRY_TTL_MS;
192
- })
193
- .slice(-MAX_ENTRIES);
203
+ const pruned = prune(fn(readRegistry(path)));
194
204
  writeRegistry(dir, path, pruned);
195
205
  return pruned;
196
206
  } finally {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * WALKING /proc — the one bounded enumeration every process scan shares, and
3
+ * the one "which processes stand in this directory, holding which sockets"
4
+ * attribution the listener measurements share.
5
+ *
6
+ * ── WHY IT IS ONE FILE (SOLID audit 2026-09-26, F007) ──
7
+ *
8
+ * Three scans spelled the runaway bound three ways. `processes.mjs` had
9
+ * already been fixed to bound the WORK — stop after 4000 matching rows — after
10
+ * it lost a tab's watcher: readdir answers in STRING order, so cutting the pid
11
+ * LIST kept '1', '10', '100'… and dropped pid 912345. `listeners.mjs` still
12
+ * cut the list, twice (the inventory and the dial-address check), so on a busy
13
+ * box a dev server at a high pid was reported ABSENT by both — "nothing is
14
+ * listening" about a socket the kernel's own table held. The rule now has one
15
+ * home: EVERY pid is looked at, and the bound is on the rows a scan collects.
16
+ *
17
+ * WHAT THAT COSTS, said plainly (review 2026-09-26). The old slice bounded the
18
+ * WALK at 4000 pids; the bound here counts MATCHED rows (for the listener
19
+ * scans, processes standing in the worktree), so on a box of N processes one
20
+ * scan does N cwd readlinks, and a reconcile runs two per worktree (inventory
21
+ * and dial check). That is the price of never measuring a high pid as absent,
22
+ * and it is a readlink each — the fd walk, the expensive part, is paid only
23
+ * by processes inside the directory. A bound on pids VISITED would bring the
24
+ * dropped-listener bug straight back, so there is none.
25
+ *
26
+ * ── AND A CUT IS SAID ──
27
+ *
28
+ * `scanProcs` answers `complete: false` when it stopped with pids unread, so a
29
+ * caller can refuse to turn "not looked at" into "not there" — the three-state
30
+ * rule the rest of this product keeps. A /proc that cannot be listed at all is
31
+ * `null` from `procPids`, which is "cannot look", a third answer again.
32
+ *
33
+ * Linux only by construction; callers keep their own macOS paths.
34
+ */
35
+
36
+ import { readdirSync, readlinkSync, realpathSync } from 'node:fs';
37
+ import { sep } from 'node:path';
38
+
39
+ /** Rows collected per scan before the walk stops early — a bound on the ROWS
40
+ * held (and the per-row work: an fd walk, a stat read) for one pathological
41
+ * box, never on which part of /proc is looked at. Every pid still costs one
42
+ * cheap filter read (see the header). The runaway bound, not a capacity
43
+ * statement. */
44
+ export const SCAN_MATCH_BOUND = 4000;
45
+
46
+ /** Every numeric entry of /proc, or null when /proc cannot be listed. */
47
+ export function procPids(list = () => readdirSync('/proc')) {
48
+ try {
49
+ return list().filter((d) => /^\d+$/.test(d));
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Walk `pids` in order; `match(raw)` returns a row or null. Stops once `bound`
57
+ * rows are held AND a pid is still unread, answering `complete: false`.
58
+ */
59
+ export function scanProcs(pids, match, bound = SCAN_MATCH_BOUND) {
60
+ const rows = [];
61
+ for (const raw of pids) {
62
+ if (rows.length >= bound) return { rows, complete: false };
63
+ const row = match(raw);
64
+ if (row != null) rows.push(row);
65
+ }
66
+ return { rows, complete: true };
67
+ }
68
+
69
+ /** A predicate for "this path is `dir` or inside it", on the RESOLVED path —
70
+ * or null when `dir` is gone (retired under us). */
71
+ export function insideDir(dir) {
72
+ let root;
73
+ try {
74
+ root = realpathSync(dir);
75
+ } catch {
76
+ return null;
77
+ }
78
+ const prefix = root.endsWith(sep) ? root : root + sep;
79
+ return (p) => p === root || p.startsWith(prefix);
80
+ }
81
+
82
+ function cwdOf(raw) {
83
+ try {
84
+ return readlinkSync(`/proc/${raw}/cwd`);
85
+ } catch {
86
+ return null; // not ours to read, or gone
87
+ }
88
+ }
89
+
90
+ /** The socket inodes one process holds open, or null when its fd table is
91
+ * unreadable. */
92
+ function socketInodesOf(raw) {
93
+ let fds;
94
+ try {
95
+ fds = readdirSync(`/proc/${raw}/fd`);
96
+ } catch {
97
+ return null;
98
+ }
99
+ const out = [];
100
+ for (const fd of fds) {
101
+ let link;
102
+ try {
103
+ link = readlinkSync(`/proc/${raw}/fd/${fd}`);
104
+ } catch {
105
+ continue;
106
+ }
107
+ const m = /^socket:\[(\d+)\]$/.exec(link);
108
+ if (m) out.push(m[1]);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * EVERY PROCESS STANDING IN `dir`, with the socket inodes it holds —
115
+ * `{ holders: [{ pid, inodes }], complete }` in /proc order.
116
+ *
117
+ * Attribution is by the CWD of the process, the rule `listeners.mjs` argues
118
+ * for. The cheap filter runs first — one readlink rejects almost every process
119
+ * on the box, and only processes inside `dir` pay for a walk of their fd
120
+ * table — and those are the rows the bound counts. `skip(pid)` drops a process
121
+ * that is Flowviant itself (the daemon, its tunnels) before its fds are read.
122
+ *
123
+ * `{ holders: [], complete: true }` when `dir` is gone; null when /proc cannot
124
+ * be listed. `io` exists ONLY so a test can hand in a pid list (and a bound)
125
+ * without a box that runs four thousand processes; nothing in the daemon
126
+ * passes it.
127
+ */
128
+ export function socketHoldersIn(dir, { skip = () => false, io = {} } = {}) {
129
+ const inside = insideDir(dir);
130
+ if (!inside) return { holders: [], complete: true };
131
+ const pids = procPids(io.list);
132
+ if (pids === null) return null;
133
+ const { rows, complete } = scanProcs(
134
+ pids,
135
+ (raw) => {
136
+ const cwd = cwdOf(raw);
137
+ if (cwd === null || !inside(cwd)) return null;
138
+ const pid = Number(raw);
139
+ if (skip(pid)) return null;
140
+ const inodes = socketInodesOf(raw);
141
+ return inodes === null ? null : { pid, inodes };
142
+ },
143
+ io.bound
144
+ );
145
+ return { holders: rows, complete };
146
+ }
@@ -44,17 +44,18 @@
44
44
  */
45
45
 
46
46
  import { execFileSync } from 'node:child_process';
47
- import { readFileSync, readdirSync } from 'node:fs';
47
+ import { readFileSync } from 'node:fs';
48
48
  import { platform } from 'node:os';
49
+ import { procPids, scanProcs } from './procScan.mjs';
49
50
 
50
- /** Matched rows collected per scan before the walk stops early — a bound on
51
- * the WORK done for one pathological group, never on which part of /proc is
52
- * looked at. It used to cut the pid LIST before filtering, and readdir answers
53
- * in string order, so a busy box kept '1', '10', '100'… and dropped a tab's
54
- * watcher at pid 912345 while `liveGroups` (which has no cap) still called its
55
- * group alive: the tab read "looked, found none" and a Stop on it answered
56
- * not_found. */
57
- const MAX_PIDS = 4000;
51
+ // THE WALK'S BOUND is `procScan.mjs`'s: matched rows collected before the walk
52
+ // stops early — a bound on the WORK done for one pathological group, never on
53
+ // which part of /proc is looked at. It used to cut the pid LIST before
54
+ // filtering, and readdir answers in string order, so a busy box kept '1',
55
+ // '10', '100'… and dropped a tab's watcher at pid 912345 while `liveGroups`
56
+ // (which has no cap) still called its group alive: the tab read "looked,
57
+ // found none" and a Stop on it answered not_found. The listener scans made
58
+ // the same mistake after this one was fixed, which is why the rule moved.
58
59
  /** Rows reported per session. A watcher, a dev server and its child is three;
59
60
  * twenty is somebody's compose stack and the extra rows say nothing. */
60
61
  export const MAX_PROCS = 12;
@@ -123,31 +124,26 @@ function cmdlineOf(pid) {
123
124
  * pinned without a box that runs four thousand processes. Nothing in the
124
125
  * daemon passes `io`. */
125
126
  export function scanLinux(pgids, io = {}) {
126
- const list = io.list ?? (() => readdirSync('/proc'));
127
127
  const pgrpFor = io.pgrpOf ?? pgrpOf;
128
128
  const cmdFor = io.cmdlineOf ?? cmdlineOf;
129
129
  const rssFor = io.rssBytes ?? rssBytes;
130
- let pids;
131
- try {
132
- pids = list().filter((d) => /^\d+$/.test(d));
133
- } catch {
134
- return [];
135
- }
136
-
137
- const out = [];
138
- for (const raw of pids) {
139
- if (out.length >= MAX_PIDS) break;
140
- const pid = Number(raw);
141
- const pgrp = pgrpFor(raw);
142
- if (pgrp === null || !pgids.has(pgrp)) continue;
143
- // The CLI itself — its argv is the prompt. See the header.
144
- if (pid === pgrp) continue;
145
- const cmd = cmdFor(raw);
146
- if (!cmd) continue;
147
- const rss = rssFor(raw);
148
- out.push({ pid, pgid: pgrp, cmd, ...(rss != null ? { rss } : {}) });
149
- }
150
- return out;
130
+ const pids = procPids(io.list);
131
+ if (pids === null) return [];
132
+ return scanProcs(
133
+ pids,
134
+ (raw) => {
135
+ const pid = Number(raw);
136
+ const pgrp = pgrpFor(raw);
137
+ if (pgrp === null || !pgids.has(pgrp)) return null;
138
+ // The CLI itself — its argv is the prompt. See the header.
139
+ if (pid === pgrp) return null;
140
+ const cmd = cmdFor(raw);
141
+ if (!cmd) return null;
142
+ const rss = rssFor(raw);
143
+ return { pid, pgid: pgrp, cmd, ...(rss != null ? { rss } : {}) };
144
+ },
145
+ io.bound
146
+ ).rows;
151
147
  }
152
148
 
153
149
  function scanDarwin(pgids) {
@@ -242,12 +238,8 @@ export function liveGroups(pgids) {
242
238
  }
243
239
  return alive;
244
240
  }
245
- let pids;
246
- try {
247
- pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
248
- } catch {
249
- return want; // cannot tell — keep what we had rather than forget a live tab
250
- }
241
+ const pids = procPids();
242
+ if (pids === null) return want; // cannot tell — keep what we had rather than forget a live tab
251
243
  for (const raw of pids) {
252
244
  const pgrp = pgrpOf(raw);
253
245
  if (pgrp !== null && want.has(pgrp)) alive.add(pgrp);