pi-lens 3.8.69 → 3.8.71

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 (147) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +31 -13
  3. package/dist/clients/bootstrap.js +3 -1
  4. package/dist/clients/bus-events-logger.js +53 -0
  5. package/dist/clients/bus-publish.js +45 -2
  6. package/dist/clients/cache/rule-cache.js +9 -5
  7. package/dist/clients/cache-manager.js +19 -13
  8. package/dist/clients/code-quality-warnings.js +27 -0
  9. package/dist/clients/dead-code-client.js +5 -23
  10. package/dist/clients/diagnostic-dispositions.js +298 -0
  11. package/dist/clients/diagnostics-publish.js +38 -2
  12. package/dist/clients/dispatch/auxiliary-lsp.js +144 -6
  13. package/dist/clients/dispatch/dispatcher.js +6 -1
  14. package/dist/clients/dispatch/facts/function-facts.js +83 -0
  15. package/dist/clients/dispatch/integration.js +19 -4
  16. package/dist/clients/dispatch/rules/async-noise.js +1 -1
  17. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +1 -1
  18. package/dist/clients/dispatch/rules/cors-wildcard.js +1 -1
  19. package/dist/clients/dispatch/rules/error-obscuring.js +2 -1
  20. package/dist/clients/dispatch/rules/error-swallowing.js +2 -1
  21. package/dist/clients/dispatch/rules/framework-call-noise.js +67 -0
  22. package/dist/clients/dispatch/rules/high-complexity.js +12 -1
  23. package/dist/clients/dispatch/rules/high-fan-out.js +14 -3
  24. package/dist/clients/dispatch/rules/high-import-coupling.js +1 -1
  25. package/dist/clients/dispatch/rules/missing-error-propagation.js +1 -1
  26. package/dist/clients/dispatch/rules/no-commented-credentials.js +1 -1
  27. package/dist/clients/dispatch/rules/pass-through-wrappers.js +1 -1
  28. package/dist/clients/dispatch/rules/placeholder-comments.js +1 -1
  29. package/dist/clients/dispatch/rules/unsafe-boundary.js +1 -1
  30. package/dist/clients/dispatch/runners/ast-grep-napi.js +75 -18
  31. package/dist/clients/dispatch/runners/lsp.js +23 -34
  32. package/dist/clients/dispatch/runners/oxlint.js +1 -1
  33. package/dist/clients/dispatch/suppress-writer.js +68 -0
  34. package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
  35. package/dist/clients/disposition-logger.js +39 -0
  36. package/dist/clients/disposition-publish.js +109 -0
  37. package/dist/clients/file-utils.js +94 -33
  38. package/dist/clients/format-events-publish.js +299 -0
  39. package/dist/clients/git-tracked-ignore.js +208 -0
  40. package/dist/clients/gitleaks-client.js +40 -5
  41. package/dist/clients/installer/index.js +123 -6
  42. package/dist/clients/instance-reaper.js +229 -2
  43. package/dist/clients/instance-registry.js +141 -2
  44. package/dist/clients/json-cache-read.js +65 -0
  45. package/dist/clients/knip-client.js +35 -38
  46. package/dist/clients/language-profile.js +11 -11
  47. package/dist/clients/lens-engine.js +12 -0
  48. package/dist/clients/lens-map.js +1079 -0
  49. package/dist/clients/log-cleanup.js +61 -19
  50. package/dist/clients/lsp/aggregation.js +69 -2
  51. package/dist/clients/lsp/cascade-tier.js +31 -19
  52. package/dist/clients/lsp/client.js +124 -14
  53. package/dist/clients/lsp/config.js +27 -1
  54. package/dist/clients/lsp/index.js +1028 -68
  55. package/dist/clients/lsp/server-strategies.js +17 -8
  56. package/dist/clients/lsp/server.js +80 -28
  57. package/dist/clients/lsp/tsserver-sync.js +145 -0
  58. package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
  59. package/dist/clients/lsp-budget.js +148 -0
  60. package/dist/clients/mcp/session.js +9 -0
  61. package/dist/clients/middle-man-analysis.js +260 -0
  62. package/dist/clients/module-report.js +173 -11
  63. package/dist/clients/ndjson-logger.js +24 -0
  64. package/dist/clients/opengrep-client.js +217 -0
  65. package/dist/clients/opengrep-config.js +2 -10
  66. package/dist/clients/path-utils.js +66 -0
  67. package/dist/clients/pipeline.js +5 -1
  68. package/dist/clients/project-diagnostics/cache.js +5 -12
  69. package/dist/clients/project-diagnostics/extractors.js +21 -1
  70. package/dist/clients/project-diagnostics/fresh-fetch.js +275 -0
  71. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +40 -0
  72. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +46 -0
  73. package/dist/clients/project-snapshot.js +3 -6
  74. package/dist/clients/quiet-window.js +57 -2
  75. package/dist/clients/read-expansion.js +2 -3
  76. package/dist/clients/read-guard-tool-lines.js +10 -0
  77. package/dist/clients/read-guard.js +1 -1
  78. package/dist/clients/resource-sampler.js +254 -0
  79. package/dist/clients/review-graph/builder.js +322 -42
  80. package/dist/clients/review-graph/import-resolvers.js +53 -19
  81. package/dist/clients/review-graph/symbol-id.js +57 -0
  82. package/dist/clients/runtime-agent-end.js +26 -0
  83. package/dist/clients/runtime-coordinator.js +9 -1
  84. package/dist/clients/runtime-session.js +93 -17
  85. package/dist/clients/runtime-tool-call.js +789 -0
  86. package/dist/clients/runtime-tool-result.js +15 -2
  87. package/dist/clients/runtime-turn.js +62 -13
  88. package/dist/clients/safe-spawn.js +48 -2
  89. package/dist/clients/session-state-store.js +6 -9
  90. package/dist/clients/sgconfig.js +2 -9
  91. package/dist/clients/source-filter.js +17 -25
  92. package/dist/clients/source-walker.js +66 -0
  93. package/dist/clients/startup-scan.js +105 -43
  94. package/dist/clients/symbol-containment.js +24 -0
  95. package/dist/clients/test-runner-client.js +544 -29
  96. package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
  97. package/dist/clients/typos-config.js +2 -11
  98. package/dist/clients/widget-state.js +83 -1
  99. package/dist/clients/write-ordering-guard.js +58 -0
  100. package/dist/clients/zizmor-config.js +29 -10
  101. package/dist/index.js +25511 -19414
  102. package/dist/mcp/server.js +95 -19
  103. package/dist/tools/activate-tools.js +72 -0
  104. package/dist/tools/ast-dump.js +2 -8
  105. package/dist/tools/lens-diagnostic-mark.js +158 -0
  106. package/dist/tools/lens-diagnostics.js +327 -33
  107. package/dist/tools/lsp-diagnostics.js +525 -105
  108. package/dist/tools/module-report.js +18 -5
  109. package/docs/agent-tools.md +136 -0
  110. package/docs/ast-grep_rules_catalog.md +564 -0
  111. package/docs/astplayground.md +178 -0
  112. package/docs/audit1.md +177 -0
  113. package/docs/custom-rules.md +231 -0
  114. package/docs/dependencies.md +54 -0
  115. package/docs/dispositions.md +85 -0
  116. package/docs/env_variables.md +10 -0
  117. package/docs/environment-variables.md +83 -0
  118. package/docs/fable.md +140 -0
  119. package/docs/features.md +338 -0
  120. package/docs/globalconfig.md +79 -0
  121. package/docs/language-coverage.md +48 -0
  122. package/docs/lsp-capability-matrix.md +157 -0
  123. package/docs/lsp-latency-benchmark.md +169 -0
  124. package/docs/mcp.md +438 -0
  125. package/docs/module-report-read-symbol.md +258 -0
  126. package/docs/servercapabilities.md +118 -0
  127. package/docs/subagent-compat.md +281 -0
  128. package/docs/tools.md +16 -0
  129. package/docs/tools_improvement2.md +345 -0
  130. package/docs/tree-sitter_rules_catalog.md +296 -0
  131. package/docs/usage.md +129 -0
  132. package/docs/word-index.md +150 -0
  133. package/package.json +10 -1
  134. package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
  135. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
  136. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
  137. package/rules/ast-grep-rules/rules/array-callback-return-js.yml +1 -1
  138. package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
  139. package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +1 -1
  140. package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +1 -1
  141. package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +1 -1
  142. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +13 -1
  143. package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
  144. package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +1 -1
  145. package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +1 -1
  146. package/skills/pi-lens-ast-grep/SKILL.md +3 -1
  147. package/dist/clients/production-readiness.js +0 -493
