pi-lens 4.1.0 → 4.1.2

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 (206) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/README.md +106 -0
  3. package/dist/clients/actionable-warnings-logger.js +2 -1
  4. package/dist/clients/actionable-warnings.js +14 -5
  5. package/dist/clients/advisory-provenance.js +48 -18
  6. package/dist/clients/ast-grep-client.js +1 -3
  7. package/dist/clients/atomic-write-staging.js +16 -4
  8. package/dist/clients/bash-file-access.js +99 -6
  9. package/dist/clients/biome-client.js +1 -3
  10. package/dist/clients/blocker-freshness.js +21 -8
  11. package/dist/clients/blocker-past-eof.js +16 -6
  12. package/dist/clients/bounded-cache.js +24 -8
  13. package/dist/clients/bounded-pid-file-lock.js +4 -3
  14. package/dist/clients/bounded-telemetry.js +14 -0
  15. package/dist/clients/cache-manager.js +6 -7
  16. package/dist/clients/cache-observability.js +782 -110
  17. package/dist/clients/call-graph.js +238 -63
  18. package/dist/clients/codebase-model.js +13 -5
  19. package/dist/clients/complexity-client.js +1 -3
  20. package/dist/clients/dead-code-client.js +19 -8
  21. package/dist/clients/dead-code-logger.js +1 -2
  22. package/dist/clients/deadline-utils.js +3 -1
  23. package/dist/clients/debug-heap.js +8 -1
  24. package/dist/clients/degradation-ledger.js +22 -1
  25. package/dist/clients/demoted-finding-render.js +155 -0
  26. package/dist/clients/dependency-checker.js +11 -7
  27. package/dist/clients/deps/pi-tui.js +8 -2
  28. package/dist/clients/deps/typebox.js +8 -3
  29. package/dist/clients/diagnostic-dispositions.js +39 -2
  30. package/dist/clients/diagnostic-line-freshness.js +5 -1
  31. package/dist/clients/dispatch/auxiliary-lsp.js +2 -3
  32. package/dist/clients/dispatch/dispatcher.js +13 -1
  33. package/dist/clients/dispatch/facts/function-facts.js +3 -1
  34. package/dist/clients/dispatch/facts/import-facts.js +2 -1
  35. package/dist/clients/dispatch/facts/tree-sitter-facts.js +1 -1
  36. package/dist/clients/dispatch/integration.js +9 -4
  37. package/dist/clients/dispatch/rules/error-swallowing.js +3 -1
  38. package/dist/clients/dispatch/rules/pass-through-wrappers.js +6 -2
  39. package/dist/clients/dispatch/rules/unsafe-boundary.js +31 -7
  40. package/dist/clients/dispatch/runners/actionlint.js +5 -2
  41. package/dist/clients/dispatch/runners/biome-check.js +10 -14
  42. package/dist/clients/dispatch/runners/cpp-check.js +5 -1
  43. package/dist/clients/dispatch/runners/detekt.js +9 -6
  44. package/dist/clients/dispatch/runners/dotnet-build.js +1 -1
  45. package/dist/clients/dispatch/runners/elixir-check.js +3 -1
  46. package/dist/clients/dispatch/runners/eslint.js +25 -6
  47. package/dist/clients/dispatch/runners/fish-indent.js +1 -1
  48. package/dist/clients/dispatch/runners/gleam-check.js +1 -1
  49. package/dist/clients/dispatch/runners/golangci-lint.js +3 -1
  50. package/dist/clients/dispatch/runners/hadolint.js +22 -15
  51. package/dist/clients/dispatch/runners/helm-lint.js +4 -1
  52. package/dist/clients/dispatch/runners/htmlhint.js +33 -16
  53. package/dist/clients/dispatch/runners/javac.js +1 -1
  54. package/dist/clients/dispatch/runners/ktlint.js +1 -1
  55. package/dist/clients/dispatch/runners/lsp.js +1 -1
  56. package/dist/clients/dispatch/runners/markdownlint.js +34 -12
  57. package/dist/clients/dispatch/runners/mypy.js +15 -17
  58. package/dist/clients/dispatch/runners/oxlint.js +231 -15
  59. package/dist/clients/dispatch/runners/php-lint.js +1 -1
  60. package/dist/clients/dispatch/runners/phpstan.js +52 -15
  61. package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -6
  62. package/dist/clients/dispatch/runners/pyright.js +23 -14
  63. package/dist/clients/dispatch/runners/rust-clippy.js +1 -1
  64. package/dist/clients/dispatch/runners/shellcheck.js +7 -9
  65. package/dist/clients/dispatch/runners/shfmt.js +5 -2
  66. package/dist/clients/dispatch/runners/spellcheck.js +10 -15
  67. package/dist/clients/dispatch/runners/sqlfluff.js +25 -17
  68. package/dist/clients/dispatch/runners/stylelint.js +40 -20
  69. package/dist/clients/dispatch/runners/swiftlint.js +8 -8
  70. package/dist/clients/dispatch/runners/taplo.js +110 -23
  71. package/dist/clients/dispatch/runners/terragrunt.js +13 -8
  72. package/dist/clients/dispatch/runners/tflint.js +25 -15
  73. package/dist/clients/dispatch/runners/tree-sitter.js +17 -3
  74. package/dist/clients/dispatch/runners/utils/candidate-probe.js +7 -1
  75. package/dist/clients/dispatch/runners/utils/runner-helpers.js +24 -1
  76. package/dist/clients/dispatch/runners/utils/tool-failure.js +152 -1
  77. package/dist/clients/dispatch/runners/vale.js +16 -14
  78. package/dist/clients/dispatch/runners/yaml-rule-parser.js +1 -3
  79. package/dist/clients/dispatch/runners/yamllint.js +13 -16
  80. package/dist/clients/dispatch/runners/zig-check.js +1 -1
  81. package/dist/clients/dispatch/suppress-writer.js +2 -1
  82. package/dist/clients/dispatch/types.js +6 -1
  83. package/dist/clients/dispatch/utils/lsp-diagnostics.js +6 -2
  84. package/dist/clients/extension-log.js +5 -3
  85. package/dist/clients/feature-hints.js +8 -1
  86. package/dist/clients/file-kinds.js +34 -129
  87. package/dist/clients/file-utils.js +17 -1
  88. package/dist/clients/finding-delivery-gate.js +79 -2
  89. package/dist/clients/formatters.js +144 -27
  90. package/dist/clients/freshness.js +38 -0
  91. package/dist/clients/git-guard.js +43 -13
  92. package/dist/clients/go-client.js +1 -3
  93. package/dist/clients/grammar-source.js +1 -1
  94. package/dist/clients/gzip-stage-write.js +30 -4
  95. package/dist/clients/host-edit-normalize.js +1 -3
  96. package/dist/clients/install-diagnostics.js +4 -2
  97. package/dist/clients/installer/index.js +86 -77
  98. package/dist/clients/instance-reaper.js +5 -2
  99. package/dist/clients/instance-registry.js +4 -2
  100. package/dist/clients/jscpd-client.js +11 -3
  101. package/dist/clients/knip-client.js +29 -15
  102. package/dist/clients/language-profile.js +11 -3
  103. package/dist/clients/latency-logger.js +13 -2
  104. package/dist/clients/lens-config.js +1 -1
  105. package/dist/clients/lens-map.js +8 -3
  106. package/dist/clients/live-bus-emitter.js +1 -1
  107. package/dist/clients/lsp/cascade-tier.js +27 -2
  108. package/dist/clients/lsp/client.js +144 -5
  109. package/dist/clients/lsp/config.js +4 -6
  110. package/dist/clients/lsp/edits.js +119 -32
  111. package/dist/clients/lsp/index.js +245 -41
  112. package/dist/clients/lsp/inferred-project.js +2 -1
  113. package/dist/clients/lsp/jvm-runtime.js +4 -1
  114. package/dist/clients/lsp/launch.js +11 -3
  115. package/dist/clients/lsp/pending-aux-coverage.js +152 -0
  116. package/dist/clients/lsp/server.js +19 -7
  117. package/dist/clients/lsp/tsserver-sync.js +10 -2
  118. package/dist/clients/lsp/workspace-diagnostics-cache.js +9 -4
  119. package/dist/clients/lsp-document-symbols.js +1 -3
  120. package/dist/clients/lsp-mutation.js +10 -20
  121. package/dist/clients/mcp/host-shim.js +1 -1
  122. package/dist/clients/memory-sampler.js +73 -7
  123. package/dist/clients/metrics-client.js +1 -3
  124. package/dist/clients/middle-man-analysis.js +8 -3
  125. package/dist/clients/module-report-lsp.js +7 -1
  126. package/dist/clients/module-report.js +28 -18
  127. package/dist/clients/ndjson-logger.js +117 -28
  128. package/dist/clients/opaque-mutation-scan.js +220 -0
  129. package/dist/clients/package-manager.js +6 -1
  130. package/dist/clients/pipeline.js +57 -9
  131. package/dist/clients/project-conventions.js +2 -8
  132. package/dist/clients/project-diagnostics/cache.js +10 -7
  133. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +2 -1
  134. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +3 -1
  135. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +13 -9
  136. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +3 -1
  137. package/dist/clients/project-diagnostics/scanner.js +22 -10
  138. package/dist/clients/project-lens-config.js +12 -5
  139. package/dist/clients/project-report.js +62 -27
  140. package/dist/clients/project-snapshot-fingerprint.js +48 -0
  141. package/dist/clients/project-snapshot-persist-worker.js +15 -1
  142. package/dist/clients/project-snapshot.js +472 -66
  143. package/dist/clients/read-expansion.js +12 -2
  144. package/dist/clients/read-guard-logger.js +12 -6
  145. package/dist/clients/read-guard-tool-lines.js +5 -2
  146. package/dist/clients/read-guard.js +88 -19
  147. package/dist/clients/resource-sampler.js +1 -1
  148. package/dist/clients/reverse-deps.js +6 -5
  149. package/dist/clients/review-graph/builder.js +236 -48
  150. package/dist/clients/review-graph/git-identity.js +8 -2
  151. package/dist/clients/review-graph/import-resolvers.js +10 -1
  152. package/dist/clients/review-graph/query.js +2 -1
  153. package/dist/clients/review-graph/revision-drift.js +21 -0
  154. package/dist/clients/review-graph/tsconfig-paths.js +4 -2
  155. package/dist/clients/review-graph/workspace-modules.js +8 -5
  156. package/dist/clients/ruff-client.js +6 -5
  157. package/dist/clients/runtime-agent-end.js +63 -31
  158. package/dist/clients/runtime-context.js +12 -4
  159. package/dist/clients/runtime-coordinator.js +127 -4
  160. package/dist/clients/runtime-session.js +55 -0
  161. package/dist/clients/runtime-tool-call.js +59 -3
  162. package/dist/clients/runtime-tool-result.js +148 -34
  163. package/dist/clients/runtime-turn.js +194 -8
  164. package/dist/clients/rust-client.js +1 -3
  165. package/dist/clients/safe-spawn.js +150 -11
  166. package/dist/clients/security-scan-client.js +1 -3
  167. package/dist/clients/session-event-guard.js +184 -0
  168. package/dist/clients/session-summary.js +3 -1
  169. package/dist/clients/sg-runner.js +1 -3
  170. package/dist/clients/source-filter.js +16 -8
  171. package/dist/clients/source-walker.js +1 -1
  172. package/dist/clients/spawn-timeout-cooldown.js +96 -0
  173. package/dist/clients/startup-scan.js +9 -3
  174. package/dist/clients/tool-definition.js +7 -0
  175. package/dist/clients/tool-policy.js +26 -11
  176. package/dist/clients/tool-render.js +2 -1
  177. package/dist/clients/tree-sitter-client.js +10 -4
  178. package/dist/clients/tree-sitter-logger.js +2 -1
  179. package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
  180. package/dist/clients/warm-attach.js +12 -5
  181. package/dist/clients/widget-state.js +27 -11
  182. package/dist/clients/word-index.js +3 -1
  183. package/dist/clients/workspace-topology.js +8 -2
  184. package/dist/clients/zizmor-config.js +9 -1
  185. package/dist/i18n.js +13 -5
  186. package/dist/index.js +15811 -12810
  187. package/dist/mcp/server.js +3 -1
  188. package/dist/tools/ast-grep-replace.js +68 -12
  189. package/dist/tools/lens-diagnostic-mark.js +21 -7
  190. package/dist/tools/lens-diagnostics.js +174 -38
  191. package/dist/tools/lsp-diagnostics.js +24 -7
  192. package/dist/tools/lsp-navigation.js +3 -1
  193. package/dist/tools/module-report.js +1 -1
  194. package/dist/tools/project-report.js +1 -1
  195. package/dist/tools/symbol-search.js +5 -2
  196. package/docs/durable-store-audit-1202.md +1 -1
  197. package/docs/servercapabilities.md +1 -1
  198. package/package.json +160 -143
  199. package/rules/typos/_typos.toml +2 -2
  200. package/scripts/analyze-pi-lens-logs.mjs +31 -13
  201. package/scripts/download-grammars.js +9 -3
  202. package/scripts/install-selftest.mjs +99 -11
  203. package/scripts/lib/host-provided-deps.mjs +75 -0
  204. package/scripts/lib/warm-loader-cache.mjs +285 -0
  205. package/scripts/rpc-load-check.mjs +30 -9
  206. package/scripts/warm-loader-cache.mjs +233 -0
