@herbertgao/pi-extensions 2026.9.15 → 2026.9.17

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 (197) hide show
  1. package/README.md +5 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
  4. package/node_modules/@herbertgao/sol-pi/package.json +1 -1
  5. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +11 -53
  6. package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
  7. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  8. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  9. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
  10. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
  11. package/node_modules/pi-lens/CHANGELOG.md +498 -0
  12. package/node_modules/pi-lens/README.md +45 -2
  13. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
  14. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
  15. package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
  16. package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
  17. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  18. package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
  19. package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
  20. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
  21. package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
  22. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
  23. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
  24. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
  25. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
  26. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
  27. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
  28. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
  29. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
  30. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
  31. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
  32. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  33. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
  34. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  40. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
  41. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
  64. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
  65. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
  66. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
  67. package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
  68. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
  69. package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
  70. package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
  71. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
  72. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
  73. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
  74. package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
  75. package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
  76. package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
  77. package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
  78. package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
  79. package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
  80. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
  81. package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
  82. package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
  83. package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
  84. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
  85. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
  86. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
  87. package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
  88. package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
  89. package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
  90. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  91. package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
  92. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
  93. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
  94. package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
  95. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
  96. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
  97. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
  98. package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
  99. package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
  100. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
  101. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
  102. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
  103. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  104. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
  105. package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
  106. package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
  107. package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
  108. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
  109. package/node_modules/pi-lens/dist/index.js +5695 -3733
  110. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
  111. package/node_modules/pi-lens/dist/mcp/server.js +33 -21
  112. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  113. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
  114. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
  115. package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  116. package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  117. package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
  118. package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
  119. package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  120. package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  121. package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  122. package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
  123. package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
  124. package/node_modules/pi-lens/docs/adr/README.md +26 -0
  125. package/node_modules/pi-lens/docs/agent-tools.md +18 -1
  126. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
  127. package/node_modules/pi-lens/docs/configuration.md +55 -1
  128. package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
  129. package/node_modules/pi-lens/docs/environment-variables.md +61 -4
  130. package/node_modules/pi-lens/docs/features.md +2 -2
  131. package/node_modules/pi-lens/docs/globalconfig.md +6 -1
  132. package/node_modules/pi-lens/docs/language-coverage.md +17 -0
  133. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
  134. package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
  135. package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
  136. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
  137. package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
  138. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  139. package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
  140. package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
  141. package/node_modules/pi-lens/docs/settings.md +12 -3
  142. package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
  143. package/node_modules/pi-lens/package.json +5 -3
  144. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  145. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  146. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  147. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  148. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  149. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  150. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  151. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  152. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  153. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  154. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  155. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  156. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  157. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  158. package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
  159. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  160. package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
  161. package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
  162. package/node_modules/pi-mcp-adapter/README.md +12 -5
  163. package/node_modules/pi-mcp-adapter/config.ts +31 -8
  164. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
  165. package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
  166. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  167. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
  168. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
  169. package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
  170. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
  171. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
  172. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
  173. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  174. package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
  175. package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
  176. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
  177. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
  178. package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
  179. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  180. package/node_modules/pi-mcp-adapter/index.ts +37 -20
  181. package/node_modules/pi-mcp-adapter/init.ts +14 -15
  182. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
  183. package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
  184. package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
  185. package/node_modules/pi-mcp-adapter/package.json +3 -2
  186. package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
  187. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
  188. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
  189. package/node_modules/pi-mcp-adapter/types.ts +2 -2
  190. package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
  191. package/node_modules/pi-mcp-adapter/utils.ts +11 -3
  192. package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
  193. package/node_modules/pi-next-cue/LICENSE +21 -0
  194. package/node_modules/pi-next-cue/README.md +81 -0
  195. package/node_modules/pi-next-cue/index.ts +358 -0
  196. package/node_modules/pi-next-cue/package.json +28 -0
  197. package/package.json +9 -6
@@ -22,6 +22,7 @@ import { incrementDegradationCount, recordDegradation, recordDegradationOnce, }
22
22
  import { logExtension } from "./extension-log.js";
