pi-lens 3.8.67 → 3.8.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,25 @@ All notable changes to pi-lens will be documented in this file.
10
10
 
11
11
  ### Fixed
12
12
 
13
+ ## [3.8.68] - 2026-07-10
14
+
15
+ ### Added
16
+
17
+ - **Subagent light mode** (#449) — the nicobailon/pi-subagents extension spawns each subagent as a child `pi` CLI process and sets `PI_SUBAGENT_CHILD=1` unconditionally in every child's environment, so a fan-out of N subagents in the same cwd previously paid N full LSP pre-warms plus N sets of heavyweight startup scans — mostly wasted on short-lived task agents. `clients/subagent-mode.ts` adds `isSubagentSession()`, detected once at session start; when engaged, both the LSP pre-warm (explicit `warmFiles` and the dominant-language auto-warm) and the knip/jscpd/madge/dead-code/govulncheck/gitleaks/trivy startup scans are skipped, extending the same `skipHeavyweightScans` gate #462 introduced for slow filesystems. Per-edit LSP dispatch and the in-process scans (todo/call-graph/codebase-model/ast-grep-exports/word-index) are untouched, so a subagent that actually edits code still gets diagnostics and symbol search. Escape hatch: `PI_LENS_SUBAGENT_FULL=1` forces full behavior even inside a detected subagent session. Logged to the latency log as a `subagent_light_mode` phase with the subagent's `runId`/`agentName` (from `PI_SUBAGENT_RUN_ID`/`PI_SUBAGENT_CHILD_AGENT`) when present.
18
+ - **Cross-process instance registry** (#449 slice 1) — a tiny machine-global registry (`~/.pi-lens/instances.json`, `clients/instance-registry.ts`) now records every live pi-lens process: pid, project root, live LSP child servers (pid/serverId/command/spawn marker), RSS, and a heartbeat. Registered at `session_start`, updated opportunistically at `turn_end` (piggybacked on the existing per-turn touchpoint, no new timer), and deregistered synchronously at `session_shutdown`. Pure observability substrate for now (zero dispatch/behavior change) — the groundwork later slices (cross-process LSP budget, same-root warm attach) will build on. Reads are corruption-safe (garbage/missing file ⇒ empty, never throws); writes are atomic tmp+rename. `PI_LENS_INSTANCE_REGISTRY=0` disables it entirely.
19
+ - **Slow-filesystem mode** (#462) — WSL 9p mounts (`/mnt/c/...`) measure ~1.3ms/`stat` vs ~17µs native (75x), so an unbounded synchronous tree walk (e.g. a 5,000-file project) could cost ~6.5s of stat time alone and freeze the TUI. `clients/slow-fs.ts` adds a cheap session-start probe (median of up to 15 `fs.statSync` calls under the project root) that classifies the workspace by measurement, not path shape, so it also catches drvfs/NFS/SMB rather than 9p-only. In slow-FS mode the sync `collectSourceFiles` walker clamps to a reduced 500-file cap (the async twin is unaffected), and the knip/jscpd/madge/dead-code/govulncheck/gitleaks/trivy background scans are skipped at session start with a visible notice instead of silently returning stale/empty results. Escape hatches: `PI_LENS_ALLOW_SLOW_FS_SCAN=1` disables slow-FS mode entirely; `PI_LENS_FORCE_SLOW_FS=1` forces it on for testing or when the probe under-fires; `PI_LENS_SLOW_FS_THRESHOLD_US` overrides the 500µs default. The verdict is logged to the latency log as a `slow_fs_probe` phase for dogfooding.
20
+ - **Subagent-extension compat smoke** (#476) — pi-lens's subagent-compatibility features (#473/#474/#475) were built on reverse-engineered facts about the nicobailon/pi-subagents and `@tintinweb/pi-subagents` extensions plus the pi SDK itself. A new nightly `.github/workflows/compat-smoke.yml` makes that compatibility empirical: Layer A (`scripts/compat-contracts.mjs`) npm-installs the real third-party packages and mechanically re-verifies six pinned contracts with resilient pattern matchers (`scripts/lib/compat-contracts.mjs`) against the installed source — no `pi` process, no LLM. Layer B (`scripts/compat-smoke-behavioral.mjs`) installs the packed pi-lens tarball into a real `pi` (the same mechanism `install-smoke.yml`'s `pi-load` job uses) and drives `pi --mode rpc` to assert, through pi-lens's own latency log, that subagent light mode engages under `PI_SUBAGENT_CHILD=1`, that `PI_LENS_SUBAGENT_FULL=1` overrides it off, and that zero LSP-server processes survive a graceful pi exit (the #472 orphan class). Both layers run `continue-on-error`; a failure opens/refreshes a single tracking issue rather than reddening the nightly. `docs/subagent-compat.md` records the exact pinned contracts (file + version last verified) and the three env levers (`PI_LENS_SUBAGENT_FULL`, `PI_LENS_CONCURRENT_SESSION_GUARD`, `PI_LENS_INSTANCE_REGISTRY`).
21
+
22
+ ### Changed
23
+
24
+ ### Fixed
25
+
26
+ - **Orphaned LSP server processes no longer survive abnormal session exit** (#472) — the #234 teardown constraint (no child spawn during `session_shutdown`, else libuv aborts) only covers CLEAN shutdown; a crashed/hard-killed/OOM'd session never runs teardown, and Windows does not kill children when a parent dies, so the whole LSP fleet could leak (7 orphaned ast-grep pairs found in the wild, up to 13 days old, ~700MB). `killProcessTree`'s `processExiting` branch only ever killed the DIRECT child (for shell/`.cmd`-wrapped servers that's the wrapper, not the real server) — its comment claiming Windows grandchildren "are reaped by the OS as the host exits" was false and is now corrected in place (behavior unchanged: still direct-child-only, per #234). The real fix is the #449 instance registry's orphan reaper: every LSP child (core and auxiliary, uniformly, at the shared `clients/lsp/launch.ts`/`client.ts` spawn/kill seam — no per-server special casing) is now recorded with its pid, resolved command, and — when the launch args carry a temp-config-style value (e.g. ast-grep's `--config <tmp sgconfig path>`) — a per-spawn-unique marker for command-line re-identification when the pid chain is broken (the synthesized baseline sgconfig now embeds the owning pid in its filename — `baseline-<pid>.sgconfig.yml`, with age-based cleanup of stale siblings — so the marker really is unique per instance; the previous shared `baseline.sgconfig.yml` would have made the marker fallback match every live ast-grep on the machine). `clients/instance-reaper.ts` sweeps at every `session_start`: a pure `decideOrphanReaping` function (conservative liveness — ESRCH-only counts as dead, EPERM/ambiguous never does; markers claimed by any live instance are never search-killed; pid kills are identity-verified against a batched command-line lookup so a recycled pid is never killed blind) decides what to kill, and an impure `sweepOrphans` executes it (`taskkill /F /T` on Windows, process-group kill on POSIX) plus a marker-based `Get-CimInstance` command-line search fallback for the broken-pid-chain case. Also resolves ast-grep's platform-native exe directly (`resolveAstGrepNativeExe`, `@ast-grep/cli-<platform>-<arch>` packages) ahead of the node-bin-wrapper candidate — one less orphanable process layer.
27
+ - **Concurrent in-process subagent binds no longer tear down the parent's LSP fleet/runtime state (#473)** — extensions that build a fresh `AgentSession` and call `session.bindExtensions()` *inside the same Node process* as the parent pi session (tintinweb/pi-subagents-style) reuse pi's process-global extension-loader cache, so the subagent's `session_start` re-invoked pi-lens's SAME module-scope singletons the parent was still using — `resetLSPService({fast:true})` killed every live LSP client and `runtime.resetForSession()` bumped the session generation, silently orphaning the parent's in-flight continuations (parked cascades, diagnostics waits) mid-turn, with no visible error. New `clients/session-lifecycle.ts` classifies each `session_start`/`session_shutdown` as `primary` / `sequential-replacement` / `concurrent-secondary` by probing whether the previously-registered ctx is still active (an SDK-wrapped accessor throws pi's own stale-ctx error only for real sequential replacement — `newSession`/`fork`/`switchSession`/`reload` — never for a concurrently-live sibling). A `concurrent-secondary` session_start now skips `handleSessionStart` and the runtime-identity update entirely and rides the already-initialized shared infra; its later shutdown skips the destructive teardown too. Classification is fail-safe: any inconclusive signal (probe failure, no prior session) falls back to today's full-reset behavior, and `PI_LENS_CONCURRENT_SESSION_GUARD=0` disables the guard outright. Zero behavior change for the common single-session process.
28
+ - **Review-graph snapshot persist is now atomic (tmp + rename)** — the debounced cache write went straight to `review-graph.json` with a plain `fs.writeFile`, so a concurrent reader (another process's blind load, or the tier-2 disk load under CI's parallel test runners) could observe a created-but-partially-written file, fail the JSON parse, and silently fall open to a full whole-repo rebuild. The write now lands in a `.tmp-<pid>` sibling and is renamed into place (atomic on POSIX and Windows), so a snapshot either doesn't exist yet or is complete; the process-exit flush uses the same pattern. This was the flaky `expected 'cached' to be 'full'` CI failure in the #300 git-stamp tests.
29
+ - **`servercapabilities.md` merge guard survives schema changes and merges bullet sections (#469)** — the #390 nightly merge guard for `scripts/server-capabilities.mjs` required the prior and freshly-generated table headers to be byte-identical before merging, so adding the `ws-pull` column silently disabled the guard and last night's ubuntu-only run dropped the rust and php rows (and their capability-key / executeCommand bullets) entirely. Fixed by reshaping prior rows onto the new header **by column name** (`reshapeRowsByName` in `scripts/lib/md-matrix.mjs` — columns the prior doc lacked are filled with the `·` placeholder, columns dropped from the new schema are simply not carried) and by merging the two bulleted sections ("Raw advertised capability keys", "Advertised executeCommand allowlists") for preserved servers, which the original guard never touched at all (`parseBulletSection`/`mergeBulletSection`). The whole merge is now a pure, unit-tested function (`mergeServerCapabilitiesDoc`) that never spawns an LSP server and fails open (writes the fresh doc, logs to stderr) if either doc's table is unparseable.
30
+ - **pi-lens loads under pi's Bun-compiled binary again** (#335) — pi ships as a `bun build --compile` single-file executable and loads extensions inside that embedded runtime, whose module resolver does not traverse the extension's on-disk `node_modules` for a bare specifier. So a static `import { minimatch } from "minimatch"` in `file-utils.js` (and every other third-party bare import) failed with `Cannot find package`, dropping the jscpd/todo/complexity analyzers into degraded mode. `dist/index.js` is now bundled into one self-contained file (`scripts/bundle-dist.mjs`, wired into `build:dist`) that inlines the pure-JS deps (minimatch, js-yaml, vscode-jsonrpc and transitives), so nothing is imported by bare specifier at load time. Host-provided packages (typebox, `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`) and the native/wasm packages (`@ast-grep/napi`, web-tree-sitter) stay external; the two lazy native/wasm accessors resolve to an absolute path via `createRequire` before dynamic-importing (a bare specifier fails under the compiled host, an absolute path does not), with the bare specifier kept as a fallback for other runtimes. esbuild is run through `node <npm-cli> exec` (shell-free, not the `npx.cmd` shim), installing into npm's cache rather than the project tree, so the build adds no dependency and works on a from-source `--omit=dev` install; a `createRequire` banner is prepended so the bundled CJS deps load under pure-ESM Node. The reporter's Windows junction workaround does not port to the Linux compiled host, so bundling is the cross-platform fix.
31
+
13
32
  ## [3.8.67] - 2026-07-09
14
33
 
15
34
  ### Added
package/README.md CHANGED
@@ -149,6 +149,8 @@ Thanks goes to these wonderful people:
149
149
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/cmptr"><img src="https://avatars.githubusercontent.com/u/32746889?v=4" width="100px;" alt=""/><br /><sub><b>Aaron Bell</b></sub></a><br /><a href="#code-cmptr" title="Code">💻</a> <a href="#bug-cmptr" title="Bug reports">🐛</a></td>
150
150
  <td align="center" valign="top" width="14.28%"><a href="http://jasonrimmer.com/"><img src="https://avatars.githubusercontent.com/u/629?v=4" width="100px;" alt=""/><br /><sub><b>Jason Rimmer</b></sub></a><br /><a href="#bug-jrimmer" title="Bug reports">🐛</a></td>
151
151
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/moj02090"><img src="https://avatars.githubusercontent.com/u/57255166?v=4" width="100px;" alt=""/><br /><sub><b>moj02090</b></sub></a><br /><a href="#code-moj02090" title="Code">💻</a></td>
152
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/vkarasen"><img src="https://avatars.githubusercontent.com/u/7932536?v=4" width="100px;" alt=""/><br /><sub><b>Vitali Karasenko</b></sub></a><br /><a href="#code-vkarasen" title="Code">💻</a></td>
153
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/mehalter"><img src="https://avatars.githubusercontent.com/u/1591837?v=4" width="100px;" alt=""/><br /><sub><b>Micah Halter</b></sub></a><br /><a href="#code-mehalter" title="Code">💻</a></td>
152
154
  </tr>
153
155
  </tbody>
154
156
  </table>
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Centralized LAZY accessor for `@ast-grep/napi` (a native addon — loaded on
3
+ * demand, never at module-eval). See ./typescript.ts for the rationale.
4
+ * Types are re-exported; the module itself is fetched via `loadAstGrepNapi()`.
5
+ *
6
+ * Resolved to an absolute `file://` URL via `createRequire` before importing: an
7
+ * absolute-path dynamic import works under pi's bundled host, a bare specifier
8
+ * does not. The path is converted to a `file://` URL (a raw Windows path is not
9
+ * a valid import specifier); bare import kept as a fallback.
10
+ */
11
+ import { createRequire } from "node:module";
12
+ import { pathToFileURL } from "node:url";
13
+ const _require = createRequire(import.meta.url);
1
14
  export function loadAstGrepNapi() {
2
- return import("@ast-grep/napi");
15
+ try {
16
+ const entry = _require.resolve("@ast-grep/napi");
17
+ return import(pathToFileURL(entry).href);
18
+ }
19
+ catch {
20
+ return import("@ast-grep/napi");
21
+ }
3
22
  }
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Centralized accessor for `@earendil-works/pi-tui`. See ./typescript.ts for the
3
3
  * rationale. (pi-tui is a pi-bundled core package, host-provided at runtime.)
4
+ *
5
+ * Re-export named bindings, not `export *`: with the package kept external, a
6
+ * wildcard re-export leaves the namespace undefined at runtime under the bundle.
4
7
  */
5
- export * from "@earendil-works/pi-tui";
8
+ export { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Centralized accessor for `typebox`. See ./typescript.ts for the rationale.
3
3
  * (typebox is a pi-bundled core package, so it resolves from the host at
4
- * runtime but it's still routed through here for a uniform dep surface.)
4
+ * runtime -- but it's still routed through here for a uniform dep surface.)
5
+ *
6
+ * Re-export named bindings, not `export *`: with the package kept external, a
7
+ * wildcard re-export leaves the namespace undefined at runtime under the bundle.
5
8
  */
6
- export * from "typebox";
9
+ export { Type } from "typebox";
@@ -1,3 +1,24 @@
1
+ /**
2
+ * Centralized LAZY accessor for `web-tree-sitter` (wasm — loaded on demand). See
3
+ * ./typescript.ts for the rationale. Types are re-exported; the module itself is
4
+ * fetched via `loadWebTreeSitter()`.
5
+ *
6
+ * Resolved to an absolute `file://` URL via `createRequire` before importing: an
7
+ * absolute-path dynamic import works under pi's bundled host, a bare specifier
8
+ * does not. The package `exports` map exposes only the `.` entry (no custom
9
+ * subpath), so we resolve the bare package name and convert it to a `file://`
10
+ * URL (a raw Windows path is not a valid import specifier); bare import kept as
11
+ * a fallback.
12
+ */
13
+ import { createRequire } from "node:module";
14
+ import { pathToFileURL } from "node:url";
15
+ const _require = createRequire(import.meta.url);
1
16
  export function loadWebTreeSitter() {
2
- return import("web-tree-sitter");
17
+ try {
18
+ const entry = _require.resolve("web-tree-sitter");
19
+ return import(pathToFileURL(entry).href);
20
+ }
21
+ catch {
22
+ return import("web-tree-sitter");
23
+ }
3
24
  }
@@ -0,0 +1,418 @@
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
+ import { spawn as nodeSpawn } from "node:child_process";
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { getGlobalPiLensDir } from "./file-utils.js";
23
+ import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-registry.js";
24
+ import { logLatency } from "./latency-logger.js";
25
+ const isWindows = process.platform === "win32";
26
+ /**
27
+ * Markers claimed by any LIVE instance's children. A marker search kills by
28
+ * command-line match, so a marker that a live session also uses must never
29
+ * be searched — killing it would take down the live session's server.
30
+ * Markers are per-process-unique by construction (sgconfig.ts embeds the
31
+ * pid), so this is defense in depth against non-unique markers ever
32
+ * reappearing (#472: the original shared baseline.sgconfig.yml would have
33
+ * made the fallback kill every live ast-grep on the machine).
34
+ */
35
+ function collectLiveMarkers(registry, isPidAlive) {
36
+ const liveMarkers = new Set();
37
+ for (const instance of registry) {
38
+ if (!isPidAlive(instance.pid))
39
+ continue;
40
+ for (const child of instance.lspChildren) {
41
+ if (child.marker)
42
+ liveMarkers.add(child.marker);
43
+ }
44
+ }
45
+ return liveMarkers;
46
+ }
47
+ /**
48
+ * Classify one dead-parent instance's children into kills / marker-searches,
49
+ * appending onto the shared `out` accumulator. Extracted from
50
+ * `decideOrphanReaping` to keep cognitive complexity in check — no behavior
51
+ * change, just the per-instance inner loop pulled out.
52
+ */
53
+ function classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, out) {
54
+ for (const child of instance.lspChildren) {
55
+ const childAlive = isPidAlive(child.pid);
56
+ if (childAlive) {
57
+ const identityOk = matchProcess
58
+ ? matchProcess(child.pid, {
59
+ command: child.command,
60
+ marker: child.marker,
61
+ })
62
+ : true;
63
+ if (identityOk) {
64
+ out.childrenToKill.push({
65
+ pid: child.pid,
66
+ serverId: child.serverId,
67
+ command: child.command,
68
+ });
69
+ continue;
70
+ }
71
+ }
72
+ // Child pid is dead, or alive-but-identity-mismatched (recycled pid) —
73
+ // if we have a marker, surface it so the caller can find a live
74
+ // process (e.g. the native exe grandchild) by command-line match.
75
+ // Never surface a marker a live instance also claims (see above).
76
+ if (child.marker && !liveMarkers.has(child.marker)) {
77
+ out.markerSearches.push({ marker: child.marker, serverId: child.serverId });
78
+ }
79
+ }
80
+ }
81
+ /**
82
+ * Pure decision function: given the registry state and injectable liveness /
83
+ * identity predicates, decide what to kill. Performs zero I/O.
84
+ *
85
+ * @param isPidAlive - `process.kill(pid, 0)`-style liveness check. Must be
86
+ * CONSERVATIVE: only pid-confirmed-dead (ESRCH) counts as dead. Any
87
+ * ambiguous result (EPERM, or the caller's fake table saying "unknown")
88
+ * must be treated as alive — never kill on an ambiguous signal-check.
89
+ * @param matchProcess - optional identity verification (e.g. confirm the
90
+ * live pid's command line still matches what we recorded) to guard against
91
+ * a recycled pid coincidentally matching. If omitted, liveness alone is used.
92
+ */
93
+ export function decideOrphanReaping(registry, isPidAlive, matchProcess) {
94
+ const deadInstances = [];
95
+ const childrenToKill = [];
96
+ const markerSearches = [];
97
+ const liveMarkers = collectLiveMarkers(registry, isPidAlive);
98
+ for (const instance of registry) {
99
+ if (isPidAlive(instance.pid)) {
100
+ continue; // parent still alive — leave its children alone entirely
101
+ }
102
+ deadInstances.push(instance);
103
+ classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, {
104
+ childrenToKill,
105
+ markerSearches,
106
+ });
107
+ }
108
+ return { deadInstances, childrenToKill, markerSearches };
109
+ }
110
+ // --- Impure liveness / identity / kill helpers ---
111
+ /** `process.kill(pid, 0)` liveness check: ESRCH ⇒ dead, anything else
112
+ * (EPERM, or no error thrown at all) ⇒ conservatively alive. */
113
+ function realIsPidAlive(pid) {
114
+ if (!Number.isFinite(pid) || pid <= 0)
115
+ return false;
116
+ try {
117
+ process.kill(pid, 0);
118
+ return true; // no throw — process exists and we can signal it
119
+ }
120
+ catch (err) {
121
+ const code = err?.code;
122
+ if (code === "ESRCH")
123
+ return false; // definitively dead
124
+ // EPERM (exists, no permission) or any other/unknown errno: ambiguous —
125
+ // never treat as dead.
126
+ return true;
127
+ }
128
+ }
129
+ function windowsExe(name) {
130
+ return path.join(process.env.SystemRoot ?? String.raw `C:\Windows`, "System32", name);
131
+ }
132
+ /** Resolve an absolute path to `ps` (S4036: never spawn via bare PATH lookup).
133
+ * Prefers `/bin/ps` (present on virtually every POSIX system), falls back to
134
+ * `/usr/bin/ps`, and defaults back to `/bin/ps` if neither probe succeeds
135
+ * (spawn will then fail closed rather than silently resolving via PATH). */
136
+ function posixPsPath() {
137
+ if (fs.existsSync("/bin/ps"))
138
+ return "/bin/ps";
139
+ if (fs.existsSync("/usr/bin/ps"))
140
+ return "/usr/bin/ps";
141
+ return "/bin/ps";
142
+ }
143
+ /** Escape a value for embedding in a WQL LIKE clause: WQL uses `'` as the
144
+ * string delimiter (doubled to escape) and `%`/`_` as wildcards — the marker
145
+ * is an opaque path string, so escape all three before interpolating. */
146
+ function escapeWqlLikeValue(value) {
147
+ return value.replaceAll("'", "''").replaceAll(/[%_]/g, (ch) => `[${ch}]`);
148
+ }
149
+ /** Search running processes whose command line contains `marker` (Windows,
150
+ * via CIM/WQL). Returns matching pids. Best-effort: any failure ⇒ []. */
151
+ async function findPidsByMarkerWindows(marker) {
152
+ if (!isWindows || !marker)
153
+ return [];
154
+ const escaped = escapeWqlLikeValue(marker);
155
+ // $PID exclusion: the query's own powershell.exe command line embeds the
156
+ // marker string, so it would match itself.
157
+ const psScript = `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%${escaped}%'" ` +
158
+ `| Where-Object { $_.ProcessId -ne $PID } ` +
159
+ `| Select-Object -ExpandProperty ProcessId`;
160
+ return new Promise((resolve) => {
161
+ try {
162
+ const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
163
+ const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
164
+ let out = "";
165
+ child.stdout?.on("data", (chunk) => {
166
+ out += chunk.toString();
167
+ });
168
+ child.once("error", () => resolve([]));
169
+ child.once("close", () => {
170
+ const pids = out
171
+ .split(/\r?\n/)
172
+ .map((line) => Number(line.trim()))
173
+ .filter((n) => Number.isFinite(n) && n > 0);
174
+ resolve(pids);
175
+ });
176
+ }
177
+ catch {
178
+ resolve([]);
179
+ }
180
+ });
181
+ }
182
+ /** Fetch command lines for a set of pids in one query (Windows: CIM; POSIX:
183
+ * `ps`). Returns a pid → command-line map; pids that can't be resolved are
184
+ * simply absent (the caller treats absent as "identity unverifiable — do not
185
+ * kill by pid"). Best-effort: any failure ⇒ empty map. */
186
+ async function queryCommandLines(pids) {
187
+ const valid = [...new Set(pids.filter((p) => Number.isFinite(p) && p > 0))];
188
+ const map = new Map();
189
+ if (valid.length === 0)
190
+ return map;
191
+ if (isWindows) {
192
+ const filter = valid.map((p) => `ProcessId=${p}`).join(" OR ");
193
+ const psScript = `Get-CimInstance Win32_Process -Filter "${filter}" ` +
194
+ `| ForEach-Object { "$($_.ProcessId)\t$($_.CommandLine)" }`;
195
+ return new Promise((resolve) => {
196
+ try {
197
+ const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
198
+ const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
199
+ let out = "";
200
+ child.stdout?.on("data", (chunk) => {
201
+ out += chunk.toString();
202
+ });
203
+ child.once("error", () => resolve(map));
204
+ child.once("close", () => {
205
+ for (const line of out.split(/\r?\n/)) {
206
+ const tab = line.indexOf("\t");
207
+ if (tab <= 0)
208
+ continue;
209
+ const pid = Number(line.slice(0, tab).trim());
210
+ if (Number.isFinite(pid) && pid > 0)
211
+ map.set(pid, line.slice(tab + 1));
212
+ }
213
+ resolve(map);
214
+ });
215
+ }
216
+ catch {
217
+ resolve(map);
218
+ }
219
+ });
220
+ }
221
+ return new Promise((resolve) => {
222
+ try {
223
+ const child = nodeSpawn(posixPsPath(), ["-p", valid.join(","), "-o", "pid=,args="], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
224
+ let out = "";
225
+ child.stdout?.on("data", (chunk) => {
226
+ out += chunk.toString();
227
+ });
228
+ child.once("error", () => resolve(map));
229
+ child.once("close", () => {
230
+ for (const line of out.split(/\r?\n/)) {
231
+ // Linear parse (S8786/S6594: avoid regex backtracking on
232
+ // attacker-lengthenable ps output) — trim leading whitespace,
233
+ // then split on the first whitespace run: " 1234 args here".
234
+ const trimmed = line.trimStart();
235
+ if (!trimmed)
236
+ continue;
237
+ let i = 0;
238
+ while (i < trimmed.length && trimmed[i] >= "0" && trimmed[i] <= "9")
239
+ i++;
240
+ if (i === 0)
241
+ continue;
242
+ const pidStr = trimmed.slice(0, i);
243
+ let j = i;
244
+ while (j < trimmed.length && (trimmed[j] === " " || trimmed[j] === "\t"))
245
+ j++;
246
+ const pid = Number(pidStr);
247
+ if (Number.isFinite(pid) && pid > 0)
248
+ map.set(pid, trimmed.slice(j));
249
+ }
250
+ resolve(map);
251
+ });
252
+ }
253
+ catch {
254
+ resolve(map);
255
+ }
256
+ });
257
+ }
258
+ /**
259
+ * Build a `matchProcess` identity predicate from a pid → command-line map
260
+ * (as produced by `queryCommandLines`). PURE — exported for unit testing.
261
+ *
262
+ * Semantics (guarding pid kills against pid recycling):
263
+ * - pid absent from the map ⇒ false: identity is UNVERIFIABLE, so never kill
264
+ * by pid (the marker-search fallback may still catch a real orphan).
265
+ * - marker recorded and present in the command line ⇒ match (strongest
266
+ * signal — markers are per-spawn-unique).
267
+ * - else: the recorded command's basename appears (case-insensitive) in the
268
+ * command line ⇒ match. Empty basename never matches (guard against a
269
+ * recorded empty/odd command matching everything via `includes("")`).
270
+ */
271
+ export function buildIdentityMatcher(cmdlines) {
272
+ return (pid, expected) => {
273
+ const cmdline = cmdlines.get(pid);
274
+ if (cmdline === undefined)
275
+ return false; // unverifiable ⇒ never kill by pid
276
+ if (expected.marker && cmdline.includes(expected.marker))
277
+ return true;
278
+ const basename = path.basename(expected.command ?? "").toLowerCase();
279
+ if (!basename)
280
+ return false;
281
+ return cmdline.toLowerCase().includes(basename);
282
+ };
283
+ }
284
+ /** Force-kill a pid's full process tree. Windows: `taskkill /F /T`. POSIX:
285
+ * mirror killProcessTree's process-group kill, falling back to a direct
286
+ * signal. Best-effort: swallow all errors. */
287
+ async function killPidTree(pid) {
288
+ if (!Number.isFinite(pid) || pid <= 0)
289
+ return;
290
+ if (isWindows) {
291
+ try {
292
+ const taskkill = windowsExe("taskkill.exe");
293
+ const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
294
+ shell: false,
295
+ windowsHide: true,
296
+ stdio: "ignore",
297
+ });
298
+ await new Promise((resolve) => {
299
+ killer.once("close", () => resolve());
300
+ killer.once("error", () => resolve());
301
+ });
302
+ }
303
+ catch {
304
+ // best-effort
305
+ }
306
+ return;
307
+ }
308
+ try {
309
+ process.kill(-pid, "SIGKILL");
310
+ }
311
+ catch {
312
+ try {
313
+ process.kill(pid, "SIGKILL");
314
+ }
315
+ catch {
316
+ // best-effort — process may already be gone
317
+ }
318
+ }
319
+ }
320
+ /**
321
+ * Fire-and-forget orphan sweep: reads the registry, decides what's dead via
322
+ * `decideOrphanReaping`, kills orphaned LSP children (by pid, with a
323
+ * marker-based command-line search fallback), then drops fully-dead
324
+ * instances from the registry. Never throws — every step is wrapped so a
325
+ * reap failure cannot block or crash the caller (session_start).
326
+ */
327
+ export async function sweepOrphans() {
328
+ if (!isInstanceRegistryEnabled())
329
+ return;
330
+ const startedAt = Date.now();
331
+ try {
332
+ const registry = await readInstanceRegistry();
333
+ if (registry.length === 0)
334
+ return;
335
+ // Identity verification before any pid kill (recycled-pid guard): fetch
336
+ // the command lines of every recorded child pid in ONE batched query,
337
+ // then let the pure decision function verify each live child's identity
338
+ // against what was recorded at spawn. A pid whose command line can't be
339
+ // fetched is treated as unverifiable and never killed by pid — the
340
+ // marker-search fallback may still catch it.
341
+ const candidatePids = registry.flatMap((instance) => instance.lspChildren.map((child) => child.pid));
342
+ const cmdlines = await queryCommandLines(candidatePids);
343
+ const matchProcess = buildIdentityMatcher(cmdlines);
344
+ const decision = decideOrphanReaping(registry, realIsPidAlive, matchProcess);
345
+ let killedCount = 0;
346
+ const killedServerIds = [];
347
+ for (const child of decision.childrenToKill) {
348
+ await killPidTree(child.pid);
349
+ killedCount++;
350
+ killedServerIds.push(child.serverId);
351
+ }
352
+ for (const search of decision.markerSearches) {
353
+ try {
354
+ const pids = await findPidsByMarkerWindows(search.marker);
355
+ for (const pid of pids) {
356
+ await killPidTree(pid);
357
+ killedCount++;
358
+ killedServerIds.push(search.serverId);
359
+ }
360
+ }
361
+ catch {
362
+ // best-effort — a failed marker search just misses that orphan this sweep
363
+ }
364
+ }
365
+ if (decision.deadInstances.length > 0) {
366
+ try {
367
+ const deadPids = new Set(decision.deadInstances.map((i) => i.pid));
368
+ await pruneDeadInstances(deadPids);
369
+ }
370
+ catch {
371
+ // best-effort — a stale registry entry is re-evaluated next sweep
372
+ }
373
+ }
374
+ try {
375
+ logLatency({
376
+ type: "phase",
377
+ phase: "orphan_lsp_reaped",
378
+ filePath: "",
379
+ durationMs: Date.now() - startedAt,
380
+ metadata: {
381
+ deadInstances: decision.deadInstances.length,
382
+ killed: killedCount,
383
+ serverIds: killedServerIds,
384
+ markerSearches: decision.markerSearches.length,
385
+ },
386
+ });
387
+ }
388
+ catch {
389
+ // best-effort logging only
390
+ }
391
+ }
392
+ catch {
393
+ // The sweep must never throw out of session_start.
394
+ }
395
+ }
396
+ /** Drop dead-parent instances from the registry. Re-reads immediately before
397
+ * writing (rather than reusing the earlier `readInstanceRegistry()` snapshot)
398
+ * to narrow — not eliminate — the last-writer-wins race already accepted for
399
+ * slice 1's read-modify-write model. */
400
+ async function pruneDeadInstances(deadPids) {
401
+ const target = path.join(getGlobalPiLensDir(), "instances.json");
402
+ try {
403
+ const raw = await fs.promises.readFile(target, "utf-8");
404
+ const parsed = JSON.parse(raw);
405
+ if (!parsed || !Array.isArray(parsed.instances))
406
+ return;
407
+ const remaining = parsed.instances.filter((entry) => !deadPids.has(entry.pid));
408
+ if (remaining.length === parsed.instances.length)
409
+ return;
410
+ const tmpPath = `${target}.tmp-${process.pid}`;
411
+ await fs.promises.mkdir(getGlobalPiLensDir(), { recursive: true });
412
+ await fs.promises.writeFile(tmpPath, JSON.stringify({ instances: remaining }), "utf-8");
413
+ await fs.promises.rename(tmpPath, target);
414
+ }
415
+ catch {
416
+ // best-effort
417
+ }
418
+ }