@@ -37,6 +37,38 @@ export function hasSpawnFailureKind(error, kind) {
37
37
  "kind" in error &&
38
38
  error.kind === kind);
39
39
  }
40
+ /**
41
+ * #2010: derive teardown evidence from observed timestamps.
42
+ *
43
+ * Pure by design so every branch is deterministically unit-testable,
44
+ * including the natural-exit-at-budget-expiry race where the timeout
45
+ * callback arms a kill against an already-dead child.
46
+ *
47
+ * - No recorded kill start (the guard saw the child already dying), no
48
+ * observed death at all, or death PREDATING the kill start: the child
49
+ * left on its own -> "exited", ms 0. Claiming a kill we cannot
50
+ * distinguish from a natural exit would be invented evidence.
51
+ * - Death observed after the kill started: "escalate-kill" when the
52
+ * SIGKILL escalation fired, otherwise "killed-by-signal"; ms is the
53
+ * observed gap (floor 0 - clock jitter must never produce a negative).
54
+ */
55
+ export function resolveTeardownOutcome(input) {
56
+ // Another path (abort / output-limit) owns this death: emitting teardown
57
+ // evidence here would describe a kill this timeout didn't perform.
58
+ if (input.killStartedAtMs === undefined ||
59
+ input.deathObservedAtMs === undefined) {
60
+ return undefined;
61
+ }
62
+ // True natural-exit race: the child beat the budget-expired kill by a
63
+ // hair - death observed before (or exactly at) the kill start.
64
+ if (input.deathObservedAtMs <= input.killStartedAtMs) {
65
+ return { ms: 0, outcome: "exited" };
66
+ }
67
+ return {
68
+ ms: Math.max(0, input.deathObservedAtMs - input.killStartedAtMs),
69
+ outcome: input.escalated ? "escalate-kill" : "killed-by-signal",
70
+ };
71
+ }
40
72
  function toError(error) {
41
73
  return error instanceof Error ? error : new Error(String(error));
42
74
  }
