pi-lens 3.8.67 → 3.8.69

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 (74) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +57 -38
  3. package/dist/clients/agent-nudge.js +262 -0
  4. package/dist/clients/biome-client.js +2 -2
  5. package/dist/clients/bus-publish.js +110 -0
  6. package/dist/clients/deps/ast-grep-napi.js +20 -1
  7. package/dist/clients/deps/pi-tui.js +4 -1
  8. package/dist/clients/deps/typebox.js +5 -2
  9. package/dist/clients/deps/web-tree-sitter.js +22 -1
  10. package/dist/clients/diagnostic-logger.js +2 -2
  11. package/dist/clients/diagnostics-publish.js +180 -0
  12. package/dist/clients/dispatch/dispatcher.js +2 -0
  13. package/dist/clients/dispatch/integration.js +153 -2
  14. package/dist/clients/dispatch/runners/ast-grep-napi.js +58 -13
  15. package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
  16. package/dist/clients/file-utils.js +20 -7
  17. package/dist/clients/installer/index.js +32 -1
  18. package/dist/clients/instance-reaper.js +516 -0
  19. package/dist/clients/instance-registry.js +238 -0
  20. package/dist/clients/jscpd-client.js +2 -2
  21. package/dist/clients/lens-config.js +17 -0
  22. package/dist/clients/lens-engine.js +44 -10
  23. package/dist/clients/lsp/cascade-tier.js +254 -0
  24. package/dist/clients/lsp/client.js +81 -3
  25. package/dist/clients/lsp/index.js +3 -0
  26. package/dist/clients/lsp/launch.js +2 -0
  27. package/dist/clients/lsp/server-strategies.js +71 -0
  28. package/dist/clients/lsp/server.js +119 -6
  29. package/dist/clients/mcp/analyze.js +110 -1
  30. package/dist/clients/module-report.js +163 -18
  31. package/dist/clients/path-utils.js +25 -0
  32. package/dist/clients/persist-debounce.js +63 -0
  33. package/dist/clients/pipeline.js +82 -2
  34. package/dist/clients/project-diagnostics/extractors.js +30 -4
  35. package/dist/clients/project-snapshot.js +7 -2
  36. package/dist/clients/quiet-window.js +168 -0
  37. package/dist/clients/recent-touches.js +233 -0
  38. package/dist/clients/review-graph/builder.js +20 -2
  39. package/dist/clients/runtime-agent-end.js +51 -1
  40. package/dist/clients/runtime-coordinator.js +21 -0
  41. package/dist/clients/runtime-session.js +154 -48
  42. package/dist/clients/runtime-tool-result.js +46 -0
  43. package/dist/clients/runtime-turn.js +9 -0
  44. package/dist/clients/session-lifecycle.js +252 -0
  45. package/dist/clients/sgconfig.js +246 -38
  46. package/dist/clients/slow-fs.js +137 -0
  47. package/dist/clients/source-filter.js +62 -14
  48. package/dist/clients/subagent-mode.js +87 -0
  49. package/dist/clients/tree-sitter-symbol-extractor.js +108 -0
  50. package/dist/clients/tui-fit.js +54 -0
  51. package/dist/clients/turn-summary-render.js +72 -0
  52. package/dist/clients/turn-summary.js +132 -0
  53. package/dist/clients/widget-state.js +27 -30
  54. package/dist/clients/word-index.js +296 -1
  55. package/dist/index.js +62809 -1633
  56. package/dist/mcp/build-staleness.js +123 -0
  57. package/dist/mcp/server.js +377 -43
  58. package/dist/tools/ast-grep-search.js +1 -1
  59. package/dist/tools/lens-diagnostics.js +42 -12
  60. package/dist/tools/lsp-diagnostics.js +117 -4
  61. package/dist/tools/module-report.js +14 -11
  62. package/dist/tools/symbol-search.js +110 -0
  63. package/package.json +3 -2
  64. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  65. package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
  66. package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
  67. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
  68. package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
  69. package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
  70. package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +1 -1
  71. package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
  72. package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
  73. package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
  74. package/dist/clients/tree-sitter-fixer.js +0 -127