23
23
  import { isFullyQualifiedWin32 } from "./path-utils.js";
24
24
  import { startSpawnUsageSampler } from "./resource-sampler.js";
25
+ import { DEFAULT_MAX_OUTPUT_BYTES } from "./spawn-output-cap.js";
25
26
  import { compareOrdinal } from "./string-utils.js";
26
27
  /** Intent-level spawn failure. `cause` retains the original OS Error/errno. */
27
28
  export class SpawnFailureError extends Error {
@@ -479,7 +480,48 @@ function installLifetimeCleanup() {
479
480
  process.once(signal, () => {
480
481
  for (const pid of lifetimeState.pids)
481
482
  killPidTreeSync(pid);
482
- process.kill(process.pid, signal);
483
+ if (process.platform === "win32" && signal === "SIGHUP") {
484
+ // Windows emits SIGHUP when its console closes, but libuv cannot
485
+ // self-send it (process.kill(..., "SIGHUP") throws ENOSYS). The
486
+ // child cleanup above remains the required shutdown action; record
487
+ // the intentional decline synchronously and let the host exit cleanly.
488
+ recordDegradationOnce({
489
+ kind: "safe-spawn-signal-reraise-unsupported",
490
+ subject: `${process.platform}:${signal}`,
491
+ reason: `${process.platform} ${signal}: self signal re-raise is unsupported by the Windows libuv signal capability`,
492
+ metadata: {
493
+ platform: process.platform,
494
+ signal,
495
+ reason: "unsupported",
496
+ },
497
+ });
498
+ return;
499
+ }
500
+ try {
501
+ process.kill(process.pid, signal);
502
+ }
503
+ catch (error) {
504
+ // #3383: this runs inside a `process.once(signal)` handler, so a
505
+ // throw here is an uncaughtException DURING SHUTDOWN, not a
506
+ // rejection anyone can see. The one failure measured in the field
507
+ // (Windows SIGHUP ENOSYS) is declined in advance just above; this
508
+ // covers every other platform/signal/errno pair the OS can refuse —
509
+ // same ledger kind, `reason: "refused"` instead of "unsupported",
510
+ // because a reader of either row wants the same fact: the host
511
+ // exited without the signal's default disposition. The child
512
+ // cleanup above has already run either way.
513
+ recordDegradationOnce({
514
+ kind: "safe-spawn-signal-reraise-unsupported",
515
+ subject: `${process.platform}:${signal}`,
516
+ reason: `${process.platform} ${signal}: self signal re-raise was refused (${error.code ?? error.message})`,
517
+ metadata: {
518
+ platform: process.platform,
519
+ signal,
520
+ reason: "refused",
521
+ code: error.code ?? null,
522
+ },
523
+ });
524
+ }
483
525
  });
484
526
  }
485
527
  }