@@ -74,7 +106,12 @@ function cwdIsUnresolvableSync(cwd) {
74
106
  */
75
107
  function commandProbablyPresent(command) {
76
108
  const exts = process.platform === "win32"
77
- ? ["", ...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)]
109
+ ? [
110
+ "",
111
+ ...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
112
+ .split(";")
113
+ .filter(Boolean),
114
+ ]
78
115
  : [""];
79
116
  const existsWithExt = (base) => {
80
117
  for (const ext of exts) {
@@ -192,7 +229,15 @@ function killPidTreeSync(pid) {
192
229
  return;
193
230
  }
194
231
  try {
195
- process.kill(pid, "SIGKILL");
232
+ // #2026: negative pid = whole process group (POSIX children spawn
233
+ // detached), reaching grandchildren too. Any failure (ESRCH = already
234
+ // gone, EPERM = raced) falls through to the direct-child attempt.
235
+ try {
236
+ process.kill(-pid, "SIGKILL");
237
+ }
238
+ catch {
239
+ process.kill(pid, "SIGKILL");
240
+ }
196
241
  }
197
242
  catch {
198
243
  // Child already exited.
@@ -268,10 +313,16 @@ function cmdEscapeArg(arg) {
268
313
  * that already worked. The `chcp 65001` prefix forces the UTF-8 code page (so
269
314
  * tool output isn't mangled by the system code page) and, as a side benefit,
270
315
  * keeps the (possibly quoted) command off the front of the line, avoiding
271
- * cmd.exe's `/s` outer-quote-stripping quirk.
316
+ * cmd.exe's `/s` outer-quote-stripping quirk. #2023: chcp is invoked via its
317
+ * absolute `%SystemRoot%\System32\chcp.com` path and chained with `&`, not
318
+ * `&&` — a child environment whose PATH cannot resolve System32 used to fail
319
+ * the bare `chcp` lookup, and `&&` then short-circuited EVERY .cmd/.bat spawn
320
+ * into exit 1 with empty output. With the pin plus `&`, even a chcp failure
321
+ * can never suppress the real command.
272
322
  */
273
323
  export function buildWindowsShellCommand(command, args) {
274
- return `chcp 65001 >nul 2>&1 && ${[command, ...args].map(cmdEscapeArg).join(" ")}`;
324
+ const chcp = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\chcp.com`;
325
+ return `${chcp} 65001 >nul 2>&1 & ${[command, ...args].map(cmdEscapeArg).join(" ")}`;
275
326
  }
276
327
  /**
277
328
  * The extensions this resolver treats as executable suffixes on their own,
@@ -859,6 +910,15 @@ export async function safeSpawnAsync(command, args, options) {
859
910
  // a ref'd 1s timer that outlives the child it was escalating against
860
911
  // would keep a one-shot `pi --print` process alive for up to 1s.
861
912
  let escalationTimer;
913
+ let teardownStartedAtMs;
914
+ let teardownEscalated = false;
915
+ // #2010: teardown evidence for the spawn's OWN timeout. The wall clock
916
+ // starts when the budget expires and the tree-kill is issued, and stops
917
+ // when the process tree is observed dead (`await killPromise` settles)
918
+ // - measured SEPARATELY from the budget itself, so a slow teardown is
919
+ // visible instead of silently inflating "how long the tool ran".
920
+ // `teardownEscalated` names whether SIGTERM sufficed or the SIGKILL
921
+ // escalation fired (POSIX; Windows taskkill /F is always forceful).
862
922
  // #1114: `child.killed` is set by Node the moment `kill()` successfully
863
923
  // SENDS a signal, not when the child actually dies — so gating the
864
924
  // escalation on `!child.killed` right after a successful `SIGTERM` send
@@ -994,6 +1054,14 @@ export async function safeSpawnAsync(command, args, options) {
994
1054
  return;
995
1055
  }
996
1056
  let child;
1057
+ // #2026: on POSIX, run the child in its OWN process group (detached).
1058
+ // killTree/killPidTreeSync then signal the whole GROUP (-pid), which
1059
+ // makes grandchildren (npm -> node, sh -> sleep, etc.) die with their
1060
+ // tool instead of surviving every timeout as orphans. Detachment stops
1061
+ // direct terminal-signal delivery, so EVERY POSIX pid registers for
1062
+ // lifetime cleanup - pi's signal/exit handlers forward the kill,
1063
+ // preserving die-with-host semantics.
1064
+ const posixProcessGroup = process.platform !== "win32";
997
1065
  try {
998
1066
  child = spawn(spawnCmd, spawnArgs, {
999
1067
  cwd: spawnCwd,
@@ -1001,6 +1069,7 @@ export async function safeSpawnAsync(command, args, options) {
1001
1069
  windowsHide: true,
1002
1070
  shell: false,
1003
1071
  windowsVerbatimArguments,
1072
+ detached: posixProcessGroup,
1004
1073
  });
1005
1074
  }
1006
1075
  catch (err) {
@@ -1022,7 +1091,10 @@ export async function safeSpawnAsync(command, args, options) {
1022
1091
  }));
1023
1092
  return;
1024
1093
  }
1025
- if (options?.lifetimeCoupled && child.pid) {
1094
+ if (child.pid && (posixProcessGroup || options?.lifetimeCoupled)) {
1095
+ // #2026: POSIX registers unconditionally - detached children no
1096
+ // longer receive terminal signals directly, so the lifetime
1097
+ // cleanup's signal forwarding IS their die-with-host path.
1026
1098
  installLifetimeCleanup();
1027
1099
  lifetimeState.pids.add(child.pid);
1028
1100
  }
@@ -1066,11 +1138,56 @@ export async function safeSpawnAsync(command, args, options) {
1066
1138
  child.kill("SIGKILL");
1067
1139
  }
1068
1140
  }
1141
+ else if (posixProcessGroup && child.pid && child.pid > 0) {
1142
+ // #2026: signal the whole process group. Grandchildren spawned
1143
+ // by the tool share its group, so one signal reaches the whole
1144
+ // tree; a negative-pid ESRCH means it already exited.
1145
+ const pgid = -child.pid;
1146
+ try {
1147
+ process.kill(pgid, "SIGTERM");
1148
+ }
1149
+ catch {
1150
+ child.kill("SIGTERM");
1151
+ }
1152
+ // #2027 round-1: gate the SIGKILL escalation on GROUP liveness,
1153
+ // not direct-child death - a tool can exit instantly on SIGTERM
1154
+ // while a SIGTERM-hardy grandchild keeps the group alive. The
1155
+ // timer stays REF'D for group mode (max 1s tail after resolve):
1156
+ // it is the only backstop for this group once finalize deletes
1157
+ // the pid from lifetimeState.
1158
+ escalationTimer = setTimeout(() => {
1159
+ let groupAlive = true;
1160
+ try {
1161
+ process.kill(pgid, 0);
1162
+ }
1163
+ catch {
1164
+ groupAlive = false;
1165
+ }
1166
+ if (!groupAlive)
1167
+ return;
1168
+ teardownEscalated = true;
1169
+ logLatency({
1170
+ type: "phase",
1171
+ phase: "spawn_group_kill_escalation",
1172
+ filePath: "",
1173
+ durationMs: 0,
1174
+ metadata: { pgid: child.pid },
1175
+ });
1176
+ try {
1177
+ process.kill(pgid, "SIGKILL");
1178
+ }
1179
+ catch {
1180
+ // Raced with group exit.
1181
+ }
1182
+ }, 1000);
1183
+ }
1069
1184
  else {
1070
1185
  child.kill("SIGTERM");
1071
1186
  escalationTimer = setTimeout(() => {
1072
- if (!closed)
1187
+ if (!closed) {
1188
+ teardownEscalated = true;
1073
1189
  child.kill("SIGKILL");
1190
+ }
1074
1191
  }, 1000);
1075
1192
  }
1076
1193
  };
@@ -1119,14 +1236,21 @@ export async function safeSpawnAsync(command, args, options) {
1119
1236
  // boolean here, before any await, means `waitForPipeIdle` can check
1120
1237
  // "did close already happen" synchronously the moment it runs.
1121
1238
  let closeSeen = false;
1239
+ // #2010: WHEN death was observed - the stop-clock for teardown evidence
1240
+ // and the discriminator for its outcome. A close that predates the
1241
+ // budget-expired kill means the child exited on its own ("exited"), no
1242
+ // matter that the timer callback raced and armed a kill anyway.
1243
+ let closedAtMs;
1122
1244
  child.on("close", () => {
1123
1245
  closeSeen = true;
1246
+ closedAtMs ??= Date.now();
1124
1247
  });
1125
1248
  // Timeout handling - KILL the process, don't just abandon it
1126
1249
  const timeoutId = setTimeout(() => {
1127
1250
  timedOut = true;
1128
1251
  if (!killed && !child.killed) {
1129
1252
  killed = true;
1253
+ teardownStartedAtMs = Date.now();
1130
1254
  killPromise = killTree();
1131
1255
  }
1132
1256
  }, timeout);
@@ -1267,11 +1391,14 @@ export async function safeSpawnAsync(command, args, options) {
1267
1391
  await killPromise;
1268
1392
  if (resolved)
1269
1393
  return;
1270
- // #1109: the child has exited — if killTree armed the non-Windows
1271
- // SIGTERM→SIGKILL escalation timer and it hasn't fired yet, clear it
1272
- // so it doesn't linger as a ref'd handle after this promise resolves.
1273
- if (escalationTimer)
1394
+ // #1109: the child has exited - clear the non-Windows escalation
1395
+ // timer so it doesn't linger as a ref'd handle. #2027 EXCEPTION:
1396
+ // in POSIX group mode the timer is the liveness-gated SIGKILL
1397
+ // backstop for SIGTERM-hardy grandchildren; it stays armed and
1398
+ // self-cleans within 1s of firing (max 1s ref'd tail).
1399
+ if (escalationTimer && !posixProcessGroup) {
1274
1400
  clearTimeout(escalationTimer);
1401
+ }
1275
1402
  // #1673 review F1: latch the verdict as DECIDED here, before the
1276
1403
  // idle-pipe wait — not after it. `code`/`signal`/`timedOut`/`aborted`
1277
1404
  // are already fixed by this point (they don't change during the
@@ -1294,6 +1421,15 @@ export async function safeSpawnAsync(command, args, options) {
1294
1421
  const signalInfo = signal ? { signal } : {};
1295
1422
  if (timedOut) {
1296
1423
  const cause = new Error(`Process timed out after ${timeout}ms (killed with ${signal || "SIGTERM"})`);
1424
+ // #2010: teardown evidence lives ONLY on the timeout path - it
1425
+ // describes how long the tree took to die after the budget
1426
+ // expired, and how it died ("exited" = the child beat the signal;
1427
+ // "killed-by-signal" / "escalate-kill" = how WE ended it).
1428
+ const timeoutTeardown = resolveTeardownOutcome({
1429
+ killStartedAtMs: teardownStartedAtMs,
1430
+ deathObservedAtMs: closedAtMs,
1431
+ escalated: teardownEscalated,
1432
+ });
1297
1433
  resolve({
1298
1434
  stdout,
1299
1435
  stderr,
@@ -1301,6 +1437,7 @@ export async function safeSpawnAsync(command, args, options) {
1301
1437
  error: cause,
1302
1438
  failure: "timeout",
1303
1439
  ...signalInfo,
1440
+ ...(timeoutTeardown && { timeoutTeardown }),
1304
1441
  spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
1305
1442
  ...outputInfo,
1306
1443
  resourceUsage,
@@ -1383,8 +1520,10 @@ export async function safeSpawnAsync(command, args, options) {
1383
1520
  closed = true;
1384
1521
  clearTimeout(timeoutId);
1385
1522
  abortSignal?.removeEventListener("abort", onAbort);
1386
- if (escalationTimer)
1523
+ // #2027: group-mode timer stays armed as the grandchild backstop.
1524
+ if (escalationTimer && !posixProcessGroup) {
1387
1525
  clearTimeout(escalationTimer);
1526
+ }
1388
1527
  if (child.pid)
1389
1528
  lifetimeState.pids.delete(child.pid);
1390
1529
  const resourceUsage = finishResourceUsage();
@@ -89,9 +89,7 @@ export class SecurityScanClient {
89
89
  */
90
90
  constructor(toolName, verbose = false) {
91
91
  this.toolName = toolName;
92
- this.log = verbose
93
- ? createSubsystemLogger(toolName)
94
- : () => { };
92
+ this.log = verbose ? createSubsystemLogger(toolName) : () => { };
95
93
  }
96
94
  /**
97
95
  * Resolve (once) whether the scanner is usable, sharing the probe promise
@@ -0,0 +1,184 @@
1
+ /**
2
+ * One home for "this event arrived on a ctx the SDK already invalidated"
3
+ * (#1925).
4
+ *
5
+ * pi invalidates a captured extension ctx when the session is replaced —
6
+ * `ctx.newSession()`, `ctx.fork()`, `ctx.switchSession()`, `ctx.reload()`.
7
+ * Every accessor on that ctx then throws from the SDK's `assertActive()`
8
+ * (`core/extensions/loader.js` in the installed
9
+ * `@earendil-works/pi-coding-agent`). An event already queued when the swap
10
+ * happens still reaches pi-lens, carrying the dead ctx, and the first
11
+ * unguarded `ctx.signal` / `ctx.ui` / `ctx.cwd` read throws.
12
+ *
13
+ * That throw does not escape into pi's loop. `ExtensionRunner.emit` wraps every
14
+ * handler call in a try/catch and routes the error to `emitError`
15
+ * (`core/extensions/runner.js:586-606` in the installed SDK), so the throw
16
+ * surfaces as an extension error report against pi-lens. The report names the
17
+ * wrong cause. It reads as a pi-lens handler bug when the real event is a
18
+ * benign race with a session swap, it says nothing about which handler keeps
19
+ * losing, and it counts nothing. This wrapper converts that noisy misattributed
20
+ * error into a counted, bounded, attributable skip.
21
+ *
22
+ * #1924 fixed that for `agent_settled` with an inline try/catch. #1925 found
23
+ * four more handlers with the same shape. Five inline copies of one policy is
24
+ * the parallel-state defect AGENTS.md names, so the policy lives here instead.
25
+ * `agent_settled` is a consumer of this path, not a special case.
26
+ *
27
+ * ## What this module actually covers
28
+ *
29
+ * Not every `pi.on` registration in `index.ts` goes through here, and the doc
30
+ * used to read as if they all did. Seven do today:
31
+ *
32
+ * - {@link wrapSessionEventHandler} — `tool_result`, `turn_start`,
33
+ * `agent_end`, `turn_end`, `agent_settled` (#1925) and `session_start`
34
+ * (#1929). All six return nothing, so a skipped event resolving to
35
+ * `undefined` is exactly their own early-exit value.
36
+ * - {@link wrapSessionEventHandlerWithResult} — `context` (#1929), which must
37
+ * hand the host back a message list on the live path.
38
+ *
39
+ * Five registrations stay unwrapped on purpose: `resources_discover`,
40
+ * `session_before_fork`, `tool_call`, `session_shutdown`, and `message_end`.
41
+ * `tests/clients/session-event-guard-sweep.test.ts` is the source of truth for
42
+ * that split. It scans every registration in `index.ts` and reds unless the
43
+ * handler is wrapped or carries a written reason, so this paragraph can go
44
+ * stale but the contract cannot.
45
+ *
46
+ * The wrapper does three things and nothing else:
47
+ *
48
+ * 1. **Probes once, before dispatch.** {@link probeCtxActive} answers `false`
49
+ * only when an `assertActive()`-wrapped accessor threw the SDK's own stale
50
+ * message. `undefined` (no ctx, unexpected shape, unrecognised throw) is
51
+ * inconclusive and always dispatches — never guess a session dead.
52
+ * 2. **Still classifies a stale throw that races in mid-handler.** The probe
53
+ * is a point-in-time read; the swap can land between the probe and the
54
+ * handler's own first ctx read, and for an async handler it can land at any
55
+ * await. Both the synchronous throw and the rejected promise are
56
+ * classified by {@link isStaleExtensionCtxError}.
57
+ * 3. **Makes the skip visible.** One bounded record and one ledger kind for
58
+ * the whole class, keyed by the event name so aggregation still answers
59
+ * WHICH handler is being skipped. A silent guard and a guard that never
60
+ * fires read identically from a log, which is exactly how vacuous guards
61
+ * survive review.
62
+ *
63
+ * Anything that is NOT the SDK's stale-ctx error propagates unchanged. The
64
+ * wrapper narrows nothing else: a handler bug must stay as loud as it was.
65
+ */
66
+ import { emitBounded } from "./bounded-telemetry.js";
67
+ import { probeCtxActive } from "./session-lifecycle.js";
68
+ /**
69
+ * The pi SDK invalidates a captured `pi`/command ctx after a session
70
+ * replacement or reload; every later `pi.*` call then throws with this
71
+ * signature. Matched by MESSAGE — not by `===` against a captured instance —
72
+ * so a fire-and-forget task that races a session swap can recognise the benign
73
+ * stale-ctx throw and degrade to a no-op. Substring-matched on the stable
74
+ * "stale after session replacement or reload" phrase so it survives incidental
75
+ * wording changes around it.
76
+ *
77
+ * Deliberately a NARROWER match than {@link probeCtxActive}'s (which accepts
78
+ * the shorter "stale after session replacement", the message
79
+ * `ExtensionRunner.invalidate()` uses for its own probe path). Widening this
80
+ * one is a behavior change: it decides which throws get swallowed.
81
+ */
82
+ export function isStaleExtensionCtxError(err) {
83
+ return (err instanceof Error &&
84
+ err.message.includes("stale after session replacement or reload"));
85
+ }
86
+ /**
87
+ * Record one skipped session event. Bounded per event name: the ledger counts
88
+ * every occurrence exactly, and only the first per event name this session
89
+ * also writes the detailed `latency.log` row, so a replaced session whose
90
+ * queue drains a hundred stale events cannot storm the log.
91
+ */
92
+ function recordStaleSkip(eventName, detectedAt) {
93
+ emitBounded("session_event_stale_ctx_skip", eventName, {
94
+ durationMs: 0,
95
+ metadata: { event: eventName, detectedAt },
96
+ }, {
97
+ ledgerKind: "extension-ctx-stale",
98
+ risingEdgePer: "identity",
99
+ reason: `${eventName} skipped: extension ctx is stale (${detectedAt})`,
100
+ });
101
+ }
102
+ function isThenable(value) {
103
+ return (typeof value?.then === "function");
104
+ }
105
+ /**
106
+ * The one policy. Both public wrappers are this function with a different
107
+ * stale-path value, so the probe point, the classification, and the record can
108
+ * never drift apart between a void handler and a value-returning one.
109
+ */
110
+ function guardSessionEvent(eventName, handler, onStaleResult, options) {
111
+ const skip = (event, detectedAt) => {
112
+ recordStaleSkip(eventName, detectedAt);
113
+ try {
114
+ options.dbg?.(`${eventName} skipped: extension ctx is stale after session replacement or reload`);
115
+ }
116
+ catch {
117
+ // A debug sink must never decide whether an event is handled.
118
+ }
119
+ return onStaleResult(event);
120
+ };
121
+ return (event, ctx) => {
122
+ // `false` is the only confirmed verdict. `undefined` means the probe
123
+ // could not tell, and an inconclusive probe must dispatch.
124
+ if (probeCtxActive(ctx) === false)
125
+ // SAFETY: `R` is the handler's declared return type, which is either a
126
+ // plain value or a promise of one. `skip` yields `Awaited<R>`, the
127
+ // settled form. TypeScript cannot see that a host awaiting an `R` is
128
+ // equally satisfied by the settled value, but every `pi.on` caller
129
+ // either awaits the result or ignores it, so both forms behave the
130
+ // same at the call site. The same reasoning covers the two casts
131
+ // below.
132
+ return skip(event, "pre-dispatch");
133
+ try {
134
+ const result = handler(event, ctx);
135
+ if (isThenable(result)) {
136
+ // Recover the rejection in place. The host awaits the same promise
137
+ // it would have awaited anyway; it just resolves instead.
138
+ // SAFETY: the recovered promise settles to `Awaited<R>`, which the
139
+ // host consumes exactly as it would an `R` — see the note above.
140
+ return Promise.resolve(result).catch((err) => {
141
+ if (isStaleExtensionCtxError(err))
142
+ return skip(event, "mid-handler");
143
+ throw err;
144
+ });
145
+ }
146
+ return result;
147
+ }
148
+ catch (err) {
149
+ if (isStaleExtensionCtxError(err))
150
+ // SAFETY: a synchronous handler's `R` is already its settled form,
151
+ // so `Awaited<R>` and `R` coincide here — see the note above.
152
+ return skip(event, "mid-handler");
153
+ throw err;
154
+ }
155
+ };
156
+ }
157
+ /**
158
+ * Wrap one `pi.on` handler so a stale ctx becomes an observable no-op instead
159
+ * of a throw into the host.
160
+ *
161
+ * The returned function keeps the handler's own signature and its return
162
+ * value, so a wrapped registration is a drop-in for the bare one. A skipped
163
+ * event resolves to `undefined`, which is what every handler wrapped this way
164
+ * already returns on its early-exit paths.
165
+ *
166
+ * Use {@link wrapSessionEventHandlerWithResult} when `undefined` is NOT the
167
+ * handler's own no-op value.
168
+ */
169
+ export function wrapSessionEventHandler(eventName, handler, options = {}) {
170
+ return guardSessionEvent(eventName, handler, () => undefined, options);
171
+ }
172
+ /**
173
+ * {@link wrapSessionEventHandler} for a handler whose return value the host
174
+ * consumes (#1929).
175
+ *
176
+ * The live path returns exactly what the handler returned. The stale path
177
+ * returns `onStaleResult(event)` instead of assuming `undefined`, so a handler
178
+ * whose no-op value is something else does not get one silently substituted.
179
+ * Probe, classification, and the bounded record are identical to the void
180
+ * wrapper's — they are the same function underneath.
181
+ */
182
+ export function wrapSessionEventHandlerWithResult(eventName, handler, options) {
183
+ return guardSessionEvent(eventName, handler, options.onStaleResult, options);
184
+ }
@@ -31,7 +31,9 @@ export function formatSlopScoreSummary(summary) {
31
31
  }
32
32
  const topRules = summary.ruleCounts.slice(0, 3);
33
33
  const detail = topRules.length > 0
34
- ? " (" + topRules.map((entry) => entry.ruleId + " ×" + entry.count).join(", ") + ")"
34
+ ? " (" +
35
+ topRules.map((entry) => entry.ruleId + " ×" + entry.count).join(", ") +
36
+ ")"
35
37
  : "";
36
38
  return `Slop score: ${summary.scorePerKloc.toFixed(1)}/KLOC${detail}`;
37
39
  }
@@ -151,9 +151,7 @@ export class SgRunner {
151
151
  */
152
152
  sweepDurablyMissing = [];
153
153
  constructor(verbose = false) {
154
- this.log = verbose
155
- ? createSubsystemLogger("sg-runner")
156
- : () => { };
154
+ this.log = verbose ? createSubsystemLogger("sg-runner") : () => { };
157
155
  }
158
156
  /**
159
157
  * Check if ast-grep CLI is available, auto-install if not.
@@ -72,9 +72,9 @@ export function sourceTwinCandidates(filePath) {
72
72
  : ext === ".cjs"
73
73
  ? [".cts", ".ts", ".tsx", ".mts"]
74
74
  : ext === ".jsx"
75
- // Deliberately retain the broad fallback: a .jsx next to a .ts
76
- // is treated as a build artifact even without a .tsx sibling.
77
- ? [".tsx", ".ts", ".mts", ".cts"]
75
+ ? // Deliberately retain the broad fallback: a .jsx next to a .ts
76
+ // is treated as a build artifact even without a .tsx sibling.
77
+ [".tsx", ".ts", ".mts", ".cts"]
78
78
  : ext === ".js"
79
79
  ? [".ts", ".tsx", ".mts", ".cts"]
80
80
  : [];
@@ -251,7 +251,7 @@ function classifySkipGeneratedOrArtifact(filePath, options) {
251
251
  });
252
252
  }
253
253
  function shouldSkipGeneratedOrArtifact(filePath, options) {
254
- return classifySkipGeneratedOrArtifact(filePath, options).verdict === "generated";
254
+ return (classifySkipGeneratedOrArtifact(filePath, options).verdict === "generated");
255
255
  }
256
256
  /**
257
257
  * Extract the basename (filename without extension) from a path.
@@ -281,7 +281,8 @@ export function findSourceSibling(filePath, probeCache) {
281
281
  const dir = getDir(filePath);
282
282
  const base = getBasename(filePath);
283
283
  for (const [sourceExt, shadowedExts] of Object.entries(SOURCE_PRECEDENCE)) {
284
- if (!sourceExtsForCompiledExt(sourceExt).length && shadowedExts.includes(ext)) {
284
+ if (!sourceExtsForCompiledExt(sourceExt).length &&
285
+ shadowedExts.includes(ext)) {
285
286
  const siblingPath = path.join(dir, base + sourceExt);
286
287
  if (probeExists(siblingPath, probeCache))
287
288
  return siblingPath;
@@ -296,7 +297,10 @@ export function findSourceSibling(filePath, probeCache) {
296
297
  * Omit for the original, uncached behavior.
297
298
  */
298
299
  function sourceExtsForCompiledExt(sourceExt) {
299
- return sourceExt === ".ts" || sourceExt === ".tsx" || sourceExt === ".mts" || sourceExt === ".cts"
300
+ return sourceExt === ".ts" ||
301
+ sourceExt === ".tsx" ||
302
+ sourceExt === ".mts" ||
303
+ sourceExt === ".cts"
300
304
  ? [sourceExt]
301
305
  : [];
302
306
  }
@@ -391,11 +395,15 @@ function classifyEntry(entry, fullPath, cfg, probeCache, skipCounters) {
391
395
  return { recurseInto: fullPath };
392
396
  }
393
397
  if (entry.isFile()) {
394
- if (ignoreMatcher.isIgnored(fullPath, false))
395
- return {};
398
+ // #1974: extension gate before isIgnored — isIgnored recompiles minimatch
399
+ // patterns per ancestor dir per call, so a large ignored-but-not-dir-
400
+ // prunable pile (e.g. wal/*.log) should never pay that cost for files
401
+ // the extension gate would have dropped anyway.
396
402
  const ext = path.extname(entry.name).toLowerCase();
397
403
  if (!extensions.has(ext))
398
404
  return {};
405
+ if (ignoreMatcher.isIgnored(fullPath, false))
406
+ return {};
399
407
  // Skip if this is a build artifact or generated/codegen output.
400
408
  // #1107: counted separately (both are observability-only — neither
401
409
  // changes what gets skipped) so an invisible coverage hole (a real
@@ -31,7 +31,7 @@ import * as fs from "node:fs";
31
31
  import * as path from "node:path";
32
32
  import { isExcludedDirName } from "./file-utils.js";
33
33
  import { isGeneratedArtifactDirectoryName } from "./generated-artifacts.js";
34
- import { createDeadline, yieldIfOverBudget, } from "./cooperative-budget.js";
34
+ import { createDeadline, yieldIfOverBudget } from "./cooperative-budget.js";
35
35
  /**
36
36
  * Read a directory's entries, returning `[]` for a permission-denied or
37
37
  * missing directory instead of throwing. Shared by every walker below — a
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Session-scoped cooldown for commands whose spawn TIMED OUT (#1995).
3
+ *
4
+ * Live evidence: one wedged `markdownlint-cli2.cmd` on Windows charged its
5
+ * failure budget three times for a single edit — availability verification
6
+ * (30s, SIGTERM), autofix `--fix` (30s), and the lint runner (15s) each paid
7
+ * separately, because a positive probe/cache verdict said nothing about the
8
+ * command's ability to COMPLETE. Total post-edit cost: 52-60 seconds.
9
+ *
10
+ * This seam is the shared negative evidence:
11
+ * - `noteSpawnTimeout` records that a resolved command timed out, in which
12
+ * phase, and what wrapper kind it had; every record emits one bounded
13
+ * latency row so the session stays auditable.
14
+ * - `isInSpawnTimeoutCooldown` is consulted by every invocation path for that
15
+ * command — verification, autofix, and lint — so ONE wedged executable
16
+ * consumes at most ONE bounded failure budget per edit (invariant 1) and a
17
+ * timeout/skipped result is never re-reported as clean (invariant 4).
18
+ *
19
+ * Durability contract: SESSION. A hot loop of edits must not retry (a wedged
20
+ * `.cmd` shim does not heal between edits), but a new session may — the
21
+ * executable or its environment may have changed. Reset is registered in the
22
+ * session-state registry (`spawn-timeout-cooldown:latches`) and wired into
23
+ * `handleSessionStart`.
24
+ */
25
+ import path from "node:path";
26
+ import { logLatency } from "./latency-logger.js";
27
+ const COOLDOWN_STATE_KEY = Symbol.for("pi-lens:spawn-timeout-cooldown");
28
+ const _global = globalThis;
29
+ if (!_global[COOLDOWN_STATE_KEY]) {
30
+ _global[COOLDOWN_STATE_KEY] = { timedOutByCommand: new Map() };
31
+ }
32
+ const timedOutByCommand = _global[COOLDOWN_STATE_KEY].timedOutByCommand;
33
+ /** Wrapper kind for the telemetry record: how the command will be launched. */
34
+ function wrapperKindOf(command) {
35
+ const ext = path.extname(command);
36
+ return ext ? ext.slice(1) : "bare";
37
+ }
38
+ /**
39
+ * Cooldown key: the command's basename minus extension, lowercased.
40
+ *
41
+ * Deliberately NOT the full resolved path: the three lanes resolve the same
42
+ * physical binary through different helpers (PATH-resolved absolute,
43
+ * `<cwd>/node_modules/.bin/...`, bare name), and cross-lane sharing is the
44
+ * entire point (#1995 - otherwise each lane pays its own first budget).
45
+ */
46
+ function cooldownKey(command) {
47
+ return path
48
+ .basename(command)
49
+ .replace(/\.[^.]+$/, "")
50
+ .toLowerCase();
51
+ }
52
+ export function noteSpawnTimeout(input) {
53
+ const key = cooldownKey(input.command);
54
+ timedOutByCommand.set(key, {
55
+ tool: input.tool,
56
+ command: input.command,
57
+ phase: input.phase,
58
+ atMs: Date.now(),
59
+ });
60
+ logLatency({
61
+ type: "phase",
62
+ toolName: input.tool,
63
+ phase: "spawn_timeout_cooldown",
64
+ filePath: input.command,
65
+ durationMs: input.durationMs ?? 0,
66
+ status: "cooldown_armed",
67
+ metadata: {
68
+ command: input.command,
69
+ wrapperKind: wrapperKindOf(input.command),
70
+ timeoutPhase: input.phase,
71
+ ...(input.durationMs !== undefined && {
72
+ timeoutBudgetMs: input.durationMs,
73
+ }),
74
+ ...(input.teardown && {
75
+ teardownMs: input.teardown.ms,
76
+ teardownOutcome: input.teardown.outcome,
77
+ }),
78
+ },
79
+ });
80
+ }
81
+ /**
82
+ * True while `command` is cooling down after a spawn timeout. Every
83
+ * invocation path for this command should skip WITHOUT spawning and report
84
+ * an honest "not checked" outcome (skipped / 0-fixes), never clean.
85
+ */
86
+ export function isInSpawnTimeoutCooldown(command) {
87
+ return timedOutByCommand.has(cooldownKey(command));
88
+ }
89
+ /**
90
+ * Session-start re-arm seam (registered in the session-state registry):
91
+ * a new session may retry a command the previous session cooled down -
92
+ * the executable or its environment may have changed.
93
+ */
94
+ export function resetSpawnTimeoutCooldowns() {
95
+ timedOutByCommand.clear();
96
+ }