@@ -196,7 +196,7 @@ export const TOOLS = [
196
196
  checkCommand: "jscpd",
197
197
  checkArgs: ["--version"],
198
198
  installStrategy: "npm",
199
- packageName: "jscpd@3.5.10", // jscpd v4 introduced reprism dep whose lib/languages/ dir is missing from the published package — v3.5.x is the last stable release
199
+ packageName: "jscpd@5.0.12", // v4's packaging bug (reprism dep missing lib/languages/) is gone in v5's ground-up Rust rewrite — verified: real per-platform native binary (jscpd-windows-x64-msvc etc. via optionalDependencies, no missing-dir regression), --min-lines/--min-tokens/--reporters/--output/--ignore all unchanged, JSON schema fields read by clients/jscpd-client.ts's parseReport() (statistics.total.*, duplicates[].firstFile/secondFile.name+start, .lines, .tokens) are identical, and it's ~50x faster on this repo (4.1s -> 76ms detection time) — closes #582
200
200
  binaryName: "jscpd",
201
201
  },
202
202
  // Structural search and dead code detection
@@ -675,6 +675,48 @@ export const TOOLS = [
675
675
  treeMarker: "bin",
676
676
  },
677
677
  },
678
+ {
679
+ // lua-language-server (#564, split from #241) — same self-contained native
680
+ // TREE BUNDLE shape as clangd: bin/lua-language-server[.exe] + bundled
681
+ // locale/meta files, no external runtime. UNLIKE clangd, the release
682
+ // archive has NO wrapping version dir (verified by inspecting the actual
683
+ // 3.18.2 linux-x64 .tar.gz and win32-x64 .zip contents: `bin/`, `LICENSE`,
684
+ // `locale/`, … sit at archive root) — so stripComponents:0, not 1.
685
+ // LuaServer launches `<bundle>/bin/lua-language-server` directly.
686
+ // checkCommand documents the binary but is unused for resolution (tree
687
+ // bundles resolve only via the extract dir + treeMarker). Platform-matched
688
+ // url: LuaLS publishes darwin/linux x64+arm64 and win32 x64 (no win32/arm64
689
+ // build as of 3.18.2 → undefined, graceful unavailable); asset naming
690
+ // verified against the live GitHub release listing, not guessed.
691
+ id: "lua-language-server",
692
+ name: "lua-language-server",
693
+ checkCommand: "lua-language-server",
694
+ checkArgs: ["--version"],
695
+ installStrategy: "archive",
696
+ binaryName: "lua-language-server",
697
+ archive: {
698
+ url: (platform, arch) => {
699
+ const version = "3.18.2";
700
+ const base = `https://github.com/LuaLS/lua-language-server/releases/download/${version}`;
701
+ if (platform === "linux")
702
+ return arch === "arm64"
703
+ ? `${base}/lua-language-server-${version}-linux-arm64.tar.gz`
704
+ : `${base}/lua-language-server-${version}-linux-x64.tar.gz`;
705
+ if (platform === "darwin")
706
+ return arch === "arm64"
707
+ ? `${base}/lua-language-server-${version}-darwin-arm64.tar.gz`
708
+ : `${base}/lua-language-server-${version}-darwin-x64.tar.gz`;
709
+ if (platform === "win32")
710
+ return arch === "arm64"
711
+ ? undefined
712
+ : `${base}/lua-language-server-${version}-win32-x64.zip`;
713
+ return undefined;
714
+ },
715
+ kind: "zip",
716
+ stripComponents: 0,
717
+ treeMarker: "bin",
718
+ },
719
+ },
678
720
  {
679
721
  id: "actionlint",
680
722
  name: "actionlint",
@@ -1183,6 +1225,7 @@ export function resetProbeCacheStateForTesting() {
1183
1225
  _probeCacheDirty = false;
1184
1226
  resolvedPathCache.clear();
1185
1227
  ensureInFlight.clear();
1228
+ lastManagedInstallVersion.clear();
1186
1229
  if (_probeCacheFlushTimer !== null) {
1187
1230
  clearTimeout(_probeCacheFlushTimer);
1188
1231
  _probeCacheFlushTimer = null;
@@ -1238,11 +1281,44 @@ async function isCommandAvailable(command, _args) {
1238
1281
  export function isLspTransportRequiredError(output) {
1239
1282
  return /Connection (?:input|output) stream is not set|Use arguments of createConnection/i.test(output);
1240
1283
  }
1284
+ /**
1285
+ * Parse the exact version pinned in a TOOLS `packageName` spec, e.g.
1286
+ * `"jscpd@3.5.10"` -> `"3.5.10"`. Scoped packages (`"@ast-grep/cli"`) have no
1287
+ * pin unless a version is appended after the package name
1288
+ * (`"@scope/pkg@1.2.3"`) — `lastIndexOf("@")` on a bare scope marker (index 0)
1289
+ * correctly reports "no pin" rather than mistaking the scope for a version.
1290
+ * Returns undefined when packageName has no explicit `@version` suffix.
1291
+ */
1292
+ function parsePinnedVersion(packageName) {
1293
+ const at = packageName.lastIndexOf("@");
1294
+ if (at <= 0)
1295
+ return undefined;
1296
+ return packageName.slice(at + 1) || undefined;
1297
+ }
1298
+ /** Extract the first semver-ish token (e.g. "3.5.10") from `--version` output. */
1299
+ function extractVersionToken(output) {
1300
+ return output.match(/\d+\.\d+\.\d+(?:[-+][\w.]+)?/)?.[0];
1301
+ }
1302
+ /**
1303
+ * Version reported by the last successful `--version` probe of a pi-lens
1304
+ * managed local npm install, keyed by toolId. Populated only inside
1305
+ * getToolPath()'s managed-local-install checks below — i.e. only when that
1306
+ * code path already spawns verifyToolBinary anyway (cache hits in
1307
+ * ensureTool()'s fast paths never reach getToolPath, so this never adds a new
1308
+ * spawn). Consumed by ensureTool() to detect drift against the tool's current
1309
+ * `packageName` pin (#589) — deliberately scoped to installStrategy "npm"
1310
+ * with an explicit version pin; unpinned tools and non-npm strategies (github/
1311
+ * maven/archive) never populate or read this map.
1312
+ */
1313
+ const lastManagedInstallVersion = new Map();
1241
1314
  /**
1242
1315
  * Verify a tool binary actually works by running --version
1243
- * This catches broken symlinks, partial installs, and corrupted binaries
1316
+ * This catches broken symlinks, partial installs, and corrupted binaries.
1317
+ * `onVersionOutput`, when provided, receives the raw stdout on a successful
1318
+ * (exit 0) probe — used to piggyback version-pin drift detection onto this
1319
+ * already-happening spawn instead of adding a new one (#589).
1244
1320
  */
1245
- async function verifyToolBinary(binPath) {
1321
+ async function verifyToolBinary(binPath, onVersionOutput) {
1246
1322
  return new Promise((resolve) => {
1247
1323
  const isWindows = process.platform === "win32";
1248
1324
  const hasKnownWindowsExt = /\.(cmd|exe|ps1)$/i.test(binPath);
@@ -1277,6 +1353,7 @@ async function verifyToolBinary(binPath) {
1277
1353
  proc.on("exit", (code) => {
1278
1354
  if (code === 0) {
1279
1355
  debugLog(`Verified: ${binPath} (version: ${stdout.trim()})`);
1356
+ onVersionOutput?.(stdout);
1280
1357
  resolve(true);
1281
1358
  }
1282
1359
  else if (isLspTransportRequiredError(`${stdout}\n${stderr}`)) {
@@ -1491,6 +1568,23 @@ export async function getToolPath(toolId) {
1491
1568
  if (tool.installStrategy === "archive" && !tool.archive?.launcher) {
1492
1569
  return getArchiveTreeBundlePath(tool);
1493
1570
  }
1571
+ // Version-pin drift detection (#589) is scoped to npm-strategy tools with an
1572
+ // explicit `@version` pin — everything else (unpinned npm entries, pip/gem,
1573
+ // github/maven/archive) has no drift signal to piggyback on here. Clear any
1574
+ // stale entry up front so a miss on the checks below never leaves a prior
1575
+ // call's version lingering for ensureTool() to misread.
1576
+ const pinnedVersion = tool.installStrategy === "npm" && tool.packageName
1577
+ ? parsePinnedVersion(tool.packageName)
1578
+ : undefined;
1579
+ if (pinnedVersion)
1580
+ lastManagedInstallVersion.delete(toolId);
1581
+ const recordVersion = pinnedVersion
1582
+ ? (output) => {
1583
+ const seen = extractVersionToken(output);
1584
+ if (seen)
1585
+ lastManagedInstallVersion.set(toolId, seen);
1586
+ }
1587
+ : undefined;
1494
1588
  // Fast path: check local npm install first (where auto-install places tools).
1495
1589
  // This avoids the ~2-5s overhead of spawning npm global probes and PATH
1496
1590
  // searches for tools we already manage locally.
@@ -1500,7 +1594,7 @@ export async function getToolPath(toolId) {
1500
1594
  const cmdPath = `${localBase}.cmd`;
1501
1595
  try {
1502
1596
  await fs.access(cmdPath);
1503
- if (await verifyToolBinary(cmdPath)) {
1597
+ if (await verifyToolBinary(cmdPath, recordVersion)) {
1504
1598
  return cmdPath;
1505
1599
  }
1506
1600
  logSessionStart(`auto-install verify: ${cmdPath} exists but is broken, will reinstall`);
@@ -1513,7 +1607,7 @@ export async function getToolPath(toolId) {
1513
1607
  const exePath = `${localBase}.exe`;
1514
1608
  try {
1515
1609
  await fs.access(exePath);
1516
- if (await verifyToolBinary(exePath)) {
1610
+ if (await verifyToolBinary(exePath, recordVersion)) {
1517
1611
  return exePath;
1518
1612
  }
1519
1613
  logSessionStart(`auto-install verify: ${exePath} exists but is broken, will reinstall`);
@@ -1524,7 +1618,7 @@ export async function getToolPath(toolId) {
1524
1618
  }
1525
1619
  try {
1526
1620
  await fs.access(localBase);
1527
- if (await verifyToolBinary(localBase)) {
1621
+ if (await verifyToolBinary(localBase, recordVersion)) {
1528
1622
  return localBase;
1529
1623
  }
1530
1624
  logSessionStart(`auto-install verify: ${localBase} exists but is broken, will reinstall`);
@@ -2653,6 +2747,29 @@ export async function ensureTool(toolId, opts) {
2653
2747
  // Check if already installed.
2654
2748
  const existingPath = await getToolPath(toolId);
2655
2749
  if (existingPath) {
2750
+ // Version-pin drift (#589): getToolPath() above just spawned
2751
+ // verifyToolBinary on the managed local install anyway (this is the
2752
+ // slow path — fast paths 1/2 above already returned before reaching
2753
+ // here), so lastManagedInstallVersion was populated for free if this
2754
+ // is a pinned npm tool. Compare it to the current pin and, on
2755
+ // mismatch, route through the EXISTING forceReinstall codepath rather
2756
+ // than resolving to a known-stale binary. Piggybacks entirely on the
2757
+ // probe-cache's ~once-per-24h/once-per-session cadence — no new spawn.
2758
+ const tool = TOOLS.find((t) => t.id === toolId);
2759
+ const pinnedVersion = tool?.installStrategy === "npm" && tool.packageName
2760
+ ? parsePinnedVersion(tool.packageName)
2761
+ : undefined;
2762
+ if (pinnedVersion) {
2763
+ const seenVersion = lastManagedInstallVersion.get(toolId);
2764
+ if (seenVersion && seenVersion !== pinnedVersion) {
2765
+ lastManagedInstallVersion.delete(toolId);
2766
+ logSessionStart(`auto-install ensure ${toolId}: version drift (installed ${seenVersion} != pinned ${pinnedVersion}) — forcing reinstall (${Date.now() - ensureStartMs}ms)`);
2767
+ return ensureTool(toolId, {
2768
+ forceReinstall: true,
2769
+ allowInstall: opts?.allowInstall,
2770
+ });
2771
+ }
2772
+ }
2656
2773
  resolvedPathCache.set(toolId, existingPath);
2657
2774
  void updateProbeCache(toolId, existingPath);
2658
2775
  logSessionStart(`auto-install ensure ${toolId}: already available at ${existingPath} (${Date.now() - ensureStartMs}ms)`);
@@ -199,8 +199,10 @@ export function decideOrphanReaping(registry, isPidAlive, matchProcess, now = Da
199
199
  }
200
200
  // --- Impure liveness / identity / kill helpers ---
201
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) {
202
+ * (EPERM, or no error thrown at all) ⇒ conservatively alive. Exported so
203
+ * other registry consumers (clients/lsp-budget.ts, #449 slice 2) reuse this
204
+ * exact liveness check rather than inventing a second one. */
205
+ export function realIsPidAlive(pid) {
204
206
  if (!Number.isFinite(pid) || pid <= 0)
205
207
  return false;
206
208
  try {
@@ -407,6 +409,231 @@ async function killPidTree(pid) {
407
409
  }
408
410
  }
409
411
  }
412
+ /**
413
+ * #658: known pi-lens-managed LSP/scanner binary names (clients/lsp/server.ts
414
+ * spawn candidates), used by the registry-INDEPENDENT backstop sweep below.
415
+ * `opengrep-core` is opengrep's own native subprocess (not spawned directly by
416
+ * pi-lens, but still part of the tree we manage). `yaml-language-server` and
417
+ * `typescript-language-server` are node-launched — their OS image name is
418
+ * `node`/`node.exe`, so matching is done against the full command line
419
+ * (script path), not the process image name, exactly like the existing
420
+ * marker-search's WQL LIKE query below.
421
+ */
422
+ export const MANAGED_BINARY_NAMES = [
423
+ "ast-grep",
424
+ "opengrep-core",
425
+ "opengrep",
426
+ "marksman",
427
+ "zizmor",
428
+ "typos-lsp",
429
+ "yaml-language-server",
430
+ "typescript-language-server",
431
+ ];
432
+ /**
433
+ * PURE decision function for the #658 registry-independent backstop: given a
434
+ * live OS-process snapshot (already filtered to known managed binary names)
435
+ * and the current registry snapshot, decide which processes are backstop-
436
+ * kill-eligible. Zero I/O — unit-testable with fake data, mirroring
437
+ * `decideOrphanReaping`.
438
+ *
439
+ * A process is eligible ONLY when ALL of:
440
+ * - its pid is NOT already tracked in any instance's `lspChildren[]` (tracked
441
+ * pids stay owned by the registry-driven `decideOrphanReaping` path above —
442
+ * this backstop must never race or duplicate that logic);
443
+ * - its reported parent pid is a verifiable, well-formed pid (finite,
444
+ * positive, and not equal to its own pid) — an unresolvable/malformed
445
+ * parent pid is UNVERIFIABLE, never treated as "confirmed dead" (this is a
446
+ * stricter contract than `realIsPidAlive`'s own conservatism, because here
447
+ * an invalid value means "the OS couldn't tell us", not "confirmed gone");
448
+ * - `isPidAlive(parentPid)` reports the parent as dead.
449
+ *
450
+ * Note the direction of the ambiguity guard: if `parentPid` itself was
451
+ * recycled onto an unrelated live process, `isPidAlive` conservatively
452
+ * reports "alive" and the process is (safely) left alone — a false negative,
453
+ * never a false positive kill. Binary name alone is never sufficient (name
454
+ * matching only decides which processes are candidates for this check at
455
+ * all); a live parent is never overridden "however unfamiliar" the process.
456
+ */
457
+ export function decideBackstopOrphanReaping(processes, registry, isPidAlive) {
458
+ const trackedPids = new Set();
459
+ for (const instance of registry) {
460
+ for (const child of instance.lspChildren)
461
+ trackedPids.add(child.pid);
462
+ }
463
+ const out = [];
464
+ for (const proc of processes) {
465
+ if (trackedPids.has(proc.pid))
466
+ continue; // owned by the registry-driven reaper
467
+ if (!Number.isFinite(proc.parentPid) || proc.parentPid <= 0)
468
+ continue; // unverifiable
469
+ if (proc.parentPid === proc.pid)
470
+ continue; // malformed data guard
471
+ if (isPidAlive(proc.parentPid))
472
+ continue; // live parent — never kill
473
+ out.push(proc);
474
+ }
475
+ return out;
476
+ }
477
+ /** Enumerate live OS processes whose command line contains one of
478
+ * `MANAGED_BINARY_NAMES` (#658), independent of the instance registry.
479
+ * Windows: one batched CIM/WQL query (mirrors `findPidsByMarkerWindows`'s
480
+ * query pattern). POSIX: `ps -eo pid=,ppid=,args=`, filtered in JS. Returns
481
+ * `{pid, parentPid, command}` rows. Best-effort: any failure ⇒ []. */
482
+ async function enumerateManagedProcesses() {
483
+ if (isWindows) {
484
+ const clauses = MANAGED_BINARY_NAMES.map((name) => `CommandLine LIKE '%${escapeWqlLikeValue(name)}%'`).join(" OR ");
485
+ const psScript = `Get-CimInstance Win32_Process -Filter "${clauses}" ` +
486
+ `| ForEach-Object { "$($_.ProcessId)\t$($_.ParentProcessId)\t$($_.CommandLine)" }`;
487
+ return new Promise((resolve) => {
488
+ try {
489
+ const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
490
+ const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
491
+ let out = "";
492
+ child.stdout?.on("data", (chunk) => {
493
+ out += chunk.toString();
494
+ });
495
+ child.once("error", () => resolve([]));
496
+ child.once("close", () => {
497
+ const results = [];
498
+ for (const line of out.split(/\r?\n/)) {
499
+ const firstTab = line.indexOf("\t");
500
+ if (firstTab <= 0)
501
+ continue;
502
+ const secondTab = line.indexOf("\t", firstTab + 1);
503
+ if (secondTab <= 0)
504
+ continue;
505
+ const pid = Number(line.slice(0, firstTab).trim());
506
+ const parentPid = Number(line.slice(firstTab + 1, secondTab).trim());
507
+ const command = line.slice(secondTab + 1);
508
+ if (Number.isFinite(pid) && pid > 0) {
509
+ results.push({ pid, parentPid, command });
510
+ }
511
+ }
512
+ resolve(results);
513
+ });
514
+ }
515
+ catch {
516
+ resolve([]);
517
+ }
518
+ });
519
+ }
520
+ // POSIX: enumerate everything, filter in JS by managed-name substring —
521
+ // there is no single-query WQL-style server-side filter available.
522
+ return new Promise((resolve) => {
523
+ try {
524
+ const child = nodeSpawn(posixPsPath(), ["-eo", "pid=,ppid=,args="], {
525
+ shell: false,
526
+ stdio: ["ignore", "pipe", "ignore"],
527
+ });
528
+ let out = "";
529
+ child.stdout?.on("data", (chunk) => {
530
+ out += chunk.toString();
531
+ });
532
+ child.once("error", () => resolve([]));
533
+ child.once("close", () => {
534
+ const results = [];
535
+ for (const line of out.split(/\r?\n/)) {
536
+ // Linear parse (S8786/S6594): " pid ppid args here".
537
+ const trimmed = line.trimStart();
538
+ if (!trimmed)
539
+ continue;
540
+ let i = 0;
541
+ while (i < trimmed.length && trimmed[i] >= "0" && trimmed[i] <= "9")
542
+ i++;
543
+ if (i === 0)
544
+ continue;
545
+ const pid = Number(trimmed.slice(0, i));
546
+ let rest = trimmed.slice(i);
547
+ let k = 0;
548
+ while (k < rest.length && (rest[k] === " " || rest[k] === "\t"))
549
+ k++;
550
+ rest = rest.slice(k);
551
+ let j = 0;
552
+ while (j < rest.length && rest[j] >= "0" && rest[j] <= "9")
553
+ j++;
554
+ if (j === 0)
555
+ continue;
556
+ const parentPid = Number(rest.slice(0, j));
557
+ let m = j;
558
+ while (m < rest.length && (rest[m] === " " || rest[m] === "\t"))
559
+ m++;
560
+ const args = rest.slice(m);
561
+ if (!Number.isFinite(pid) || pid <= 0)
562
+ continue;
563
+ const lowerArgs = args.toLowerCase();
564
+ if (MANAGED_BINARY_NAMES.some((name) => lowerArgs.includes(name.toLowerCase()))) {
565
+ results.push({ pid, parentPid, command: args });
566
+ }
567
+ }
568
+ resolve(results);
569
+ });
570
+ }
571
+ catch {
572
+ resolve([]);
573
+ }
574
+ });
575
+ }
576
+ /**
577
+ * Fire-and-forget REGISTRY-INDEPENDENT backstop sweep (#658): finds
578
+ * pi-lens-managed LSP/scanner processes the registry-driven `sweepOrphans`
579
+ * can never see again once their registry trace is lost (a stale-heartbeat
580
+ * entry removal, or a `killPidTree` call that failed silently) — enumerates
581
+ * live OS processes by known binary name, and kills any that are both
582
+ * untracked and have a confirmed-dead parent. A strictly ADDITIVE second
583
+ * layer: `sweepOrphans`'s registry-driven path is untouched and remains the
584
+ * cheap, correct common case.
585
+ *
586
+ * Kill-attempt retry: deliberately NO separate retry-tracking state. If
587
+ * `killPidTree` fails silently, the process stays alive, untracked, and its
588
+ * parent stays dead — so it stays classified as backstop-kill-eligible and
589
+ * this exact sweep simply tries again at the next `session_start`, with zero
590
+ * extra bookkeeping. This only breaks if the orphan's *parent* pid gets
591
+ * reused by a live process in the interim (closes the window), which is the
592
+ * same residual risk `decideOrphanReaping`'s registry path already accepts.
593
+ * A future hardening (making `killPidTree` return a success signal so a
594
+ * failed kill can be logged distinctly from "already gone") is a reasonable
595
+ * follow-up, not required for correctness here.
596
+ *
597
+ * Never throws — every step is wrapped so a reap failure cannot block or
598
+ * crash the caller (session_start).
599
+ */
600
+ export async function sweepUntrackedOrphans() {
601
+ if (!isInstanceRegistryEnabled())
602
+ return;
603
+ const startedAt = Date.now();
604
+ try {
605
+ const [registry, processes] = await Promise.all([
606
+ readInstanceRegistry(),
607
+ enumerateManagedProcesses(),
608
+ ]);
609
+ if (processes.length === 0)
610
+ return;
611
+ const candidates = decideBackstopOrphanReaping(processes, registry, realIsPidAlive);
612
+ let killedCount = 0;
613
+ for (const proc of candidates) {
614
+ await killPidTree(proc.pid);
615
+ killedCount++;
616
+ }
617
+ try {
618
+ logLatency({
619
+ type: "phase",
620
+ phase: "orphan_backstop_reaped",
621
+ filePath: "",
622
+ durationMs: Date.now() - startedAt,
623
+ metadata: {
624
+ scanned: processes.length,
625
+ killed: killedCount,
626
+ },
627
+ });
628
+ }
629
+ catch {
630
+ // best-effort logging only
631
+ }
632
+ }
633
+ catch {
634
+ // The sweep must never throw out of session_start.
635
+ }
636
+ }
410
637
  /**
411
638
  * Fire-and-forget orphan sweep: reads the registry, decides what's dead via
412
639
  * `decideOrphanReaping`, kills orphaned LSP children (by pid, with a
@@ -23,6 +23,16 @@
23
23
  import * as fs from "node:fs";
24
24
  import * as path from "node:path";
25
25
  import { getGlobalPiLensDir } from "./file-utils.js";
26
+ // #735: reuse the #449/#525 reaper's exact conservative liveness check
27
+ // (`process.kill(pid, 0)`, ESRCH-only-means-dead) rather than inventing a
28
+ // second one — see realIsPidAlive's own docstring, which already calls out
29
+ // clients/lsp-budget.ts as a precedent consumer. This creates a live-binding
30
+ // import cycle with instance-reaper.ts (which imports readInstanceRegistry/
31
+ // isInstanceRegistryEnabled from here); safe under Node ESM because every
32
+ // use on both sides happens inside function bodies, never at module-
33
+ // evaluation time, so both modules are fully initialized before either
34
+ // import is actually invoked.
35
+ import { realIsPidAlive } from "./instance-reaper.js";
26
36
  import { normalizeFilePath } from "./path-utils.js";
27
37
  function registryPath() {
28
38
  return path.join(getGlobalPiLensDir(), "instances.json");
@@ -138,7 +148,8 @@ export async function registerInstance(projectRoot) {
138
148
  });
139
149
  await writeRegistryAsync({ instances: others });
140
150
  }
141
- /** Update this process's heartbeat/rss. Cheap — safe to call every turn end. */
151
+ /** Update this process's heartbeat/rss (and, since #620, host CPU% + live
152
+ * LSP children's rss/CPU%). Cheap — safe to call every turn end. */
142
153
  export async function updateHeartbeat(patch = {}) {
143
154
  if (!isInstanceRegistryEnabled())
144
155
  return;
@@ -153,10 +164,24 @@ export async function updateHeartbeat(patch = {}) {
153
164
  }
154
165
  const now = new Date().toISOString();
155
166
  const current = file.instances[idx];
167
+ const lspChildren = patch.childUsage
168
+ ? current.lspChildren.map((child) => {
169
+ const usage = patch.childUsage?.[child.pid];
170
+ if (!usage)
171
+ return child;
172
+ return {
173
+ ...child,
174
+ rssBytes: usage.rssBytes ?? child.rssBytes,
175
+ cpuPercent: usage.cpuPercent ?? child.cpuPercent,
176
+ };
177
+ })
178
+ : current.lspChildren;
156
179
  file.instances[idx] = {
157
180
  ...current,
158
181
  rssBytes: patch.rssBytes ?? process.memoryUsage().rss,
159
- lspChildCount: current.lspChildren.length,
182
+ cpuPercent: patch.cpuPercent ?? current.cpuPercent,
183
+ lspChildren,
184
+ lspChildCount: lspChildren.length,
160
185
  heartbeatAt: now,
161
186
  };
162
187
  await writeRegistryAsync(file);
@@ -236,3 +261,117 @@ export function deregisterInstance() {
236
261
  return; // nothing to remove
237
262
  writeRegistrySync({ instances: remaining });
238
263
  }
264
+ /**
265
+ * PURE aggregation over a registry snapshot: "how much CPU/RAM is pi-lens
266
+ * attributable to, right now, across every process it owns" (#620) — the
267
+ * host of every registered instance plus every one of its live LSP children.
268
+ * Missing/unsampled `rssBytes`/`cpuPercent` (best-effort sampling can fail)
269
+ * are treated as 0 for summation purposes — never as a full instance to
270
+ * exclude, since a partially-sampled instance's other numbers are still real
271
+ * data worth surfacing.
272
+ *
273
+ * Does NOT include transient analyzer children (jscpd/knip/etc.) — those are
274
+ * short-lived and sampled separately per-invocation via
275
+ * clients/resource-sampler.ts into clients/latency-logger.ts, not carried in
276
+ * the registry (see the module docstring's scope note).
277
+ *
278
+ * #735: `isPidAlive`, when supplied, drops any instance whose owning pid is
279
+ * confirmed dead BEFORE aggregation — a hard-killed pi process otherwise
280
+ * leaves a registry entry with heartbeat-cached RSS that reads as a live,
281
+ * resource-consuming instance until it eventually ages out (up to
282
+ * `STALE_HEARTBEAT_MS`, see clients/instance-reaper.ts). Dropped rather than
283
+ * flagged `stale: true`: a dead pid is unambiguous (unlike heartbeat
284
+ * staleness, which the reaper deliberately treats as "maybe idle-but-alive"
285
+ * and never uses to justify removing/hiding anything) — the wire shape and
286
+ * every existing caller (chiefly `pilens_health`'s headline instance
287
+ * count/RSS/CPU numbers) simply expects a footprint of currently-live
288
+ * instances. Left `undefined` for pure/synchronous callers (incl. every
289
+ * pre-#735 unit test) that pass a plain snapshot with no intent to check OS
290
+ * process state — no filtering happens, preserving prior behavior exactly.
291
+ * No pid-reuse identity check is applied here (unlike the reaper's child-pid
292
+ * `matchProcess`): `InstanceEntry` never recorded the host's own command
293
+ * line to verify against (same gap #525 called out for the reaper's PARENT
294
+ * pid, deliberately left unfixed there too), and a health-report false
295
+ * positive is a much smaller blast radius than the reaper's mistaken kill —
296
+ * so plain liveness is judged sufficient here.
297
+ */
298
+ export function computeResourceFootprint(instances, isPidAlive) {
299
+ const liveInstances = isPidAlive
300
+ ? instances.filter((instance) => isPidAlive(instance.pid))
301
+ : instances;
302
+ const perInstance = liveInstances.map((instance) => {
303
+ const lspChildRssBytes = instance.lspChildren.reduce((sum, child) => sum + (child.rssBytes ?? 0), 0);
304
+ const lspChildCpuPercent = instance.lspChildren.reduce((sum, child) => sum + (child.cpuPercent ?? 0), 0);
305
+ return {
306
+ pid: instance.pid,
307
+ projectRoot: instance.projectRoot,
308
+ rssBytes: instance.rssBytes ?? 0,
309
+ cpuPercent: instance.cpuPercent ?? 0,
310
+ lspChildCount: instance.lspChildren.length,
311
+ lspChildRssBytes,
312
+ lspChildCpuPercent,
313
+ };
314
+ });
315
+ let totalRssBytes = 0;
316
+ let totalCpuPercent = 0;
317
+ let totalLspChildCount = 0;
318
+ for (const inst of perInstance) {
319
+ totalRssBytes += inst.rssBytes + inst.lspChildRssBytes;
320
+ totalCpuPercent += inst.cpuPercent + inst.lspChildCpuPercent;
321
+ totalLspChildCount += inst.lspChildCount;
322
+ }
323
+ return {
324
+ instanceCount: perInstance.length,
325
+ totalRssBytes,
326
+ totalCpuPercent,
327
+ totalLspChildCount,
328
+ perInstance,
329
+ };
330
+ }
331
+ /**
332
+ * Read the live registry and compute the aggregate footprint — the query
333
+ * side of "how much CPU/RAM is pi-lens using right now" (#620). Best-effort
334
+ * (readInstanceRegistry never throws); the answer only reflects whatever
335
+ * heartbeats have landed so far.
336
+ *
337
+ * #735: defaults to the reaper's `realIsPidAlive` so dead-pid registry
338
+ * entries (a hard-killed pi process) are excluded from both the returned
339
+ * footprint AND — opportunistically, fire-and-forget, best-effort — pruned
340
+ * from the on-disk registry, the same "entry removal only, never blocks the
341
+ * caller" convention `sweepOrphans`/`pruneDeadInstances` already use.
342
+ * Pruning here is a bonus cleanup, not a substitute for the reaper sweep:
343
+ * this path only prunes pids this particular read happened to find dead,
344
+ * while the reaper sweep is the authoritative, scheduled cleanup. Injectable
345
+ * so tests can pass a fake predicate (or omit filtering entirely by passing
346
+ * a function that always returns true) without touching real OS process
347
+ * state.
348
+ */
349
+ export async function getResourceFootprint(isPidAlive = realIsPidAlive) {
350
+ const instances = await readInstanceRegistry();
351
+ const deadPids = new Set(instances.filter((instance) => !isPidAlive(instance.pid)).map((instance) => instance.pid));
352
+ if (deadPids.size > 0) {
353
+ // Fire-and-forget: a health-report read must never block on, or fail
354
+ // because of, a registry write.
355
+ prunePids(deadPids).catch(() => {
356
+ // best-effort — a dead-pid entry that fails to prune here is simply
357
+ // re-evaluated (and re-dropped from the report) on the next read, and
358
+ // remains catchable by the scheduled reaper sweep regardless.
359
+ });
360
+ }
361
+ return computeResourceFootprint(instances, isPidAlive);
362
+ }
363
+ /** Best-effort removal of specific dead pids' entries from the on-disk
364
+ * registry (#735). Re-reads immediately before writing (rather than reusing
365
+ * an earlier snapshot) to narrow — not eliminate — the last-writer-wins race
366
+ * already accepted for this module's read-modify-write model (see the
367
+ * module docstring). Mirrors clients/instance-reaper.ts's
368
+ * `pruneDeadInstances`, kept local here rather than imported to avoid
369
+ * reaching back across the same import edge `realIsPidAlive` already
370
+ * crosses in the other direction. */
371
+ async function prunePids(deadPids) {
372
+ const file = await readRegistryAsync();
373
+ const remaining = file.instances.filter((entry) => !deadPids.has(entry.pid));
374
+ if (remaining.length === file.instances.length)
375
+ return;
376
+ await writeRegistryAsync({ instances: remaining });
377
+ }