@@ -1156,6 +1198,24 @@ export async function safeSpawnAsync(command, args, options) {
1156
1198
  let killed = false;
1157
1199
  let outputTruncated = false;
1158
1200
  let killedForOutputCap = false;
1201
+ // #3375: bytes the child actually EMITTED (not what survived the cap) —
1202
+ // the one number the field crash report could not supply, so the ledger
1203
+ // row below can name how chatty the offending command really was.
1204
+ let observedOutputBytes = 0;
1205
+ // #3375: bytes RETAINED so far, kept incrementally. The cap check used to
1206
+ // re-measure `Buffer.byteLength(stdout) + Buffer.byteLength(stderr)` on
1207
+ // every chunk, which flattens both accumulated strings each time — O(n^2)
1208
+ // in total output. Harmless while only 12 of 110 call sites passed a cap;
1209
+ // with the cap now on by default it would tax every spawn (measured: a
1210
+ // 32 MiB child resolved in 7944 ms before this counter, 469 ms after).
1211
+ // Equal to the two byte lengths by construction: before
1212
+ // truncation `stdout`/`stderr` are exactly the appended texts, and after
1213
+ // it the branch that renders from the retained head/tail never reads it.
1214
+ let retainedOutputBytes = 0;
1215
+ // #3375: latched the first time a chunk handler throws. Later chunks
1216
+ // return immediately, so the same fault cannot be re-entered and
1217
+ // `refreshRetainedOutputs` can never overwrite the bytes already kept.
1218
+ let outputHandlerFailed = false;
1159
1219
  let streamingMatch = false;
1160
1220
  // #1651 review: a single boolean the close/error handlers both check
1161
1221
  // AND set, so whichever one decides the outcome first wins outright —
@@ -1198,11 +1258,14 @@ export async function safeSpawnAsync(command, args, options) {
1198
1258
  // re-arm the grace timer on every chunk. `undefined` before exit / after
1199
1259
  // the wait settles, so the calls below are no-ops outside that window.
1200
1260
  let rearmIdleGrace;
1201
- const maxOutputBytes = options?.maxOutputBytes !== undefined &&
1202
- Number.isFinite(options.maxOutputBytes) &&
1203
- options.maxOutputBytes > 0
1204
- ? Math.floor(options.maxOutputBytes)
1205
- : undefined;
1261
+ // #3375: never `undefined`. An omitted or unusable cap resolves to the
1262
+ // module default instead of to unbounded retention, so `appendOutput`
1263
+ // below has no arm that concatenates without a ceiling.
1264
+ const callerCap = options?.maxOutputBytes;
1265
+ const capFromCaller = callerCap !== undefined && Number.isFinite(callerCap) && callerCap > 0;
1266
+ const maxOutputBytes = capFromCaller
1267
+ ? Math.floor(callerCap)
1268
+ : DEFAULT_MAX_OUTPUT_BYTES;
1206
1269
  const outputTruncationMarker = "\n...[output truncated]...\n";
1207
1270
  let retainedHead = [];
1208
1271
  let retainedTail = [];
@@ -1259,17 +1322,16 @@ export async function safeSpawnAsync(command, args, options) {
1259
1322
  };
1260
1323
  const appendOutput = (stream, current, chunk) => {
1261
1324
  const text = typeof chunk === "string" ? chunk : chunk.toString();
1262
- if (maxOutputBytes === undefined || outputTruncated) {
1263
- if (maxOutputBytes === undefined)
1264
- return current + text;
1325
+ if (outputTruncated) {
1265
1326
  appendTail({ stream, text }, retainedTailLimit);
1266
1327
  truncationStream = stream;
1267
1328
  return renderOutput(stream);
1268
1329
  }
1269
- const used = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
1270
1330
  const bytes = Buffer.byteLength(text);
1271
- if (used + bytes <= maxOutputBytes)
1331
+ if (retainedOutputBytes + bytes <= maxOutputBytes) {
1332
+ retainedOutputBytes += bytes;
1272
1333
  return current + text;
1334
+ }
1273
1335
  outputTruncated = true;
1274
1336
  truncationStream = stream;
1275
1337
  const available = Math.max(0, maxOutputBytes - Buffer.byteLength(outputTruncationMarker));
@@ -1501,10 +1563,48 @@ export async function safeSpawnAsync(command, args, options) {
1501
1563
  usageSampler = { stop: () => null };
1502
1564
  }
1503
1565
  const resourceLabel = options?.resourceLabel ?? command;
1566
+ let killFailed = false;
1567
+ /** Send one signal. INVARIANT (#3375 r3 / M3384-2): every signal SEND in
1568
+ * `killTree` goes through this function. Exactly two `try`/`catch`
1569
+ * blocks remain in there and neither contains a bare send: the Windows
1570
+ * `await` guard around the taskkill child (its `catch` body sends only
1571
+ * through `trySend`), and the group LIVENESS PROBE, whose `catch`
1572
+ * computes a predicate rather than sending anything — the same shape as
1573
+ * the module-level `groupIsGone`. Returns false instead of throwing, and
1574
+ * records nothing itself: a refused signal is often ORDINARY (a negative-pid
1575
+ * ESRCH means the group already exited and the direct-child fallback
1576
+ * takes over), so only a send with no fallback left behind it pairs
1577
+ * with `noteKillFailure`. */
1578
+ const trySend = (send) => {
1579
+ try {
1580
+ send();
1581
+ return true;
1582
+ }
1583
+ catch {
1584
+ return false;
1585
+ }
1586
+ };
1587
+ /** Every signal for this teardown was refused, so the child may still be
1588
+ * running. One record per spawn — the reviewer's reproduction refused
1589
+ * two kills (the fault's and then the timeout's) and a row per attempt
1590
+ * would be a flood. A kind of its own rather than a field on
1591
+ * `spawn-output-cap-truncated`: this fires on the abort and timeout
1592
+ * teardowns too, which have nothing to do with an output cap. */
1593
+ const noteKillFailure = (phase) => {
1594
+ if (killFailed)
1595
+ return;
1596
+ killFailed = true;
1597
+ incrementDegradationCount({
1598
+ kind: "spawn-kill-failed",
1599
+ subject: resourceLabel,
1600
+ reason: `every signal available for the ${phase} teardown was refused; the child may still be running`,
1601
+ metadata: { phase, pid: child.pid },
1602
+ });
1603
+ };
1504
1604
  // On Windows, shell:true means child.pid is cmd.exe — child.kill() only
1505
1605
  // kills the wrapper, leaving the actual subprocess (e.g. knip/npx) alive
1506
1606
  // as an orphan. Use taskkill /F /T to kill the full process tree instead.
1507
- const killTree = async () => {
1607
+ const killTree = async (phase) => {
1508
1608
  if (isWindows && child.pid && child.pid > 0) {
1509
1609
  const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
1510
1610
  try {
@@ -1516,13 +1616,17 @@ export async function safeSpawnAsync(command, args, options) {
1516
1616
  });
1517
1617
  killer.once("close", () => done());
1518
1618
  killer.once("error", () => {
1519
- child.kill("SIGKILL");
1619
+ // E4: a throw HERE would leave the EventEmitter as an
1620
+ // uncaught exception, not a rejection.
1621
+ if (!trySend(() => child.kill("SIGKILL")))
1622
+ noteKillFailure(phase);
1520
1623
  done();
1521
1624
  });
1522
1625
  });
1523
1626
  }
1524
1627
  catch {
1525
- child.kill("SIGKILL");
1628
+ if (!trySend(() => child.kill("SIGKILL")))
1629
+ noteKillFailure(phase);
1526
1630
  }
1527
1631
  }
1528
1632
  else if (posixProcessGroup && ownsChildPid) {
@@ -1530,11 +1634,18 @@ export async function safeSpawnAsync(command, args, options) {
1530
1634
  // by the tool share its group, so one signal reaches the whole
1531
1635
  // tree; a negative-pid ESRCH means it already exited.
1532
1636
  const pgid = -child.pid;
1533
- try {
1534
- process.kill(pgid, "SIGTERM");
1535
- }
1536
- catch {
1537
- child.kill("SIGTERM");
1637
+ // #3375 round 3 (M3384-2): through the one sink, like every other
1638
+ // send. A second ad-hoc `try`/`catch` here would be a rival
1639
+ // signal-safety mechanism inside the same teardown — the shape this
1640
+ // change deleted four of — and a later edit to this branch would not
1641
+ // inherit `trySend`'s total send or its failure accounting.
1642
+ // `trySend` returning false here is ORDINARY, not a failure: #2026's
1643
+ // negative-pid ESRCH means the group already exited, so the direct
1644
+ // child below is the normal fallback and nothing is recorded. Only
1645
+ // the last-resort send can fail the teardown.
1646
+ if (!trySend(() => process.kill(pgid, "SIGTERM"))) {
1647
+ if (!trySend(() => child.kill("SIGTERM")))
1648
+ noteKillFailure(phase);
1538
1649
  }
1539
1650
  // #2027 round-1: gate the SIGKILL escalation on GROUP liveness,
1540
1651
  // not direct-child death - a tool can exit instantly on SIGTERM
@@ -1543,38 +1654,49 @@ export async function safeSpawnAsync(command, args, options) {
1543
1654
  // it is the only backstop for this group once finalize deletes
1544
1655
  // the pid from lifetimeState.
1545
1656
  escalationTimer = setTimeout(() => {
1546
- let groupAlive = true;
1547
- try {
1548
- process.kill(pgid, 0);
1549
- }
1550
- catch {
1551
- groupAlive = false;
1552
- }
1553
- if (!groupAlive)
1554
- return;
1555
- teardownEscalated = true;
1556
- logLatency({
1557
- type: "phase",
1558
- phase: "spawn_group_kill_escalation",
1559
- filePath: "",
1560
- durationMs: 0,
1561
- metadata: { pgid: child.pid },
1657
+ // The WHOLE body, not just the signal: this runs in a bare
1658
+ // timer callback, where any throw is an uncaught exception.
1659
+ const escalated = trySend(() => {
1660
+ let groupAlive = true;
1661
+ try {
1662
+ process.kill(pgid, 0);
1663
+ }
1664
+ catch {
1665
+ groupAlive = false;
1666
+ }
1667
+ if (!groupAlive)
1668
+ return;
1669
+ teardownEscalated = true;
1670
+ logLatency({
1671
+ type: "phase",
1672
+ phase: "spawn_group_kill_escalation",
1673
+ filePath: "",
1674
+ durationMs: 0,
1675
+ metadata: { pgid: child.pid },
1676
+ });
1677
+ // Through the sink too (#3375 r3 / M3384-2): one vocabulary
1678
+ // for every send in this function. A false here is ORDINARY
1679
+ // — the group raced its own exit — so it is deliberately not
1680
+ // escalated to `noteKillFailure`; the enclosing `trySend`
1681
+ // already guarantees nothing escapes this timer callback.
1682
+ trySend(() => process.kill(pgid, "SIGKILL"));
1562
1683
  });
1563
- try {
1564
- process.kill(pgid, "SIGKILL");
1565
- }
1566
- catch {
1567
- // Raced with group exit.
1568
- }
1684
+ if (!escalated)
1685
+ noteKillFailure(phase);
1569
1686
  }, 1000);
1570
1687
  }
1571
1688
  else {
1572
- child.kill("SIGTERM");
1689
+ // E2/E3. This branch runs whenever `/proc` cannot prove pid
1690
+ // ownership — every macOS host, and any Linux host with an
1691
+ // unreadable `/proc` — so it is not a hypothetical path.
1692
+ if (!trySend(() => child.kill("SIGTERM")))
1693
+ noteKillFailure(phase);
1573
1694
  escalationTimer = setTimeout(() => {
1574
- if (!closed) {
1575
- teardownEscalated = true;
1576
- child.kill("SIGKILL");
1577
- }
1695
+ if (closed)
1696
+ return;
1697
+ teardownEscalated = true;
1698
+ if (!trySend(() => child.kill("SIGKILL")))
1699
+ noteKillFailure(phase);
1578
1700
  }, 1000);
1579
1701
  }
1580
1702
  };
@@ -1583,7 +1705,7 @@ export async function safeSpawnAsync(command, args, options) {
1583
1705
  aborted = true;
1584
1706
  if (!killed && !child.killed) {
1585
1707
  killed = true;
1586
- void killTree();
1708
+ void killTree("abort");
1587
1709
  }
1588
1710
  };
1589
1711
  abortSignal?.addEventListener("abort", onAbort, { once: true });
@@ -1600,28 +1722,95 @@ export async function safeSpawnAsync(command, args, options) {
1600
1722
  !child.killed) {
1601
1723
  killedForOutputCap = true;
1602
1724
  killed = true;
1603
- killPromise = killTree();
1725
+ killPromise = killTree("output-cap");
1726
+ }
1727
+ };
1728
+ // #3375: one bounded, counted ledger row per command label the moment the
1729
+ // cap first bites - the crash report named no command, no byte count and
1730
+ // no cap value, so nothing in it could identify the chatty producer.
1731
+ // `incrementDegradationCount` keeps one latest-reason entry per subject
1732
+ // with an exact tally and writes durable rows only at powers of two, so a
1733
+ // tool that trips the cap on every dispatch cannot flood the sink.
1734
+ const recordOutputCapTrip = () => {
1735
+ incrementDegradationCount({
1736
+ kind: "spawn-output-cap-truncated",
1737
+ subject: resourceLabel,
1738
+ reason: `retained output reached the ${maxOutputBytes}-byte ${capFromCaller ? "caller" : "default"} cap after ${observedOutputBytes} bytes; child ${killedForOutputCap ? "terminated" : "left running"}`,
1739
+ metadata: {
1740
+ capBytes: maxOutputBytes,
1741
+ capSource: capFromCaller ? "caller" : "default",
1742
+ observedBytes: observedOutputBytes,
1743
+ killed: killedForOutputCap,
1744
+ },
1745
+ });
1746
+ };
1747
+ // #3375: the fault path for a chunk handler that threw. Every statement
1748
+ // here is total by construction - plain assignments, a ledger call that
1749
+ // swallows its own failures, and `killTree()`, which is `async` so a
1750
+ // synchronous fault inside it becomes a rejected promise rather than a
1751
+ // throw - because a throw raised HERE would escape the same way the
1752
+ // original RangeError did.
1753
+ const failOutputHandler = (stream, error) => {
1754
+ outputHandlerFailed = true;
1755
+ // Retention is over. The bytes already in `stdout`/`stderr` stay as
1756
+ // the bounded output: nothing renders from the retained head/tail
1757
+ // again, because the re-entry guard returns before that can happen.
1758
+ // That is also why `truncationStream` is deliberately NOT set here —
1759
+ // only `renderOutput` reads it, and nothing renders after a fault, so
1760
+ // the assignment was mutation-inert (removing it reds nothing).
1761
+ outputTruncated = true;
1762
+ incrementDegradationCount({
1763
+ kind: "spawn-output-handler-fault",
1764
+ subject: resourceLabel,
1765
+ reason: `${stream} chunk handling threw ${error.name} after ${observedOutputBytes} bytes; retention stopped and the child was terminated`,
1766
+ metadata: {
1767
+ stream,
1768
+ error: error.name,
1769
+ capBytes: maxOutputBytes,
1770
+ observedBytes: observedOutputBytes,
1771
+ },
1772
+ });
1773
+ if (!killed && !child.killed) {
1774
+ killedForOutputCap = true;
1775
+ killed = true;
1776
+ killPromise = killTree("handler-fault");
1604
1777
  }
1605
1778
  };
1606
1779
  // Collect output
1607
1780
  child.stdout?.setEncoding("utf-8");
1608
1781
  child.stderr?.setEncoding("utf-8");
1609
- child.stdout?.on("data", (data) => {
1610
- matchStreamingChunk("stdout", data);
1611
- stdout = appendOutput("stdout", stdout, data);
1612
- if (outputTruncated)
1613
- refreshRetainedOutputs();
1614
- stopForOutputLimit();
1615
- rearmIdleGrace?.();
1616
- });
1617
- child.stderr?.on("data", (data) => {
1618
- matchStreamingChunk("stderr", data);
1619
- stderr = appendOutput("stderr", stderr, data);
1620
- if (outputTruncated)
1621
- refreshRetainedOutputs();
1622
- stopForOutputLimit();
1623
- rearmIdleGrace?.();
1624
- });
1782
+ // #3375: a throw raised inside a stream `data` handler is delivered to
1783
+ // the process, not to the caller awaiting this spawn - no `try`/`catch`
1784
+ // around `await safeSpawnAsync(...)` can contain it, which is why a
1785
+ // `RangeError: Invalid string length` from `appendOutput` terminated the
1786
+ // Pi host on 2026-09-22. Every statement the handler runs lives inside
1787
+ // this `try`, so ANY fault in the retention path (not just that one
1788
+ // RangeError) becomes the bounded output-cap result the caller already
1789
+ // knows how to read.
1790
+ const onOutputChunk = (stream, data) => {
1791
+ if (outputHandlerFailed)
1792
+ return;
1793
+ try {
1794
+ observedOutputBytes += Buffer.byteLength(data);
1795
+ const wasTruncated = outputTruncated;
1796
+ matchStreamingChunk(stream, data);
1797
+ if (stream === "stdout")
1798
+ stdout = appendOutput(stream, stdout, data);
1799
+ else
1800
+ stderr = appendOutput(stream, stderr, data);
1801
+ if (outputTruncated)
1802
+ refreshRetainedOutputs();
1803
+ stopForOutputLimit();
1804
+ if (!wasTruncated && outputTruncated)
1805
+ recordOutputCapTrip();
1806
+ rearmIdleGrace?.();
1807
+ }
1808
+ catch (error) {
1809
+ failOutputHandler(stream, toError(error));
1810
+ }
1811
+ };
1812
+ child.stdout?.on("data", (data) => onOutputChunk("stdout", data));
1813
+ child.stderr?.on("data", (data) => onOutputChunk("stderr", data));
1625
1814
  // #1673 review round 3 (F2): registered HERE, at spawn time, alongside
1626
1815
  // the other handlers above — not inside `waitForPipeIdle`. Real Node
1627
1816
  // emits `close` a fraction of a millisecond after `exit` (both fire in
@@ -1650,7 +1839,7 @@ export async function safeSpawnAsync(command, args, options) {
1650
1839
  if (!killed && !child.killed) {
1651
1840
  killed = true;
1652
1841
  teardownStartedAtMs = Date.now();
1653
- killPromise = killTree();
1842
+ killPromise = killTree("timeout");
1654
1843
  }
1655
1844
  }, timeout);
1656
1845
  // #620: stop the poll and log peak/average CPU%+RSS for this invocation
@@ -1817,6 +2006,7 @@ export async function safeSpawnAsync(command, args, options) {
1817
2006
  const outputInfo = {
1818
2007
  ...(outputTruncated ? { outputTruncated: true } : {}),
1819
2008
  ...(killedForOutputCap ? { killedForOutputCap: true } : {}),
2009
+ ...(killFailed ? { killFailed: true } : {}),
1820
2010
  };
1821
2011
  const streamingMatchInfo = streamingMatch ? { streamingMatch: true } : {};
1822
2012
  // #1816: surface the signal name as a field on every path where one
@@ -1960,6 +2150,7 @@ export async function safeSpawnAsync(command, args, options) {
1960
2150
  spawnFailure,
1961
2151
  ...(outputTruncated ? { outputTruncated: true } : {}),
1962
2152
  ...(killedForOutputCap ? { killedForOutputCap: true } : {}),
2153
+ ...(killFailed ? { killFailed: true } : {}),
1963
2154
  ...(streamingMatch ? { streamingMatch: true } : {}),
1964
2155
  resourceUsage,
1965
2156
  });
@@ -1,5 +1,7 @@
1
1
  /**
2
- * The one reading of `SpawnResult.outputTruncated` (#2100).
2
+ * The one reading of `SpawnResult.outputTruncated` (#2100), the one output-cap
3
+ * number (#3375), and the bounded accumulator the `data` handlers that never
4
+ * reach `safeSpawnAsync` use instead of `+=` (#3383).
3
5
  *
4
6
  * Its own module for the same reason `ledger-bounds.ts` is: `spawn-outcome.ts`
5
7
  * is on the shared runner path that dozens of test files reach with a bare
@@ -7,6 +9,87 @@
7
9
  * every one of those mocks have to re-export it. This module has no imports, so
8
10
  * nobody has to mock it.
9
11
  */
12
+ /**
13
+ * The cap `safeSpawnAsync` applies when a caller passes no `maxOutputBytes`
14
+ * (#3375).
15
+ *
16
+ * Before this existed, an omitted cap meant NO cap: `appendOutput` fell
17
+ * through to `current + text` and grew one JS string until V8 refused the next
18
+ * concatenation with `RangeError: Invalid string length`. That throw is raised
19
+ * inside a stdout/stderr `data` handler, where the awaiting caller's
20
+ * `try`/`catch` cannot reach it, so it left the Pi host as an uncaught
21
+ * exception (field report: Pi 0.86.1, 2026-09-22).
22
+ *
23
+ * 32 MiB is chosen against the tree's own deliberate ceilings, not invented:
24
+ * the most generous explicit cap any caller asks for is 16 MiB
25
+ * (`MAX_GIT_STATUS_OUTPUT_BYTES` in `clients/shared-checkout-guard.ts` and
26
+ * `clients/opaque-mutation-scan.ts`, `MAX_LS_FILES_OUTPUT_BYTES` in
27
+ * `clients/git-tracked-ignore.ts` — a monorepo's whole tracked-file list), and
28
+ * the rest sit at 8 MiB or 64 KiB. Doubling that ceiling means no consumer
29
+ * whose legitimate volume is within the most generous allowance a maintainer
30
+ * has ever justified can be truncated by the DEFAULT, while the retained
31
+ * string stays 16x below V8's max string length (2^29-24 bytes of ASCII), so
32
+ * the concatenation that crashed the host is now unreachable rather than
33
+ * merely less likely. A caller that genuinely needs more passes its own
34
+ * `maxOutputBytes`; it always wins.
35
+ */
36
+ export const DEFAULT_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
37
+ /**
38
+ * The bound for a stream `data` handler that accumulates a string OUTSIDE
39
+ * `safeSpawnAsync` (#3383).
40
+ *
41
+ * #3375 capped the shared spawn seam, which covered 98 of 110 call sites; the
42
+ * class sweep for it found five accumulators that never reach that seam — a
43
+ * forked analyze worker (`clients/mcp/review.ts`), the unref'd one-shot
44
+ * collector (`clients/child-unref.ts`, which cannot import the seam at all: it
45
+ * is what `safe-spawn.ts` itself depends on) and two installer interpreter
46
+ * probes. Each grew one JS string per chunk with no ceiling, so each could
47
+ * reach the identical ending: V8 refusing the next concatenation with
48
+ * `RangeError: Invalid string length` INSIDE a `data` handler, which is an
49
+ * uncaught exception that terminated the Pi host on 2026-09-22.
50
+ *
51
+ * Retention is HEAD-ONLY and whole-chunk: the first chunk that would cross the
52
+ * bound is dropped entire, not byte-prefixed. `safeSpawnAsync`'s own
53
+ * `appendOutput` keeps a head, a marker and a tail because its callers parse
54
+ * tool output where the tail carries the summary; every caller here wants a
55
+ * prefix (a JSON document, a `ps` table, one path line) and a whole-chunk drop
56
+ * cannot split a multi-byte character.
57
+ *
58
+ * `maxBytes` defaults to {@link DEFAULT_MAX_OUTPUT_BYTES} so every bounded
59
+ * accumulator in the tree answers to ONE reviewed number. NO production caller
60
+ * passes it; it is a parameter so the retention arithmetic can be pinned at a
61
+ * readable size (`createBoundedOutputSink(10)`) instead of only at 32 MiB.
62
+ * There is deliberately no sanitizing of it either: nothing passes a value that
63
+ * would need it, so a `Number.isFinite` arm would be mutation-inert.
64
+ */
65
+ export function createBoundedOutputSink(maxBytes = DEFAULT_MAX_OUTPUT_BYTES) {
66
+ let text = "";
67
+ let retained = 0;
68
+ let truncated = false;
69
+ let observed = 0;
70
+ return {
71
+ append(chunk) {
72
+ const part = typeof chunk === "string" ? chunk : chunk.toString();
73
+ const bytes = Buffer.byteLength(part);
74
+ observed += bytes;
75
+ if (retained + bytes <= maxBytes) {
76
+ retained += bytes;
77
+ text += part;
78
+ return;
79
+ }
80
+ truncated = true;
81
+ },
82
+ get text() {
83
+ return text;
84
+ },
85
+ get truncated() {
86
+ return truncated;
87
+ },
88
+ get observedBytes() {
89
+ return observed;
90
+ },
91
+ };
92
+ }
10
93
  /**
11
94
  * True when `outputTruncated` is the OUTPUT CAP's own verdict about this run,
12
95
  * and not a detail of some other ending.