@herbertgao/pi-extensions 2026.9.15 → 2026.9.16

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 (195) 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/@narumitw/pi-btw/dist/index.ts +12 -6
  5. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  6. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  7. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
  8. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
  9. package/node_modules/pi-lens/CHANGELOG.md +498 -0
  10. package/node_modules/pi-lens/README.md +45 -2
  11. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
  12. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
  13. package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
  14. package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
  15. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  16. package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
  17. package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
  18. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
  19. package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
  20. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
  21. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
  22. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
  23. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
  24. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
  25. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
  26. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
  27. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
  28. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
  29. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
  30. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  31. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
  32. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  33. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
  34. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
  40. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
  41. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
  64. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
  65. package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
  66. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
  67. package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
  68. package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
  69. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
  70. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
  71. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
  72. package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
  73. package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
  74. package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
  75. package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
  76. package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
  77. package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
  78. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
  79. package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
  80. package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
  81. package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
  82. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
  83. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
  84. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
  85. package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
  86. package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
  87. package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
  88. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  89. package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
  90. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
  91. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
  92. package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
  93. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
  94. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
  95. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
  96. package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
  97. package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
  98. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
  99. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
  100. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
  101. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  102. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
  103. package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
  104. package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
  105. package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
  106. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
  107. package/node_modules/pi-lens/dist/index.js +5695 -3733
  108. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
  109. package/node_modules/pi-lens/dist/mcp/server.js +33 -21
  110. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  111. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
  112. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
  113. package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  114. package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  115. package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
  116. package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
  117. package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  118. package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  119. package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  120. package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
  121. package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
  122. package/node_modules/pi-lens/docs/adr/README.md +26 -0
  123. package/node_modules/pi-lens/docs/agent-tools.md +18 -1
  124. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
  125. package/node_modules/pi-lens/docs/configuration.md +55 -1
  126. package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
  127. package/node_modules/pi-lens/docs/environment-variables.md +61 -4
  128. package/node_modules/pi-lens/docs/features.md +2 -2
  129. package/node_modules/pi-lens/docs/globalconfig.md +6 -1
  130. package/node_modules/pi-lens/docs/language-coverage.md +17 -0
  131. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
  132. package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
  133. package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
  134. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
  135. package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
  136. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  137. package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
  138. package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
  139. package/node_modules/pi-lens/docs/settings.md +12 -3
  140. package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
  141. package/node_modules/pi-lens/package.json +5 -3
  142. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  143. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  144. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  145. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  146. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  147. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  148. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  149. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  150. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  151. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  152. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  153. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  154. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  155. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  156. package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
  157. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  158. package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
  159. package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
  160. package/node_modules/pi-mcp-adapter/README.md +12 -5
  161. package/node_modules/pi-mcp-adapter/config.ts +31 -8
  162. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
  163. package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
  164. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  165. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
  166. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
  167. package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
  168. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
  169. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
  170. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
  171. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  172. package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
  173. package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
  174. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
  175. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
  176. package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
  177. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  178. package/node_modules/pi-mcp-adapter/index.ts +37 -20
  179. package/node_modules/pi-mcp-adapter/init.ts +14 -15
  180. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
  181. package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
  182. package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
  183. package/node_modules/pi-mcp-adapter/package.json +3 -2
  184. package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
  185. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
  186. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
  187. package/node_modules/pi-mcp-adapter/types.ts +2 -2
  188. package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
  189. package/node_modules/pi-mcp-adapter/utils.ts +11 -3
  190. package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
  191. package/node_modules/pi-next-cue/LICENSE +21 -0
  192. package/node_modules/pi-next-cue/README.md +81 -0
  193. package/node_modules/pi-next-cue/index.ts +358 -0
  194. package/node_modules/pi-next-cue/package.json +28 -0
  195. package/package.json +8 -5
@@ -19,6 +19,8 @@ import * as net from "node:net";
19
19
  import * as os from "node:os";
20
20
  import * as path from "node:path";
21
21
  import { writeFileAtomic } from "../atomic-write.js";
