flowviant 0.102.2 → 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 (141) 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 +184 -2396
  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 +34 -3
  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/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,249 @@
1
+ /**
2
+ * THE KNOWLEDGE LIBRARY'S RULES, pure (2026-09-26, split out of knowledge.mjs —
3
+ * SOLID SRP, F049): where every manifest entry may land, under what name, at
4
+ * what cap, and what the generated catalog says. No disk, no network — the
5
+ * materialisation (knowledgeFiles.mjs), the download (knowledgeFetch.mjs) and
6
+ * the roster driver (knowledge.mjs) all ask these, so a change to the bundle
7
+ * format or the naming rule is a change here and nowhere else.
8
+ *
9
+ * ── THE KEPT LIBRARY (2026-09-23, 0.97.0) ──
10
+ *
11
+ * The manifest may carry `library: { items: [...] }` — the designs and
12
+ * write-ups the project KEPT when a person accepted them — and each item lands
13
+ * at its own relative path, `designs/<slug>-v<N>.html` or
14
+ * `research/<slug>-v<N>.md`, with a generated `LIBRARY.md` at the root naming
15
+ * every one (path, title, kind, the card that asked for it, the date, what it
16
+ * supersedes). So "implement design A" is an agent opening LIBRARY.md, finding
17
+ * design A, and reading the page. At 0.99.0 a kept design is one folder,
18
+ * including its original relative sidecar paths and measured preview.
19
+ *
20
+ * SUBDIRECTORIES, AND EXACTLY TWO. The safe-name rule for a knowledge file
21
+ * forbids every separator; a library item is the one thing allowed ONE, and
22
+ * only after one of the two literal prefixes, with a stem that is already
23
+ * safe and the extension its kind hands back. `..`, a third segment, another
24
+ * prefix, a leading dot — refused, never rewritten, because a rewritten path
25
+ * could collide with another item's real one. The two directories are OURS the
26
+ * way the library directory is: a symlink or a file planted there is removed
27
+ * (the link, never its target) and a real directory made.
28
+ *
29
+ * ABSENT MEANS "LEAVE IT ALONE", the same rule the whole key keeps: a manifest
30
+ * with no `library` key is an older server, and `designs/`, `research/` and
31
+ * `LIBRARY.md` are neither synced nor swept. `library: { items: [] }` is the
32
+ * emptied catalog, SAID — both directories and the catalog go.
33
+ */
34
+
35
+ import { safeFileName } from './safeFileName.mjs';
36
+
37
+ /** Relative to the checkout, under `.flowviant/` beside the turn uploads and
38
+ * the artifacts — see `FLOWVIANT_OWN_PATHS` for why that is NOT one exclude
39
+ * line covering all three. */
40
+ export const KNOWLEDGE_DIR = '.flowviant/knowledge';
41
+
42
+ /**
43
+ * THE PATHS UNDER `.flowviant/` THAT ARE OURS, and the exclude file names these
44
+ * and nothing wider (2026-09-23).
45
+ *
46
+ * The first cut excluded `/.flowviant/` whole, from every place a turn spawns
47
+ * in — the attachment fetch's line, which until this release was written only
48
+ * in repos somebody had attached a file in. But `.flowviant/` is not only ours:
49
+ * the repo DECLARES `.flowviant/check.json` and `.flowviant/deploy.json` there,
50
+ * committed like any other file. An exclude line never hides a TRACKED file,
51
+ * so an existing one was safe — and a NEW one was not: an agent asked to "add a
52
+ * check command" writes `.flowviant/check.json`, its `git add -A` skips it as
53
+ * ignored, the commit lands without it, and `git status` shows nothing to
54
+ * explain why. Writing that line into every worktree on every turn would have
55
+ * made the product's own configuration uncommittable by the product's own
56
+ * agents. So the exclude names the four things this daemon writes there, and a
57
+ * repo's own `.flowviant/*.json` stays ordinary, committable work.
58
+ *
59
+ * (A repo that already carries the old `/.flowviant/` line keeps it — it is in
60
+ * the user's own exclude file, and removing a line we cannot prove we alone
61
+ * wrote is not ours to do.)
62
+ */
63
+ export const FLOWVIANT_OWN_PATHS = [
64
+ '.flowviant/knowledge/',
65
+ '.flowviant/knowledge.rev*',
66
+ '.flowviant/artifacts/',
67
+ '.flowviant/uploads/',
68
+ ];
69
+ /** The generated catalog of the kept library, at the knowledge root. Reserved
70
+ * like INSTRUCTIONS.md: a person's file of that name is suffixed, because the
71
+ * prompt tells the CLI this one is the catalog. */
72
+ export const LIBRARY_FILE = 'LIBRARY.md';
73
+ /** The two subdirectories a kept item may live in, by kind. */
74
+ export const LIBRARY_DIRS = { design: 'designs', research: 'research' };
75
+ const LIBRARY_EXT = { design: ['.html', '.htm', '.gltf', '.obj', '.glb', '.bin'], research: ['.md'] };
76
+ /** The server caps a library at 200 items; a longer manifest is cut. */
77
+ const MAX_LIBRARY_ITEMS = 256;
78
+
79
+ /** The person's standing brief, written from the manifest's `instructions`.
80
+ * A RESERVED name: a library FILE called this gets a suffix, because the
81
+ * prompt tells the CLI to read this one first and a stranger's file must
82
+ * never be able to stand in for the person's own words. */
83
+ export const INSTRUCTIONS_FILE = 'INSTRUCTIONS.md';
84
+ /** The server's per-file ceiling (`KNOWLEDGE_FILE_MAX_BYTES`), re-checked here:
85
+ * this is somebody's disk, and one place checking is one deploy away from
86
+ * zero places — the attachment fetch's own rule. */
87
+ export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
88
+ export const KNOWLEDGE_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
89
+ export const knowledgeMaxFor = (name, base) => /\.(glb|bin)$/i.test(name)
90
+ ? Math.max(base, KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
91
+ /** The manifest is capped at the server (25 files); a manifest longer than
92
+ * this is not one the product produced, and is cut rather than trusted. */
93
+ export const MAX_FILES = 64;
94
+
95
+ /**
96
+ * `safeFileName` (bin/lib/safeFileName.mjs — the server's rule, one daemon
97
+ * home): drops every path separator, never starts with a dot or a dash, and
98
+ * THE EXTENSION SURVIVES A CUT — byte-identical to what the server computes,
99
+ * so a knowledge file synced under this name and one requested by name later
100
+ * never disagree over the cut. An empty name is `file`.
101
+ */
102
+ export const safeKnowledgeName = (raw) => safeFileName(raw, 'file');
103
+
104
+ /**
105
+ * The LOCAL name each manifest entry is written under, in manifest order.
106
+ * Collisions get a numeric suffix (`spec.md`, `spec-2.md`) — two files a
107
+ * person uploaded with one name are two files, and silently writing one over
108
+ * the other loses one. Deterministic in the manifest's order, so the same
109
+ * manifest always yields the same names and a re-sync never renames a file.
110
+ * `INSTRUCTIONS.md` is reserved (compared case-insensitively — macOS and
111
+ * Windows disks are).
112
+ */
113
+ export function planKnowledgeNames(files) {
114
+ // The catalog's name and the two library directories are reserved beside
115
+ // the brief (0.97.0): a person's `LIBRARY.md` or a file called `designs`
116
+ // must never stand where the catalog or a directory has to.
117
+ const taken = new Set([
118
+ INSTRUCTIONS_FILE.toLowerCase(),
119
+ LIBRARY_FILE.toLowerCase(),
120
+ ...Object.values(LIBRARY_DIRS),
121
+ ]);
122
+ const out = [];
123
+ for (const f of files) {
124
+ const name = safeKnowledgeName(f.name);
125
+ const dot = name.lastIndexOf('.');
126
+ const stem = dot > 0 ? name.slice(0, dot) : name;
127
+ const ext = dot > 0 ? name.slice(dot) : '';
128
+ let candidate = name;
129
+ for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${stem}-${n}${ext}`;
130
+ taken.add(candidate.toLowerCase());
131
+ out.push({ ...f, local: candidate });
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * A kept item's relative path, or null when it is not EXACTLY
138
+ * `<designs|research>/<safe stem>.<its kind's extension>` — see the header.
139
+ * Refused rather than rewritten: a rewritten path is one the server never
140
+ * named, and it could land on another item's real one.
141
+ */
142
+ export function safeLibraryPath(name, kind) {
143
+ if (typeof name !== 'string' || !(kind in LIBRARY_DIRS)) return null;
144
+ const parts = name.split('/');
145
+ if (parts.length !== 2 && !(kind === 'design' && parts.length >= 3 && parts.length <= 7)) return null;
146
+ const [dir, ...rest] = parts;
147
+ const file = rest.at(-1);
148
+ if (dir !== LIBRARY_DIRS[kind]) return null;
149
+ if (rest.length >= 2 && !/^[a-z0-9-]+-v[1-9][0-9]*$/.test(rest[0])) return null;
150
+ if (rest.slice(1, -1).some((p) => !safeBundleSegment(p))) return null;
151
+ if (!file || (rest.length === 1 ? file !== safeKnowledgeName(file) : !safeBundleSegment(file))) return null;
152
+ if (!LIBRARY_EXT[kind].some((ext) => file.toLowerCase().endsWith(ext) && file.length > ext.length)) return null;
153
+ return name;
154
+ }
155
+
156
+ function safeBundleSegment(part) {
157
+ return typeof part === 'string' && part.length > 0 && part.length <= 80 &&
158
+ /^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(part) && !part.endsWith('.');
159
+ }
160
+
161
+ function safeBundleFile(path, rootDir) {
162
+ if (typeof path !== 'string' || !path.startsWith(`${rootDir}/`)) return null;
163
+ const relative = path.slice(rootDir.length + 1);
164
+ if (relative === 'preview.png') return null;
165
+ const parts = relative.split('/');
166
+ if (!parts.length || parts.some((p) => !safeBundleSegment(p))) return null;
167
+ if (!/\.(gltf|obj|glb|bin|png|jpg|jpeg|gif|webp|svg|json)$/i.test(relative)) return null;
168
+ return path;
169
+ }
170
+
171
+ /** The manifest's library items that are safe to write, in manifest order, or
172
+ * null when the key is ABSENT (an older server — leave everything alone). */
173
+ export function libraryItemsOf(manifest) {
174
+ const lib = manifest?.library;
175
+ if (lib === undefined || lib === null) return null;
176
+ if (typeof lib !== 'object' || !Array.isArray(lib.items)) return null;
177
+ const out = [];
178
+ const seen = new Set();
179
+ for (const it of lib.items.slice(0, MAX_LIBRARY_ITEMS)) {
180
+ if (!it || typeof it.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(it.id)) continue;
181
+ const path = safeLibraryPath(it.name, it.kind);
182
+ if (!path || seen.has(path.toLowerCase())) continue;
183
+ seen.add(path.toLowerCase());
184
+ const bundleDir = path.split('/').length >= 3 ? path.split('/').slice(0, 2).join('/') : null;
185
+ const expectedPreview = bundleDir ? `${bundleDir}/preview.png` : it.name.replace(/\.html$/, '.png');
186
+ const previewPath = it.kind === 'design' && it.preview && it.preview.name === expectedPreview ? expectedPreview : null;
187
+ const files = bundleDir && Array.isArray(it.files) ? it.files.map((f, index) => ({ ...f,
188
+ index, path: safeBundleFile(f.name, bundleDir),
189
+ })).filter((f) => f.path && !seen.has(f.path.toLowerCase())) : [];
190
+ for (const f of files) seen.add(f.path.toLowerCase());
191
+ out.push({ ...it, path, files, previewPath, previewUnknown: it.kind === 'design' && path.endsWith('.html') && it.preview === undefined });
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /** One line of the catalog: server strings are made single-line and capped —
197
+ * they are a person's card title and an artifact's name, headed into a file
198
+ * an agent reads, so they may not carry a line break that forges a second
199
+ * entry. */
200
+ const catalogText = (v, max) =>
201
+ String(v ?? '')
202
+ .replace(/[\r\n\t]+/g, ' ')
203
+ .replace(/\s+/g, ' ')
204
+ .trim()
205
+ .slice(0, max);
206
+
207
+ /**
208
+ * THE CATALOG, generated whole from the manifest on every sync:
209
+ *
210
+ * - designs/landing-v2.html — Landing mockup (design, from card "Redesign
211
+ * landing page", 2026-09-23, supersedes designs/landing-v1.html)
212
+ *
213
+ * (one line per item, oldest first — the server's order — so a sync that adds
214
+ * one item adds one line and moves nothing). Null when there are no items, in
215
+ * which case the file is removed rather than written empty.
216
+ */
217
+ export function renderLibraryCatalog(items) {
218
+ if (!items || items.length === 0) return null;
219
+ const lines = items.map((it) => {
220
+ const facts = [it.kind === 'research' ? 'research' : 'design'];
221
+ const card = catalogText(it.taskTitle, 200);
222
+ if (card) facts.push(`from card "${card.replace(/"/g, "'")}"`);
223
+ const day = typeof it.createdAt === 'string' ? it.createdAt.slice(0, 10) : '';
224
+ if (/^\d{4}-\d{2}-\d{2}$/.test(day)) facts.push(day);
225
+ const sup = typeof it.supersedes === 'string' ? catalogText(it.supersedes, 200) : '';
226
+ if (sup) facts.push(`supersedes ${sup}`);
227
+ const title = catalogText(it.title, 200) || it.path;
228
+ return `- ${it.path} — ${title} (${facts.join(', ')})`;
229
+ });
230
+ return (
231
+ '# Library\n\n' +
232
+ 'The designs and research this project kept. Paths are relative to this directory;\n' +
233
+ 'a newer version supersedes an older one of the same name.\n\n' +
234
+ `${lines.join('\n')}\n`
235
+ );
236
+ }
237
+
238
+ /** Does this manifest look like one the server produced? Anything else is
239
+ * ignored whole rather than half-applied. */
240
+ export function isKnowledgeManifest(k) {
241
+ return (
242
+ !!k &&
243
+ typeof k === 'object' &&
244
+ Number.isInteger(k.rev) &&
245
+ k.rev >= 0 &&
246
+ (k.instructions === null || k.instructions === undefined || typeof k.instructions === 'string') &&
247
+ Array.isArray(k.files)
248
+ );
249
+ }
@@ -32,8 +32,9 @@ import { git, baseBranchName } from './git.mjs';
32
32
  import { commitRecord, stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
33
33
  import { warn } from './ui.mjs';
34
34
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
35
+ import { fleetEndpoint } from './fleetWire.mjs';
35
36
 
36
- const LANDED_URL = FLEET_URL.replace(/\/agents\/?$/, '/base-landed');
37
+ const LANDED_URL = fleetEndpoint('base-landed', FLEET_URL);
37
38
  /** The server accepts 50 per report. A bigger range walks OLDEST-FIRST in
38
39
  * batches: the persisted tip advances to the last commit actually walked,
39
40
  * so the remainder is picked up on the next beat rather than skipped forever
@@ -53,10 +53,10 @@
53
53
 
54
54
  import { execFileSync } from 'node:child_process';
55
55
  import { createConnection } from 'node:net';
56
- import { readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
56
+ import { readFileSync } from 'node:fs';
57
57
  import { platform } from 'node:os';
58
58
  import { rssBytes } from './processes.mjs';
59
- import { sep } from 'node:path';
59
+ import { insideDir, socketHoldersIn } from './procScan.mjs';
60
60
 
61
61
  /**
62
62
  * INFRASTRUCTURE CHILDREN ARE NOT LISTENERS. cloudflared opens its own local
@@ -78,10 +78,9 @@ export const forgetInfraPid = (pid) => {
78
78
  infraPids.delete(pid);
79
79
  };
80
80
 
81
- /** A box with more processes than this is not one we walk per sweep. The scan
82
- * is one readlink per pid and runs every reconcile; this is the runaway
83
- * bound, not a capacity statement. */
84
- const MAX_PIDS = 4000;
81
+ /** A process that IS Flowviant is not a process Flowviant reports — see
82
+ * `infraPids` above and the daemon's own gate in `scanLinux`. */
83
+ const isFlowviant = (pid) => pid === process.pid || infraPids.has(pid);
85
84
  /**
86
85
  * Rows reported per session.
87
86
  *
@@ -249,64 +248,35 @@ function labelFor(pid) {
249
248
  }
250
249
  }
251
250
 
252
- function scanLinux(worktree) {
251
+ /**
252
+ * The worktree's listening sockets, from the kernel's table and the processes
253
+ * standing in the worktree — `{ rows, complete }`. The walk of /proc is
254
+ * `procScan.mjs`'s (bounded on the processes it reads in the worktree, never
255
+ * on which pids are looked at); `complete: false` means it stopped with pids
256
+ * unread, and a listener among them is UNMEASURED, not absent.
257
+ *
258
+ * Exported with `io` ONLY so the scan-order property can be pinned without a
259
+ * box that runs four thousand processes. Nothing in the daemon passes it.
260
+ */
261
+ export function scanLinux(worktree, io) {
253
262
  const inodes = listeningByInode();
254
- if (inodes.size === 0) return [];
255
-
256
- let root;
257
- try {
258
- root = realpathSync(worktree);
259
- } catch {
260
- return []; // the directory is gone — retired under us
261
- }
262
- const prefix = root.endsWith(sep) ? root : root + sep;
263
- const inside = (p) => p === root || p.startsWith(prefix);
264
-
265
- let pids;
266
- try {
267
- pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
268
- } catch {
269
- return [];
270
- }
271
- if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
263
+ if (inodes.size === 0) return { rows: [], complete: true };
264
+
265
+ // NEVER THE DAEMON ITSELF. `startAuthProxy` binds a loopback port IN THIS
266
+ // PROCESS to gate a preview, and the daemon's own cwd is inside the
267
+ // checkout — so the gate was attributed to the repo place and surfaced as
268
+ // a listener the operator could click Stop on. Stopping it would take down
269
+ // the door in front of a live share, and it is not a thing anybody
270
+ // started: it is us. Same rule `processes.mjs` keeps by never reporting the
271
+ // group leader, for the same reason — a process that IS Flowviant is not a
272
+ // process Flowviant reports.
273
+ const scan = socketHoldersIn(worktree, { skip: isFlowviant, io });
274
+ if (scan === null) return { rows: [], complete: true };
272
275
 
273
276
  const found = new Map(); // port -> row
274
- for (const pid of pids) {
275
- // Cheap filter FIRST: one readlink rejects almost every process on the box,
276
- // and only survivors pay for a readdir of their fd table.
277
- let cwd;
278
- try {
279
- cwd = readlinkSync(`/proc/${pid}/cwd`);
280
- } catch {
281
- continue; // not ours, or gone
282
- }
283
- if (!inside(cwd)) continue;
284
- // NEVER THE DAEMON ITSELF. `startAuthProxy` binds a loopback port IN THIS
285
- // PROCESS to gate a preview, and the daemon's own cwd is inside the
286
- // checkout — so the gate was attributed to the repo place and surfaced as
287
- // a listener the operator could click Stop on. Stopping it would take down
288
- // the door in front of a live share, and it is not a thing anybody
289
- // started: it is us. Same rule `processes.mjs` keeps by never reporting the
290
- // group leader, for the same reason — a process that IS Flowviant is not a
291
- // process Flowviant reports.
292
- if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
293
-
294
- let fds;
295
- try {
296
- fds = readdirSync(`/proc/${pid}/fd`);
297
- } catch {
298
- continue;
299
- }
300
- for (const fd of fds) {
301
- let link;
302
- try {
303
- link = readlinkSync(`/proc/${pid}/fd/${fd}`);
304
- } catch {
305
- continue;
306
- }
307
- const m = /^socket:\[(\d+)\]$/.exec(link);
308
- if (!m) continue;
309
- const hit = inodes.get(m[1]);
277
+ for (const { pid, inodes: held } of scan.holders) {
278
+ for (const inode of held) {
279
+ const hit = inodes.get(inode);
310
280
  if (!hit) continue;
311
281
  if (found.has(hit.port)) continue;
312
282
  const chosen = chosenPort(hit.port);
@@ -319,13 +289,13 @@ function scanLinux(worktree) {
319
289
  // was thrown away the moment the row was built. Keeping it is what lets
320
290
  // a surface group nine sockets under the ONE program that opened them,
321
291
  // and it is the only thing a kill could ever be aimed at.
322
- pid: Number(pid),
292
+ pid,
323
293
  ...(rss != null ? { rss } : {}),
324
294
  ...(chosen === undefined ? {} : { chosen }),
325
295
  });
326
296
  }
327
297
  }
328
- return [...found.values()];
298
+ return { rows: [...found.values()], complete: scan.complete };
329
299
  }
330
300
 
331
301
  // ── macOS ──────────────────────────────────────────────────────────────────
@@ -363,12 +333,10 @@ function scanDarwin(worktree) {
363
333
  } catch {
364
334
  /* no memory on this box — rows simply carry no rss */
365
335
  }
366
- const self = String(process.pid);
367
336
  for (const line of lsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']).split('\n')) {
368
337
  // The daemon's own preview gate is not something the driver started — see
369
338
  // the linux branch for the whole argument. Nor its cloudflared children.
370
- if (line.startsWith('p'))
371
- pid = line.slice(1) === self || infraPids.has(Number(line.slice(1))) ? null : line.slice(1);
339
+ if (line.startsWith('p')) pid = isFlowviant(Number(line.slice(1))) ? null : line.slice(1);
372
340
  else if (line.startsWith('n') && pid) {
373
341
  const m = /:(\d+)$/.exec(line.slice(1));
374
342
  if (!m) continue;
@@ -380,13 +348,8 @@ function scanDarwin(worktree) {
380
348
  }
381
349
  if (byPid.size === 0) return [];
382
350
 
383
- let root;
384
- try {
385
- root = realpathSync(worktree);
386
- } catch {
387
- return [];
388
- }
389
- const prefix = root.endsWith(sep) ? root : root + sep;
351
+ const inside = insideDir(worktree);
352
+ if (!inside) return [];
390
353
 
391
354
  // Pass 2: the cwd of exactly those pids, in ONE batched call.
392
355
  const out = new Map();
@@ -394,8 +357,7 @@ function scanDarwin(worktree) {
394
357
  for (const line of lsof(['-a', '-d', 'cwd', '-F', 'pn', '-p', [...byPid.keys()].join(',')]).split('\n')) {
395
358
  if (line.startsWith('p')) cur = line.slice(1);
396
359
  else if (line.startsWith('n') && cur) {
397
- const cwd = line.slice(1);
398
- if (cwd !== root && !cwd.startsWith(prefix)) continue;
360
+ if (!inside(line.slice(1))) continue;
399
361
  for (const row of byPid.get(cur) || []) {
400
362
  if (out.has(row.port)) continue;
401
363
  const chosen = chosenPort(row.port);
@@ -452,17 +414,39 @@ export function byChosenThenPort(a, b) {
452
414
  * Repository block already settled that trade for branches and worktrees: the
453
415
  * capped list rides beside the count it was cut from. An empty array on an
454
416
  * unsupported platform means "we did not look" — callers must not turn it into
455
- * "nothing is running".
417
+ * "nothing is running". `incomplete: true` rides along when the /proc walk hit
418
+ * its work bound (`procScan.mjs`) with pids still unread.
419
+ *
420
+ * `incomplete` IS NOT RELAYED, deliberately, for now. The listener reports
421
+ * (`workPlaceReports.mjs`, `repoState.mjs`) send `rows`/`total` only: a new report field
422
+ * needs no floor, but it needs a reader on the server and a sentence on the
423
+ * web, and neither exists — sending it would be a field nobody reads. The cut
424
+ * needs thousands of processes standing in ONE worktree; the share path, where
425
+ * the cut would change an answer, says it in words (`originFor`). Relaying it
426
+ * to the Workbench is a product call, not this module's.
427
+ *
428
+ * `io` is the test seam `scanLinux` documents; nothing in the daemon passes it.
456
429
  */
457
- export function measureListeners(worktree) {
430
+ export function measureListeners(worktree, io) {
458
431
  if (!worktree) return { rows: [], total: 0 };
459
- let rows;
432
+ let scan;
460
433
  try {
461
- rows = platform() === 'linux' ? scanLinux(worktree) : platform() === 'darwin' ? scanDarwin(worktree) : [];
434
+ scan =
435
+ platform() === 'linux'
436
+ ? scanLinux(worktree, io)
437
+ : { rows: platform() === 'darwin' ? scanDarwin(worktree) : [], complete: true };
462
438
  } catch {
463
439
  return { rows: [], total: 0 };
464
440
  }
465
- return { rows: rows.sort(byChosenThenPort).slice(0, MAX_ROWS), total: rows.length };
441
+ const { rows, complete } = scan;
442
+ return {
443
+ rows: rows.sort(byChosenThenPort).slice(0, MAX_ROWS),
444
+ total: rows.length,
445
+ // SAID ONLY WHEN IT HAPPENED: the walk stopped with pids unread, so the
446
+ // rows are real and the total is a floor — a listener missing from them
447
+ // was not looked for, which is not the same fact as "not running".
448
+ ...(complete ? {} : { incomplete: true }),
449
+ };
466
450
  }
467
451
 
468
452
  /** The capped rows alone, for callers with nowhere to put a total. */
@@ -537,23 +521,36 @@ export function isListening(port, timeoutMs = 1500) {
537
521
  * file cannot measure answers the loopback it always dialled — the same
538
522
  * behaviour as before, never a refusal invented from ignorance.
539
523
  */
540
- export function originFor(worktree, port) {
524
+ export function originFor(worktree, port, io) {
541
525
  if (!worktree || !Number.isInteger(port) || port <= 0 || port > 65535) {
542
526
  return { error: `nothing is listening on port ${port} in this worktree.` };
543
527
  }
544
- let sockets;
528
+ let scan;
545
529
  try {
546
- sockets =
530
+ scan =
547
531
  platform() === 'linux'
548
- ? socketsOnPortLinux(worktree, port)
532
+ ? socketsOnPortLinux(worktree, port, io)
549
533
  : platform() === 'darwin'
550
- ? socketsOnPortDarwin(worktree, port)
534
+ ? { sockets: socketsOnPortDarwin(worktree, port), complete: true }
551
535
  : null;
552
536
  } catch {
553
- sockets = null;
537
+ scan = null;
538
+ }
539
+ if (scan == null) return { host: '127.0.0.1' };
540
+ const picked = pickOrigin(scan.sockets, port);
541
+ // A socket on this port whose holder the walk never reached is read as
542
+ // FOREIGN by the rule — the safe reading for a shadow check — so an
543
+ // unshadowed socket of ours is shared exactly as a complete walk would share
544
+ // it. Only when the rule would REFUSE does the cut matter: then "nothing is
545
+ // listening" (or "another process") may be about a socket the kernel holds
546
+ // for a process the walk never reached, and that is said instead (F007;
547
+ // review 2026-09-26: never a refusal built on the cut alone).
548
+ if (picked.error && !scan.complete && scan.sockets.some((s) => !s.ours)) {
549
+ return {
550
+ error: `this machine runs more processes in this worktree than one look reads, so which one holds port ${port} could not be told — it was not shared.`,
551
+ };
554
552
  }
555
- if (sockets == null) return { host: '127.0.0.1' };
556
- return pickOrigin(sockets, port);
553
+ return picked;
557
554
  }
558
555
 
559
556
  /**
@@ -617,7 +614,9 @@ function hexAddr(hex, family) {
617
614
  return `${head}::${tail}`;
618
615
  }
619
616
 
620
- function socketsOnPortLinux(worktree, port) {
617
+ /** `{ sockets, complete }` on success, null when /proc cannot be listed.
618
+ * `io` is the test seam `scanLinux` documents. */
619
+ export function socketsOnPortLinux(worktree, port, io) {
621
620
  const byInode = new Map(); // inode -> { family, addr }
622
621
  for (const [f, family] of [
623
622
  ['/proc/net/tcp', 4],
@@ -637,51 +636,17 @@ function socketsOnPortLinux(worktree, port) {
637
636
  if (c[9] && c[9] !== '0') byInode.set(c[9], { family, addr: hexAddr(a, family) });
638
637
  }
639
638
  }
640
- if (byInode.size === 0) return [];
641
- let root;
642
- try {
643
- root = realpathSync(worktree);
644
- } catch {
645
- return [];
646
- }
647
- const prefix = root.endsWith(sep) ? root : root + sep;
639
+ if (byInode.size === 0) return { sockets: [], complete: true };
640
+ const scan = socketHoldersIn(worktree, { skip: isFlowviant, io });
641
+ if (scan === null) return null;
648
642
  const ours = new Set();
649
- let pids;
650
- try {
651
- pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
652
- } catch {
653
- return null;
654
- }
655
- if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
656
- for (const pid of pids) {
657
- let cwd;
658
- try {
659
- cwd = readlinkSync(`/proc/${pid}/cwd`);
660
- } catch {
661
- continue;
662
- }
663
- if (cwd !== root && !cwd.startsWith(prefix)) continue;
664
- if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
665
- let fds;
666
- try {
667
- fds = readdirSync(`/proc/${pid}/fd`);
668
- } catch {
669
- continue;
670
- }
671
- for (const fd of fds) {
672
- let link;
673
- try {
674
- link = readlinkSync(`/proc/${pid}/fd/${fd}`);
675
- } catch {
676
- continue;
677
- }
678
- const m = /^socket:\[(\d+)\]$/.exec(link);
679
- if (m && byInode.has(m[1])) ours.add(m[1]);
680
- }
681
- }
643
+ for (const { inodes } of scan.holders) for (const inode of inodes) if (byInode.has(inode)) ours.add(inode);
682
644
  // A socket whose holder we could not read (another user's process) is
683
645
  // FOREIGN — the safe reading for a refusal check.
684
- return [...byInode.entries()].map(([inode, s]) => ({ ...s, ours: ours.has(inode) }));
646
+ return {
647
+ sockets: [...byInode.entries()].map(([inode, s]) => ({ ...s, ours: ours.has(inode) })),
648
+ complete: scan.complete,
649
+ };
685
650
  }
686
651
 
687
652
  function socketsOnPortDarwin(worktree, port) {
@@ -700,22 +665,15 @@ function socketsOnPortDarwin(worktree, port) {
700
665
  }
701
666
  }
702
667
  if (!rows.length) return [];
703
- let root;
704
- try {
705
- root = realpathSync(worktree);
706
- } catch {
707
- return [];
708
- }
709
- const prefix = root.endsWith(sep) ? root : root + sep;
668
+ const within = insideDir(worktree);
669
+ if (!within) return [];
710
670
  const inside = new Set();
711
- const self = String(process.pid);
712
671
  let cur = null;
713
672
  const pids = [...new Set(rows.map((r) => r.pid))];
714
673
  for (const line of lsof(['-a', '-d', 'cwd', '-F', 'pn', '-p', pids.join(',')]).split('\n')) {
715
674
  if (line.startsWith('p')) cur = line.slice(1);
716
675
  else if (line.startsWith('n') && cur) {
717
- const cwd = line.slice(1);
718
- if ((cwd === root || cwd.startsWith(prefix)) && cur !== self && !infraPids.has(Number(cur))) inside.add(cur);
676
+ if (within(line.slice(1)) && !isFlowviant(Number(cur))) inside.add(cur);
719
677
  }
720
678
  }
721
679
  return rows.map((r) => ({ family: r.family, addr: r.addr, ours: inside.has(r.pid) }));