@@ -0,0 +1,516 @@
1
+ /**
2
+ * Orphaned LSP process reaper (#472), built on the instance registry (#449
3
+ * slice 1).
4
+ *
5
+ * Split into a PURE decision function (`decideOrphanReaping`) and an IMPURE
6
+ * sweep (`sweepOrphans`) so the decision logic is unit-testable with fake
7
+ * pid tables — no real process spawns/kills in tests.
8
+ *
9
+ * Why the registry reaper, not EOF/processId alone (see issue #472): both are
10
+ * best-effort hints a well-behaved server may honor (typescript-language-server
11
+ * does; ast-grep's native exe does not — an upstream LSP-spec violation). The
12
+ * registry reaper works regardless of why a stdin pipe write-end stayed open
13
+ * after the parent died (Windows handle-inheritance capture) — it identifies
14
+ * dead-parent instances directly and kills the full recorded child tree, with
15
+ * a command-line marker fallback for the case where the pid itself was
16
+ * recycled or the mid-tree pid link is broken (e.g. a dead node-wrapper whose
17
+ * native-exe grandchild is still alive under a different, unrecorded pid).
18
+ *
19
+ * #525 root cause: a parent instance was ONLY ever considered dead via
20
+ * `isPidAlive(instance.pid)` — a raw `process.kill(pid, 0)` check. Unlike
21
+ * child pids (which get a command-line/marker identity check via
22
+ * `matchProcess` to guard against a recycled pid), the PARENT pid had no
23
+ * identity verification at all, because `InstanceEntry` never recorded the
24
+ * parent's own command line. Windows recycles pids far more aggressively than
25
+ * POSIX (no zombie/wait-reaping semantics holding a dead pid "reserved"), so
26
+ * over a long enough window (observed: ~13h) a dead parent's pid is very
27
+ * plausibly reassigned to a live, unrelated process — `isPidAlive` then
28
+ * (correctly, per its own conservative contract) reports "alive", and the
29
+ * stale instance is never reaped, no matter how old its heartbeat is.
30
+ *
31
+ * The #525 fix is deliberately ASYMMETRIC by consequence:
32
+ *
33
+ * - **Heartbeat staleness (`STALE_HEARTBEAT_MS`) cleans REGISTRY ENTRIES,
34
+ * never kills.** A stale-heartbeat-but-pid-alive instance goes into
35
+ * `staleInstances` (entry dropped from instances.json) with ZERO process
36
+ * kills and its children still marker-protected. Why: the heartbeat call
37
+ * sites are runtime-turn.ts (per turn end) and quiet-window.ts (per run
38
+ * settle) ONLY — no timer exists. A pi session left OPEN but UNUSED
39
+ * overnight fires neither, so its heartbeat legitimately goes >6h stale
40
+ * while the session and its warm LSP fleet are genuinely alive. Killing on
41
+ * staleness would take that fleet down under the idle session — and
42
+ * `matchProcess` would NOT save it (its children really ARE that
43
+ * instance's LSP servers; identity verification guards against pid reuse,
44
+ * not against misclassifying a live parent). Removing just the entry is
45
+ * safe: the idle session's next turn re-registers via `registerInstance`.
46
+ * - **Process kills require a pid-confirmed-DEAD parent** (`deadInstances`),
47
+ * exactly as before #525. Only then are its children classified for
48
+ * kill/marker-search.
49
+ *
50
+ * This still fixes both observed #525 cases: the 13h-stale test-fixture
51
+ * entry AND the recycled-parent-pid case (a dead parent whose pid now
52
+ * belongs to an unrelated live process — `isPidAlive` lies, but the stale
53
+ * heartbeat gets the ENTRY dropped, which is all the pollution fix needs).
54
+ */
55
+ /**
56
+ * A pid-ALIVE instance whose heartbeat is older than this gets its registry
57
+ * ENTRY removed — it is NEVER kill-eligible on staleness alone (see the
58
+ * module docstring's asymmetry note: an overnight-idle-but-alive session
59
+ * legitimately exceeds this threshold because heartbeats only fire at turn
60
+ * end / run settle, so staleness must clean records, not kill processes).
61
+ * 6 hours comfortably catches the observed 13h-stale pollution case.
62
+ */
63
+ export const STALE_HEARTBEAT_MS = 6 * 60 * 60 * 1000;
64
+ import { spawn as nodeSpawn } from "node:child_process";
65
+ import * as fs from "node:fs";
66
+ import * as path from "node:path";
67
+ import { getGlobalPiLensDir } from "./file-utils.js";
68
+ import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-registry.js";
69
+ import { logLatency } from "./latency-logger.js";
70
+ const isWindows = process.platform === "win32";
71
+ /**
72
+ * KILL eligibility: pid-confirmed-dead ONLY. Heartbeat staleness must never
73
+ * make an instance kill-eligible — an overnight-idle-but-alive session
74
+ * legitimately goes >STALE_HEARTBEAT_MS stale (heartbeats fire only at turn
75
+ * end / run settle; no timer exists), and killing its genuine live LSP
76
+ * children would pass `matchProcess` identity verification (they really are
77
+ * that instance's servers — the matcher guards against pid reuse, not
78
+ * against misclassifying a live parent). See the module docstring (#525).
79
+ */
80
+ function isInstanceKillEligible(instance, isPidAlive) {
81
+ return !isPidAlive(instance.pid);
82
+ }
83
+ /**
84
+ * REGISTRY-ENTRY staleness: heartbeat older than `STALE_HEARTBEAT_MS` (or
85
+ * unparseable — missing data must never keep a polluted entry alive
86
+ * forever). Drives entry removal ONLY, never kills (#525). This is what
87
+ * cleans the recycled-parent-pid case: `isPidAlive` lies for a long-dead
88
+ * parent whose pid the OS reassigned to an unrelated live process, but the
89
+ * stale heartbeat still gets the ENTRY dropped.
90
+ */
91
+ function isInstanceEntryStale(instance, now) {
92
+ const heartbeatMs = Date.parse(instance.heartbeatAt);
93
+ if (Number.isNaN(heartbeatMs))
94
+ return true;
95
+ return now - heartbeatMs > STALE_HEARTBEAT_MS;
96
+ }
97
+ /**
98
+ * Markers claimed by any pid-ALIVE instance's children. A marker search
99
+ * kills by command-line match, so a marker that a live session also uses
100
+ * must never be searched — killing it would take down the live session's
101
+ * server. Protection is deliberately keyed on pid-liveness ALONE, regardless
102
+ * of heartbeat staleness (#525): a stale-heartbeat-but-alive instance (e.g.
103
+ * overnight-idle) must keep its children protected — protection stays
104
+ * conservative even where entry cleanup does not.
105
+ * Markers are per-process-unique by construction (sgconfig.ts embeds the
106
+ * pid), so this is defense in depth against non-unique markers ever
107
+ * reappearing (#472: the original shared baseline.sgconfig.yml would have
108
+ * made the fallback kill every live ast-grep on the machine).
109
+ */
110
+ function collectLiveMarkers(registry, isPidAlive) {
111
+ const liveMarkers = new Set();
112
+ for (const instance of registry) {
113
+ if (!isPidAlive(instance.pid))
114
+ continue;
115
+ for (const child of instance.lspChildren) {
116
+ if (child.marker)
117
+ liveMarkers.add(child.marker);
118
+ }
119
+ }
120
+ return liveMarkers;
121
+ }
122
+ /**
123
+ * Classify one dead-parent instance's children into kills / marker-searches,
124
+ * appending onto the shared `out` accumulator. Extracted from
125
+ * `decideOrphanReaping` to keep cognitive complexity in check — no behavior
126
+ * change, just the per-instance inner loop pulled out.
127
+ */
128
+ function classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, out) {
129
+ for (const child of instance.lspChildren) {
130
+ const childAlive = isPidAlive(child.pid);
131
+ if (childAlive) {
132
+ const identityOk = matchProcess
133
+ ? matchProcess(child.pid, {
134
+ command: child.command,
135
+ marker: child.marker,
136
+ })
137
+ : true;
138
+ if (identityOk) {
139
+ out.childrenToKill.push({
140
+ pid: child.pid,
141
+ serverId: child.serverId,
142
+ command: child.command,
143
+ });
144
+ continue;
145
+ }
146
+ }
147
+ // Child pid is dead, or alive-but-identity-mismatched (recycled pid) —
148
+ // if we have a marker, surface it so the caller can find a live
149
+ // process (e.g. the native exe grandchild) by command-line match.
150
+ // Never surface a marker a live instance also claims (see above).
151
+ if (child.marker && !liveMarkers.has(child.marker)) {
152
+ out.markerSearches.push({ marker: child.marker, serverId: child.serverId });
153
+ }
154
+ }
155
+ }
156
+ /**
157
+ * Pure decision function: given the registry state and injectable liveness /
158
+ * identity predicates, decide what to kill. Performs zero I/O.
159
+ *
160
+ * @param isPidAlive - `process.kill(pid, 0)`-style liveness check. Must be
161
+ * CONSERVATIVE: only pid-confirmed-dead (ESRCH) counts as dead. Any
162
+ * ambiguous result (EPERM, or the caller's fake table saying "unknown")
163
+ * must be treated as alive — never kill on an ambiguous signal-check.
164
+ * @param matchProcess - optional identity verification (e.g. confirm the
165
+ * live pid's command line still matches what we recorded) to guard against
166
+ * a recycled pid coincidentally matching. If omitted, liveness alone is used.
167
+ * @param now - epoch ms "now", for heartbeat-staleness comparison (#525).
168
+ * Injectable for deterministic tests; defaults to `Date.now()`. The two
169
+ * signals are ASYMMETRIC by consequence: pid-confirmed-dead ⇒
170
+ * `deadInstances` (kill-eligible + entry removal); pid-alive but heartbeat
171
+ * older than `STALE_HEARTBEAT_MS` ⇒ `staleInstances` (entry removal ONLY —
172
+ * never kills, never loses marker protection; the parent may be an
173
+ * overnight-idle-but-alive session). See the module docstring.
174
+ */
175
+ export function decideOrphanReaping(registry, isPidAlive, matchProcess, now = Date.now()) {
176
+ const deadInstances = [];
177
+ const staleInstances = [];
178
+ const childrenToKill = [];
179
+ const markerSearches = [];
180
+ // Marker protection is pid-liveness ONLY — a stale-heartbeat-but-alive
181
+ // instance keeps its children protected (conservative on the kill side).
182
+ const liveMarkers = collectLiveMarkers(registry, isPidAlive);
183
+ for (const instance of registry) {
184
+ if (isInstanceKillEligible(instance, isPidAlive)) {
185
+ // pid-confirmed-dead: entry removal + children classified for kills.
186
+ deadInstances.push(instance);
187
+ classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, {
188
+ childrenToKill,
189
+ markerSearches,
190
+ });
191
+ }
192
+ else if (isInstanceEntryStale(instance, now)) {
193
+ // pid-alive but stale heartbeat: record cleanup only — NO kills.
194
+ staleInstances.push(instance);
195
+ }
196
+ // else: alive + fresh heartbeat — leave it alone entirely.
197
+ }
198
+ return { deadInstances, staleInstances, childrenToKill, markerSearches };
199
+ }
200
+ // --- Impure liveness / identity / kill helpers ---
201
+ /** `process.kill(pid, 0)` liveness check: ESRCH ⇒ dead, anything else
202
+ * (EPERM, or no error thrown at all) ⇒ conservatively alive. */
203
+ function realIsPidAlive(pid) {
204
+ if (!Number.isFinite(pid) || pid <= 0)
205
+ return false;
206
+ try {
207
+ process.kill(pid, 0);
208
+ return true; // no throw — process exists and we can signal it
209
+ }
210
+ catch (err) {
211
+ const code = err?.code;
212
+ if (code === "ESRCH")
213
+ return false; // definitively dead
214
+ // EPERM (exists, no permission) or any other/unknown errno: ambiguous —
215
+ // never treat as dead.
216
+ return true;
217
+ }
218
+ }
219
+ function windowsExe(name) {
220
+ return path.join(process.env.SystemRoot ?? String.raw `C:\Windows`, "System32", name);
221
+ }
222
+ /** Resolve an absolute path to `ps` (S4036: never spawn via bare PATH lookup).
223
+ * Prefers `/bin/ps` (present on virtually every POSIX system), falls back to
224
+ * `/usr/bin/ps`, and defaults back to `/bin/ps` if neither probe succeeds
225
+ * (spawn will then fail closed rather than silently resolving via PATH). */
226
+ function posixPsPath() {
227
+ if (fs.existsSync("/bin/ps"))
228
+ return "/bin/ps";
229
+ if (fs.existsSync("/usr/bin/ps"))
230
+ return "/usr/bin/ps";
231
+ return "/bin/ps";
232
+ }
233
+ /** Escape a value for embedding in a WQL LIKE clause: WQL uses `'` as the
234
+ * string delimiter (doubled to escape) and `%`/`_` as wildcards — the marker
235
+ * is an opaque path string, so escape all three before interpolating. */
236
+ function escapeWqlLikeValue(value) {
237
+ return value.replaceAll("'", "''").replaceAll(/[%_]/g, (ch) => `[${ch}]`);
238
+ }
239
+ /** Search running processes whose command line contains `marker` (Windows,
240
+ * via CIM/WQL). Returns matching pids. Best-effort: any failure ⇒ []. */
241
+ async function findPidsByMarkerWindows(marker) {
242
+ if (!isWindows || !marker)
243
+ return [];
244
+ const escaped = escapeWqlLikeValue(marker);
245
+ // $PID exclusion: the query's own powershell.exe command line embeds the
246
+ // marker string, so it would match itself.
247
+ const psScript = `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%${escaped}%'" ` +
248
+ `| Where-Object { $_.ProcessId -ne $PID } ` +
249
+ `| Select-Object -ExpandProperty ProcessId`;
250
+ return new Promise((resolve) => {
251
+ try {
252
+ const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
253
+ const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
254
+ let out = "";
255
+ child.stdout?.on("data", (chunk) => {
256
+ out += chunk.toString();
257
+ });
258
+ child.once("error", () => resolve([]));
259
+ child.once("close", () => {
260
+ const pids = out
261
+ .split(/\r?\n/)
262
+ .map((line) => Number(line.trim()))
263
+ .filter((n) => Number.isFinite(n) && n > 0);
264
+ resolve(pids);
265
+ });
266
+ }
267
+ catch {
268
+ resolve([]);
269
+ }
270
+ });
271
+ }
272
+ /** Fetch command lines for a set of pids in one query (Windows: CIM; POSIX:
273
+ * `ps`). Returns a pid → command-line map; pids that can't be resolved are
274
+ * simply absent (the caller treats absent as "identity unverifiable — do not
275
+ * kill by pid"). Best-effort: any failure ⇒ empty map. */
276
+ async function queryCommandLines(pids) {
277
+ const valid = [...new Set(pids.filter((p) => Number.isFinite(p) && p > 0))];
278
+ const map = new Map();
279
+ if (valid.length === 0)
280
+ return map;
281
+ if (isWindows) {
282
+ const filter = valid.map((p) => `ProcessId=${p}`).join(" OR ");
283
+ const psScript = `Get-CimInstance Win32_Process -Filter "${filter}" ` +
284
+ `| ForEach-Object { "$($_.ProcessId)\t$($_.CommandLine)" }`;
285
+ return new Promise((resolve) => {
286
+ try {
287
+ const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
288
+ const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
289
+ let out = "";
290
+ child.stdout?.on("data", (chunk) => {
291
+ out += chunk.toString();
292
+ });
293
+ child.once("error", () => resolve(map));
294
+ child.once("close", () => {
295
+ for (const line of out.split(/\r?\n/)) {
296
+ const tab = line.indexOf("\t");
297
+ if (tab <= 0)
298
+ continue;
299
+ const pid = Number(line.slice(0, tab).trim());
300
+ if (Number.isFinite(pid) && pid > 0)
301
+ map.set(pid, line.slice(tab + 1));
302
+ }
303
+ resolve(map);
304
+ });
305
+ }
306
+ catch {
307
+ resolve(map);
308
+ }
309
+ });
310
+ }
311
+ return new Promise((resolve) => {
312
+ try {
313
+ const child = nodeSpawn(posixPsPath(), ["-p", valid.join(","), "-o", "pid=,args="], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
314
+ let out = "";
315
+ child.stdout?.on("data", (chunk) => {
316
+ out += chunk.toString();
317
+ });
318
+ child.once("error", () => resolve(map));
319
+ child.once("close", () => {
320
+ for (const line of out.split(/\r?\n/)) {
321
+ // Linear parse (S8786/S6594: avoid regex backtracking on
322
+ // attacker-lengthenable ps output) — trim leading whitespace,
323
+ // then split on the first whitespace run: " 1234 args here".
324
+ const trimmed = line.trimStart();
325
+ if (!trimmed)
326
+ continue;
327
+ let i = 0;
328
+ while (i < trimmed.length && trimmed[i] >= "0" && trimmed[i] <= "9")
329
+ i++;
330
+ if (i === 0)
331
+ continue;
332
+ const pidStr = trimmed.slice(0, i);
333
+ let j = i;
334
+ while (j < trimmed.length && (trimmed[j] === " " || trimmed[j] === "\t"))
335
+ j++;
336
+ const pid = Number(pidStr);
337
+ if (Number.isFinite(pid) && pid > 0)
338
+ map.set(pid, trimmed.slice(j));
339
+ }
340
+ resolve(map);
341
+ });
342
+ }
343
+ catch {
344
+ resolve(map);
345
+ }
346
+ });
347
+ }
348
+ /**
349
+ * Build a `matchProcess` identity predicate from a pid → command-line map
350
+ * (as produced by `queryCommandLines`). PURE — exported for unit testing.
351
+ *
352
+ * Semantics (guarding pid kills against pid recycling):
353
+ * - pid absent from the map ⇒ false: identity is UNVERIFIABLE, so never kill
354
+ * by pid (the marker-search fallback may still catch a real orphan).
355
+ * - marker recorded and present in the command line ⇒ match (strongest
356
+ * signal — markers are per-spawn-unique).
357
+ * - else: the recorded command's basename appears (case-insensitive) in the
358
+ * command line ⇒ match. Empty basename never matches (guard against a
359
+ * recorded empty/odd command matching everything via `includes("")`).
360
+ */
361
+ export function buildIdentityMatcher(cmdlines) {
362
+ return (pid, expected) => {
363
+ const cmdline = cmdlines.get(pid);
364
+ if (cmdline === undefined)
365
+ return false; // unverifiable ⇒ never kill by pid
366
+ if (expected.marker && cmdline.includes(expected.marker))
367
+ return true;
368
+ const basename = path.basename(expected.command ?? "").toLowerCase();
369
+ if (!basename)
370
+ return false;
371
+ return cmdline.toLowerCase().includes(basename);
372
+ };
373
+ }
374
+ /** Force-kill a pid's full process tree. Windows: `taskkill /F /T`. POSIX:
375
+ * mirror killProcessTree's process-group kill, falling back to a direct
376
+ * signal. Best-effort: swallow all errors. */
377
+ async function killPidTree(pid) {
378
+ if (!Number.isFinite(pid) || pid <= 0)
379
+ return;
380
+ if (isWindows) {
381
+ try {
382
+ const taskkill = windowsExe("taskkill.exe");
383
+ const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
384
+ shell: false,
385
+ windowsHide: true,
386
+ stdio: "ignore",
387
+ });
388
+ await new Promise((resolve) => {
389
+ killer.once("close", () => resolve());
390
+ killer.once("error", () => resolve());
391
+ });
392
+ }
393
+ catch {
394
+ // best-effort
395
+ }
396
+ return;
397
+ }
398
+ try {
399
+ process.kill(-pid, "SIGKILL");
400
+ }
401
+ catch {
402
+ try {
403
+ process.kill(pid, "SIGKILL");
404
+ }
405
+ catch {
406
+ // best-effort — process may already be gone
407
+ }
408
+ }
409
+ }
410
+ /**
411
+ * Fire-and-forget orphan sweep: reads the registry, decides what's dead via
412
+ * `decideOrphanReaping`, kills orphaned LSP children (by pid, with a
413
+ * marker-based command-line search fallback), then drops fully-dead
414
+ * instances from the registry. Never throws — every step is wrapped so a
415
+ * reap failure cannot block or crash the caller (session_start).
416
+ */
417
+ export async function sweepOrphans() {
418
+ if (!isInstanceRegistryEnabled())
419
+ return;
420
+ const startedAt = Date.now();
421
+ try {
422
+ const registry = await readInstanceRegistry();
423
+ if (registry.length === 0)
424
+ return;
425
+ // Identity verification before any pid kill (recycled-pid guard): fetch
426
+ // the command lines of every recorded child pid in ONE batched query,
427
+ // then let the pure decision function verify each live child's identity
428
+ // against what was recorded at spawn. A pid whose command line can't be
429
+ // fetched is treated as unverifiable and never killed by pid — the
430
+ // marker-search fallback may still catch it.
431
+ const candidatePids = registry.flatMap((instance) => instance.lspChildren.map((child) => child.pid));
432
+ const cmdlines = await queryCommandLines(candidatePids);
433
+ const matchProcess = buildIdentityMatcher(cmdlines);
434
+ const decision = decideOrphanReaping(registry, realIsPidAlive, matchProcess);
435
+ let killedCount = 0;
436
+ const killedServerIds = [];
437
+ for (const child of decision.childrenToKill) {
438
+ await killPidTree(child.pid);
439
+ killedCount++;
440
+ killedServerIds.push(child.serverId);
441
+ }
442
+ for (const search of decision.markerSearches) {
443
+ try {
444
+ const pids = await findPidsByMarkerWindows(search.marker);
445
+ for (const pid of pids) {
446
+ await killPidTree(pid);
447
+ killedCount++;
448
+ killedServerIds.push(search.serverId);
449
+ }
450
+ }
451
+ catch {
452
+ // best-effort — a failed marker search just misses that orphan this sweep
453
+ }
454
+ }
455
+ // Entry removal covers BOTH sets: pid-dead instances AND stale-heartbeat
456
+ // (pid-alive) instances — the latter is record cleanup only (#525);
457
+ // nothing belonging to a stale instance was killed above.
458
+ if (decision.deadInstances.length > 0 || decision.staleInstances.length > 0) {
459
+ try {
460
+ const prunePids = new Set([
461
+ ...decision.deadInstances.map((i) => i.pid),
462
+ ...decision.staleInstances.map((i) => i.pid),
463
+ ]);
464
+ await pruneDeadInstances(prunePids);
465
+ }
466
+ catch {
467
+ // best-effort — a stale registry entry is re-evaluated next sweep
468
+ }
469
+ }
470
+ try {
471
+ logLatency({
472
+ type: "phase",
473
+ phase: "orphan_lsp_reaped",
474
+ filePath: "",
475
+ durationMs: Date.now() - startedAt,
476
+ metadata: {
477
+ deadInstances: decision.deadInstances.length,
478
+ staleInstances: decision.staleInstances.length,
479
+ killed: killedCount,
480
+ serverIds: killedServerIds,
481
+ markerSearches: decision.markerSearches.length,
482
+ },
483
+ });
484
+ }
485
+ catch {
486
+ // best-effort logging only
487
+ }
488
+ }
489
+ catch {
490
+ // The sweep must never throw out of session_start.
491
+ }
492
+ }
493
+ /** Drop dead-parent AND stale-heartbeat (#525, record-cleanup-only)
494
+ * instances from the registry. Re-reads immediately before
495
+ * writing (rather than reusing the earlier `readInstanceRegistry()` snapshot)
496
+ * to narrow — not eliminate — the last-writer-wins race already accepted for
497
+ * slice 1's read-modify-write model. */
498
+ async function pruneDeadInstances(deadPids) {
499
+ const target = path.join(getGlobalPiLensDir(), "instances.json");
500
+ try {
501
+ const raw = await fs.promises.readFile(target, "utf-8");
502
+ const parsed = JSON.parse(raw);
503
+ if (!parsed || !Array.isArray(parsed.instances))
504
+ return;
505
+ const remaining = parsed.instances.filter((entry) => !deadPids.has(entry.pid));
506
+ if (remaining.length === parsed.instances.length)
507
+ return;
508
+ const tmpPath = `${target}.tmp-${process.pid}`;
509
+ await fs.promises.mkdir(getGlobalPiLensDir(), { recursive: true });
510
+ await fs.promises.writeFile(tmpPath, JSON.stringify({ instances: remaining }), "utf-8");
511
+ await fs.promises.rename(tmpPath, target);
512
+ }
513
+ catch {
514
+ // best-effort
515
+ }
516
+ }