22
+ import { incrementDegradationCount } from "../degradation-ledger.js";
23
+ import { DEFAULT_MAX_OUTPUT_BYTES } from "../spawn-output-cap.js";
22
24
  export const WARM_DIAGNOSTICS_SCHEMA_VERSION = 1;
23
25
  export const WARM_CODE_ACTION_LOOKUP_LIMIT = 6;
24
26
  /**
@@ -27,9 +29,9 @@ export const WARM_CODE_ACTION_LOOKUP_LIMIT = 6;
27
29
  * resolved root (lowercased for case-insensitive filesystems), so when they're
28
30
  * the same project they meet. Mismatch → the client just falls back to cold.
29
31
  */
30
- export function ipcPathForCwd(cwd) {
31
- const hash = workspaceHash(cwd);
32
- if (process.platform === "win32") {
32
+ export function ipcPathForCwd(cwd, platform = process.platform) {
33
+ const hash = workspaceHash(cwd, platform);
34
+ if (platform === "win32") {
33
35
  return `\\\\.\\pipe\\pi-lens-mcp-${hash}`;
34
36
  }
35
37
  return path.join(os.tmpdir(), `pi-lens-mcp-${hash}.sock`);
@@ -38,21 +40,86 @@ export function ipcPathForCwd(cwd) {
38
40
  * The single stable per-workspace id both endpoint derivations key on. Every
39
41
  * per-workspace side-channel name (socket/pipe, turn-end status file) must come
40
42
  * from HERE, so the hook process and the server process cannot drift apart.
43
+ *
44
+ * #1193 P3 decision: this input is NOT a map key and neither path-utils seam
45
+ * belongs here. Writers and readers of this id, and what each one holds:
46
+ *
47
+ * | process | site | input |
48
+ * |--------------------|-----------------------------------------------|-------|
49
+ * | MCP server (warm) | `mcp/server.ts` `IPC_PATH` — listen | its launch cwd |
50
+ * | pi session (warm) | `clients/warm-attach.ts` — listen (pid-scoped) | runtime cwd |
51
+ * | PostToolUse hook | `requestWarmAnalyze` — connect | hook cwd |
52
+ * | Stop hook | `requestWarmTurnEnd` — connect | hook cwd |
53
+ * | Stop hook / health | `turnEndStatusPathForCwd` — write + read a file in tmpdir | cwd |
54
+ *
55
+ * Every row is a SEPARATE process that must reproduce the same 16 hex
56
+ * characters with no shared state, and the rows do not run at the same time —
57
+ * the server derives its id at launch, a hook derives its own minutes or hours
58
+ * later. `normalizeMapKey`/`normalizeFilePath` would make the id depend on
59
+ * filesystem state (`realpathSync.native`, on-disk casing) read at two
60
+ * different moments: a case-only rename, a remounted symlink, or a hook running
61
+ * in a different mount namespace than the server would silently stop the two
62
+ * from meeting. That is the exact staleness PR #2193 was rejected for on the
63
+ * LSP seam. `normalizeEphemeralMapKey` is excluded by its own contract — it is
64
+ * scoped to process-local, same-run keys and says so.
65
+ *
66
+ * So the derivation stays pure string math — and #3255 narrowed its case fold
67
+ * to the platforms whose FILESYSTEM folds case, because "pure" and
68
+ * "unconditional" are not the same thing:
69
+ *
70
+ * - case-INSENSITIVE default (`win32`, `darwin` APFS/HFS+): `Alpha` and `alpha`
71
+ * are ONE directory, so the only failure mode is a MISS. The server row and
72
+ * the hook rows can hold different spellings of it — the server's cwd can be
73
+ * a hand-written `--cwd=` / `PI_LENS_MCP_CWD` while the hook's comes from the
74
+ * payload (see `tests/mcp/turn-end-route.smoke.test.ts`'s win32 case) — so the
75
+ * fold is what makes them meet, and it stays. `darwin` belongs here for a
76
+ * reason in this tree, not by analogy: the incumbent selection that decides
77
+ * two sessions share a root already compares through `normalizeFilePath`
78
+ * (`clients/instance-registry.ts:837`), whose `realpathSync.native` returns
79
+ * on-disk casing on Darwin. A pair that SELECTS each other case-insensitively
80
+ * must be able to MEET.
81
+ * - case-SENSITIVE (Linux, *BSD): `Alpha` and `alpha` are TWO directories, and
82
+ * two spellings of one workspace cannot occur (a mis-cased `--cwd=` names a
83
+ * directory that does not exist). Folding there bought nothing and collided
84
+ * two workspaces onto one socket and one status file — #3255.
85
+ *
86
+ * Known residuals, both fail-safe and both un-closable without a filesystem
87
+ * read this derivation is forbidden to do: a case-insensitive MOUNT on a
88
+ * case-sensitive platform (`nocase` vfat/ntfs3/cifs, ext4 `chattr +F`) now
89
+ * misses instead of meeting — the same blind spot `normalizeFilePath` has
90
+ * there, measured and filed as #3154 — and a case-SENSITIVE APFS volume keeps
91
+ * today's collision.
92
+ *
93
+ * `platform` is an argument, not a `process.platform` read, so every arm is
94
+ * testable from one lane (the seam `normalizePathEntry` uses in
95
+ * `clients/lsp/launch.ts`).
41
96
  */
42
- function workspaceHash(cwd) {
43
- const root = path.resolve(cwd).toLowerCase();
97
+ function workspaceHash(cwd, platform = process.platform) {
98
+ const resolved = path.resolve(cwd);
99
+ const root = platform === "win32" || platform === "darwin"
100
+ ? resolved.toLowerCase()
101
+ : resolved;
44
102
  // sha256 (not for security — just a stable short id for the IPC socket/pipe
45
103
  // name keyed by cwd; sha256 over sha1 keeps SonarCloud's weak-hash check quiet)
46
104
  return crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
47
105
  }
48
106
  /** PID-scoped endpoint used by pi sessions. The legacy MCP analyze endpoint
49
107
  * remains workspace-scoped for compatibility with the PostToolUse hook. */
50
- export function diagnosticsIpcPathForCwd(cwd, pid) {
51
- const base = ipcPathForCwd(cwd);
52
- if (process.platform === "win32")
108
+ export function diagnosticsIpcPathForCwd(cwd, pid, platform = process.platform) {
109
+ const base = ipcPathForCwd(cwd, platform);
110
+ if (platform === "win32")
53
111
  return `${base}-diagnostics-${pid}`;
54
112
  return base.replace(/\.sock$/, `-diagnostics-${pid}.sock`);
55
113
  }
114
+ /**
115
+ * A connect that never landed, as opposed to a socket that opened and then
116
+ * failed. `ENOENT` is the POSIX "no socket file there"; `ECONNREFUSED` is a
117
+ * socket file (or named pipe) with no live listener behind it.
118
+ */
119
+ function isNoListenerError(error) {
120
+ const code = error?.code;
121
+ return code === "ENOENT" || code === "ECONNREFUSED";
122
+ }
56
123
  export function contentHash(content) {
57
124
  return crypto.createHash("sha256").update(content).digest("hex");
58
125
  }
@@ -80,19 +147,14 @@ function requestOverWarmIpc(endpoint, timeoutMs, buildRequest, validate) {
80
147
  const deadlineAt = Date.now() + timeoutMs;
81
148
  const socket = net.createConnection(endpoint);
82
149
  socket.setEncoding("utf8");
83
- let buffer = "";
84
150
  const timer = setTimeout(() => finish({ available: false, reason: "timeout" }), timeoutMs);
85
151
  timer.unref();
86
152
  socket.on("connect", () => {
87
153
  socket.write(`${JSON.stringify(buildRequest(deadlineAt))}\n`);
88
154
  });
89
- socket.on("data", (chunk) => {
90
- buffer += chunk;
91
- const newline = buffer.indexOf("\n");
92
- if (newline === -1)
93
- return;
155
+ socket.on("data", createWarmIpcLineReader((line) => {
94
156
  try {
95
- const message = JSON.parse(buffer.slice(0, newline));
157
+ const message = JSON.parse(line);
96
158
  const result = message.result;
97
159
  if (message.error || !result) {
98
160
  finish({ available: false, reason: "ipc-error" });
@@ -109,8 +171,14 @@ function requestOverWarmIpc(endpoint, timeoutMs, buildRequest, validate) {
109
171
  catch {
110
172
  finish({ available: false, reason: "schema-mismatch" });
111
173
  }
112
- });
113
- socket.on("error", () => finish({ available: false, reason: "ipc-error" }));
174
+ }, {
175
+ label: "warm-diagnostics-reply",
176
+ onOverflow: () => finish({ available: false, reason: "ipc-error" }),
177
+ }));
178
+ socket.on("error", (error) => finish({
179
+ available: false,
180
+ reason: isNoListenerError(error) ? "no-listener" : "ipc-error",
181
+ }));
114
182
  socket.on("close", () => finish({ available: false, reason: "ipc-error" }));
115
183
  });
116
184
  }
@@ -201,12 +269,12 @@ export async function requestWarmTurnEnd(cwd, timeoutMs = 55_000) {
201
269
  : { available: false, reason: ack.reason };
202
270
  }
203
271
  /** Per-workspace status file, keyed by the same hash as the IPC endpoint. */
204
- export function turnEndStatusPathForCwd(cwd) {
205
- return path.join(os.tmpdir(), `pi-lens-turn-end-${workspaceHash(cwd)}.json`);
272
+ export function turnEndStatusPathForCwd(cwd, platform = process.platform) {
273
+ return path.join(os.tmpdir(), `pi-lens-turn-end-${workspaceHash(cwd, platform)}.json`);
206
274
  }
207
- export function readTurnEndStatus(cwd) {
275
+ export function readTurnEndStatus(cwd, platform = process.platform) {
208
276
  try {
209
- const raw = fs.readFileSync(turnEndStatusPathForCwd(cwd), "utf8");
277
+ const raw = fs.readFileSync(turnEndStatusPathForCwd(cwd, platform), "utf8");
210
278
  const parsed = JSON.parse(raw);
211
279
  if (!parsed || typeof parsed !== "object")
212
280
  return undefined;
@@ -237,10 +305,10 @@ export function readTurnEndStatus(cwd) {
237
305
  * this is bounded best-effort telemetry (self-heals, errors already
238
306
  * swallowed below), and a lock on this path isn't worth it.
239
307
  */
240
- export function recordTurnEndOutcome(cwd, outcome) {
308
+ export function recordTurnEndOutcome(cwd, outcome, platform = process.platform) {
241
309
  try {
242
310
  const now = new Date().toISOString();
243
- const previous = readTurnEndStatus(cwd) ?? { ran: 0, skipped: 0 };
311
+ const previous = readTurnEndStatus(cwd, platform) ?? { ran: 0, skipped: 0 };
244
312
  const next = outcome.ran
245
313
  ? { ...previous, ran: previous.ran + 1, lastRunAt: now }
246
314
  : {
@@ -249,7 +317,15 @@ export function recordTurnEndOutcome(cwd, outcome) {
249
317
  lastSkipReason: outcome.reason,
250
318
  lastSkipAt: now,
251
319
  };
252
- writeFileAtomic(turnEndStatusPathForCwd(cwd), `${JSON.stringify(next)}\n`);
320
+ // Writes THIS workspace's file and nothing else. #3255 round 2 also deleted
321
+ // the file at the retired always-fold name; round 3 removed that, because
322
+ // on a case-sensitive host that name is not an orphan — it is the live
323
+ // current file of the case-variant sibling workspace, and the record
324
+ // carries no ownership field that could tell the two apart. The
325
+ // pre-upgrade file is left to the same existence gate every other stale
326
+ // tmp artifact has: nothing derives that name any more, so nothing reads
327
+ // it except a still-running pre-upgrade server, for which it is correct.
328
+ writeFileAtomic(turnEndStatusPathForCwd(cwd, platform), `${JSON.stringify(next)}\n`);
253
329
  }
254
330
  catch {
255
331
  // telemetry only — a read-only tmpdir must not break the Stop hook
@@ -272,7 +348,6 @@ export function requestWarmAnalyze(cwd, file, timeoutMs = 30_000) {
272
348
  };
273
349
  const socket = net.createConnection(ipcPathForCwd(cwd));
274
350
  socket.setEncoding("utf8");
275
- let buffer = "";
276
351
  const timer = setTimeout(() => {
277
352
  socket.destroy();
278
353
  finish(undefined);
@@ -282,45 +357,158 @@ export function requestWarmAnalyze(cwd, file, timeoutMs = 30_000) {
282
357
  const request = { file, cwd };
283
358
  socket.write(`${JSON.stringify(request)}\n`);
284
359
  });
285
- socket.on("data", (chunk) => {
286
- buffer += chunk;
287
- const newline = buffer.indexOf("\n");
288
- if (newline === -1)
289
- return;
360
+ socket.on("data", createWarmIpcLineReader((line) => {
290
361
  try {
291
- const message = JSON.parse(buffer.slice(0, newline));
362
+ const message = JSON.parse(line);
292
363
  finish(message.error ? undefined : message.result);
293
364
  }
294
365
  catch {
295
366
  finish(undefined);
296
367
  }
297
368
  socket.end();
298
- });
369
+ }, {
370
+ label: "warm-analyze-reply",
371
+ // `finish` here only clears the timer and resolves — unlike
372
+ // `requestOverWarmIpc`'s, which destroys the socket itself — so the
373
+ // peer that misframed would otherwise keep an open connection and
374
+ // keep writing into a reader that ignores it.
375
+ onOverflow: () => {
376
+ socket.destroy();
377
+ finish(undefined);
378
+ },
379
+ }));
299
380
  // No server / connection refused / reset → cold fallback.
300
381
  socket.on("error", () => finish(undefined));
301
382
  socket.on("close", () => finish(undefined));
302
383
  });
303
384
  }
304
385
  /**
305
- * One-shot line reader for the warm IPC socket (#1219). The clients write
306
- * exactly one newline-terminated request per connection and read one reply, so
307
- * the server must dispatch at most one line and ignore anything after it — a
308
- * `data` handler that keeps re-reading the same buffered line re-dispatches
309
- * the request on stray bytes. Returns the handler to attach to the socket's
310
- * `data` event.
386
+ * The ceiling on ONE newline-framed line, for every reader below (#3383).
387
+ *
388
+ * Deliberately the same number as the spawn output cap rather than a second one
389
+ * to argue about: the largest legitimate line in this protocol is a
390
+ * `requestWarmDiagnostics` request carrying a file's whole content, or the
391
+ * diagnostics reply to it, and 32 MiB is far above anything the read guard lets
392
+ * through while staying 16x below V8's max string length — so the
393
+ * concatenation that crashed the host in #3375 is unreachable here rather than
394
+ * merely unlikely.
311
395
  */
312
- export function createWarmIpcLineReader(onLine) {
396
+ export const MAX_FRAMED_LINE_BYTES = DEFAULT_MAX_OUTPUT_BYTES;
397
+ /**
398
+ * Newline-framed line reader for the warm IPC sockets and the MCP host's stdin.
399
+ *
400
+ * One-shot by default (#1219): the clients write exactly one newline-terminated
401
+ * request per connection and read one reply, so the server must dispatch at
402
+ * most one line and ignore anything after it — a `data` handler that keeps
403
+ * re-reading the same buffered line re-dispatches the request on stray bytes.
404
+ *
405
+ * BOUNDED since #3383. Every reader used to be `buffer += chunk` with no
406
+ * ceiling, so a peer that never sent a newline grew one JS string until the
407
+ * request's own timeout fired — measured through `requestWarmAnalyze` against a
408
+ * real socket: 64 MiB of newline-free reply, +929 MiB of heap, 20 s of it. Past
409
+ * {@link MAX_FRAMED_LINE_BYTES} the line is DISCARDED, the overflow is recorded
410
+ * once, and the caller's `onOverflow` decides the ending.
411
+ *
412
+ * The bound is on the FRAME, never on what is left over after a chunk's last
413
+ * newline (#3388 review H3388-1). The first version checked only the
414
+ * newline-free remainder, so a peer whose over-limit frame arrived with its
415
+ * newline in the SAME `data` chunk — which is what a single TCP segment or one
416
+ * `socket.write` produces — had its whole line dispatched, bound bypassed:
417
+ * measured at 33,554,433 bytes delivered, 0 overflow callbacks, 0 records. So
418
+ * each complete line is accounted for BEFORE it is dispatched, retained prefix
419
+ * included, and the remainder check below is now only the unterminated case.
420
+ *
421
+ * RESYNC applies to the unterminated case alone. A discarded UNTERMINATED line
422
+ * has a newline still to come, so framing skips to it and the next well-formed
423
+ * line is read normally rather than parsed as the dropped line's tail. A
424
+ * discarded COMPLETE line is already past its own newline, so framing continues
425
+ * from there — resyncing would eat the next line instead.
426
+ *
427
+ * Returns the handler to attach to the stream's `data` event.
428
+ */
429
+ export function createWarmIpcLineReader(onLine, options) {
430
+ /** The unterminated head of a line, and its size. */
313
431
  let buffer = "";
432
+ let bufferedBytes = 0;
314
433
  let dispatched = false;
434
+ let resyncing = false;
435
+ /**
436
+ * Drop the frame, record it once, and let the caller end the exchange.
437
+ * `awaitingNewline` is the difference between the two overflow shapes: an
438
+ * unterminated line must be skipped up to the newline that has not arrived
439
+ * yet, a complete one must not, because framing is already past its newline.
440
+ */
441
+ const overflow = (frameBytes, awaitingNewline) => {
442
+ buffer = "";
443
+ bufferedBytes = 0;
444
+ resyncing = awaitingNewline;
445
+ if (options.continuous !== true)
446
+ dispatched = true;
447
+ incrementDegradationCount({
448
+ kind: "ipc-frame-overflow",
449
+ subject: options.label,
450
+ reason: `discarded ${awaitingNewline ? "an unterminated" : "a complete"} line at ${frameBytes} bytes (limit ${MAX_FRAMED_LINE_BYTES})`,
451
+ metadata: {
452
+ limitBytes: MAX_FRAMED_LINE_BYTES,
453
+ overflowBytes: frameBytes,
454
+ terminated: !awaitingNewline,
455
+ },
456
+ });
457
+ options.onOverflow?.();
458
+ };
315
459
  return (chunk) => {
316
460
  if (dispatched)
317
461
  return;
318
- buffer += chunk;
319
- const newline = buffer.indexOf("\n");
320
- if (newline === -1)
462
+ // Every scan below runs over the NEW chunk at an offset, never over the
463
+ // retained buffer, and the retained bytes are carried rather than
464
+ // re-measured. Both matter once the buffer is allowed to reach the bound:
465
+ // `retained.indexOf()` flattens the accumulated rope on every socket read,
466
+ // and one 36 MiB unframed reply arrives as ~550 reads, so the bound alone
467
+ // would trade an unbounded string for seconds of CPU. Measured on
468
+ // `tests/clients/mcp/ipc-frame-bounds.test.ts`, whose real-socket case
469
+ // sends exactly that: 16.2 s with a rescan per read, 1.2 s with this.
470
+ let from = 0;
471
+ if (resyncing) {
472
+ // Still inside the over-long line: retain nothing until its newline.
473
+ const end = chunk.indexOf("\n");
474
+ if (end === -1)
475
+ return;
476
+ resyncing = false;
477
+ from = end + 1;
478
+ }
479
+ let newline = chunk.indexOf("\n", from);
480
+ while (newline !== -1) {
481
+ const segment = chunk.slice(from, newline);
482
+ from = newline + 1;
483
+ // H3388-1: the FRAME's length — this chunk's segment plus whatever was
484
+ // retained for it — decided before the line exists as a string.
485
+ const frameBytes = bufferedBytes + Buffer.byteLength(segment);
486
+ if (frameBytes > MAX_FRAMED_LINE_BYTES) {
487
+ overflow(frameBytes, false);
488
+ if (dispatched)
489
+ return;
490
+ newline = chunk.indexOf("\n", from);
491
+ continue;
492
+ }
493
+ const line = buffer + segment;
494
+ buffer = "";
495
+ bufferedBytes = 0;
496
+ if (options.continuous !== true) {
497
+ dispatched = true;
498
+ onLine(line);
499
+ return;
500
+ }
501
+ onLine(line);
502
+ newline = chunk.indexOf("\n", from);
503
+ }
504
+ if (from < chunk.length) {
505
+ const rest = chunk.slice(from);
506
+ buffer += rest;
507
+ bufferedBytes += Buffer.byteLength(rest);
508
+ }
509
+ if (bufferedBytes <= MAX_FRAMED_LINE_BYTES)
321
510
  return;
322
- dispatched = true;
323
- onLine(buffer.slice(0, newline));
511
+ overflow(bufferedBytes, true);
324
512
  };
325
513
  }
326
514
  export function createWarmIpcRequestQueue() {
@@ -10,7 +10,9 @@ import { spawn } from "node:child_process";
10
10
  import { existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { pmBinary, resolveNodePackageManager, runScriptArgs, } from "../package-manager.js";
13
+ import { incrementDegradationCount } from "../degradation-ledger.js";
13
14
  import { safeSpawnAsync } from "../safe-spawn.js";
15
+ import { createBoundedOutputSink, DEFAULT_MAX_OUTPUT_BYTES, } from "../spawn-output-cap.js";
14
16
  /**
15
17
  * Which npm script rebuilds the layout the server is running from. A server at
16
18
  * `…/dist/mcp/server.js` is the precompiled dist (`build:dist` recreates it); a
@@ -27,6 +29,14 @@ export function resolveRebuildScript(serverFilePath) {
27
29
  * Fork `node <workerPath>` to analyze a file in a fresh process. We spawn node
28
30
  * directly (no shell) so an interpreter path containing spaces is safe on
29
31
  * Windows — `safeSpawnAsync`'s shell mode does not escape the command itself.
32
+ *
33
+ * #3383: both pipes accumulate through {@link createBoundedOutputSink} rather
34
+ * than `+=`. The worker writes one JSON document to stdout, so an unbounded
35
+ * accumulation here could reach the same ending #3375 fixed at the shared spawn
36
+ * seam — V8 refusing the next concatenation with `RangeError: Invalid string
37
+ * length` inside a `data` handler, uncatchable by this promise. The only thing
38
+ * bounding it before was the `timeoutMs` kill, and a worker can emit hundreds
39
+ * of MiB long before 120 s elapse.
30
40
  */
31
41
  export function analyzeFileFresh(workerPath, file, cwd, options = {}, timeoutMs = 120_000) {
32
42
  return new Promise((resolve) => {
@@ -48,8 +58,8 @@ export function analyzeFileFresh(workerPath, file, cwd, options = {}, timeoutMs
48
58
  });
49
59
  return;
50
60
  }
51
- let stdout = "";
52
- let stderr = "";
61
+ const stdout = createBoundedOutputSink();
62
+ const stderr = createBoundedOutputSink();
53
63
  let settled = false;
54
64
  const finish = (outcome) => {
55
65
  if (settled)
@@ -63,23 +73,59 @@ export function analyzeFileFresh(workerPath, file, cwd, options = {}, timeoutMs
63
73
  finish({ error: `fresh analyze timed out after ${timeoutMs}ms` });
64
74
  }, timeoutMs);
65
75
  child.stdout.setEncoding("utf8");
66
- child.stdout.on("data", (chunk) => (stdout += chunk));
76
+ child.stdout.on("data", (chunk) => {
77
+ // Settled already means timed out or failed: keep accumulating and the
78
+ // retained output outlives the promise nobody is awaiting any more.
79
+ if (settled)
80
+ return;
81
+ stdout.append(chunk);
82
+ // Truncated stdout is never parseable JSON, so the run is over. Report
83
+ // the cap as the ending instead of letting `JSON.parse` blame the
84
+ // worker's output shape, and terminate the producer.
85
+ if (!stdout.truncated)
86
+ return;
87
+ incrementDegradationCount({
88
+ kind: "spawn-output-cap-truncated",
89
+ subject: "mcp-fresh-analyze",
90
+ reason: `worker stdout reached the ${DEFAULT_MAX_OUTPUT_BYTES}-byte default cap after ${stdout.observedBytes} bytes; worker terminated`,
91
+ metadata: {
92
+ capBytes: DEFAULT_MAX_OUTPUT_BYTES,
93
+ capSource: "default",
94
+ observedBytes: stdout.observedBytes,
95
+ killed: true,
96
+ },
97
+ });
98
+ try {
99
+ child.kill();
100
+ }
101
+ catch {
102
+ // Best-effort: the worker may already be gone, and this runs inside a
103
+ // `data` handler where a throw would be uncatchable.
104
+ }
105
+ finish({
106
+ error: `worker output exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes`,
107
+ });
108
+ });
67
109
  child.stderr.setEncoding("utf8");
68
- child.stderr.on("data", (chunk) => (stderr += chunk));
110
+ child.stderr.on("data", (chunk) => {
111
+ if (settled)
112
+ return;
113
+ stderr.append(chunk);
114
+ });
69
115
  child.on("error", (err) => finish({ error: `failed to fork worker: ${err.message}` }));
70
116
  child.on("close", (code) => {
71
117
  if (code !== 0) {
72
118
  finish({
73
- error: `worker exited ${code}: ${stderr.trim() || "(no stderr)"}`,
119
+ error: `worker exited ${code}: ${stderr.text.trim() || "(no stderr)"}`,
74
120
  });
75
121
  return;
76
122
  }
77
123
  try {
78
- finish({ result: JSON.parse(stdout) });
124
+ finish({ result: JSON.parse(stdout.text) });
79
125
  }
80
126
  catch {
81
127
  finish({
82
- error: `worker produced invalid JSON (${stderr.trim() || stdout.slice(0, 200)})`,
128
+ error: `worker produced invalid JSON (${stderr.text.trim() || stdout.text.slice(0, 200)})`,
83
129
  });
84
130
  }
85
131
  });
@@ -183,13 +183,18 @@ export class OpengrepClient extends SecurityScanClient {
183
183
  success: true,
184
184
  // A warning-level partial report with no scanned paths proves that
185
185
  // the producer went cold. A complete empty report still proves a
186
- // genuine scan, so it remains authoritative for the id-only fallback.
186
+ // genuine scan, so it is still "analysed" for the render layer's
187
+ // analysed-and-found-nothing state (#2970) — but it carries no
188
+ // FILE-level authority, which `analyzedFiles: []` below is what
189
+ // says.
187
190
  analyzed: !report.partial || report.scanned.length > 0,
188
- ...(report.scanned.length > 0
189
- ? {
190
- analyzedFiles: report.scanned.map((file) => realpathOrResolve(path.resolve(cwd, file))),
191
- }
192
- : {}),
191
+ // #2962: the set is ALWAYS carried, empty included. "This producer
192
+ // declared its coverage and it was zero files" and "this producer
193
+ // declares no coverage at all" are different facts; dropping the
194
+ // empty array collapses them into the second, and the second is
195
+ // what hands a zero-file scan whole-root retirement authority
196
+ // downstream (`runnerRetirementDecision`, tools/lens-diagnostics.ts).
197
+ analyzedFiles: report.scanned.map((file) => realpathOrResolve(path.resolve(cwd, file))),
193
198
  ...(report.partial ? { partial: true } : {}),
194
199
  ...(report.partial ? { summary: report.partial.reason } : {}),
195
200
  ...(report.partial && report.scanned.length === 0