@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
@@ -16,7 +16,7 @@ export { detectPythonVenv };
16
16
  import { STAGE_TMP_PATTERN } from "../atomic-write-staging.js";
17
17
  import { DOTNET_CSHARP_ROOT_MARKERS, DOTNET_FSHARP_ROOT_MARKERS, KIND_EXTENSIONS, } from "../file-kinds.js";
18
18
  import { extensionsForLanguage } from "../language-registry.js";
19
- import { CARGO_WORKSPACE_MEMBER_DIALECT, direntsHaveMarkerGlobMatch, isAtOrAboveHomeDir, isFullyQualified, isWindowsPath, matchesWorkspaceMemberPattern, pathsEqual, } from "../path-utils.js";
19
+ import { CARGO_WORKSPACE_MEMBER_DIALECT, direntsHaveMarkerGlobMatch, isAtOrAboveHomeDir, isFullyQualified, isWindowsPath, matchesWorkspaceMemberPattern, normalizeEphemeralMapKey, pathsEqual, toPosix, } from "../path-utils.js";
20
20
  import { ensureTool, findManagedToolBinary, getToolEnvironment, getToolPath, } from "../installer/index.js";
21
21
  import * as installer from "../installer/index.js";
22
22
  import { classifyProbeFailure, describeInstallAttempt, logAvailabilityDecision, } from "../dispatch/runners/utils/availability-policy.js";
@@ -28,6 +28,10 @@ import { findLocalSgconfig, resolveBaselineSgconfig } from "../sgconfig.js";
28
28
  import { findLocalTyposConfig } from "../typos-config.js";
29
29
  import { resolvePackagePath } from "../package-root.js";
30
30
  import { resolveToolCwd } from "../tool-cwd.js";
31
+ import { dirMtimeRecordsAsync, dirMtimesStillFreshAsync, unknownDirMtimeRecords, } from "../workspace-topology.js";
32
+ import { bounded } from "../deadline-utils.js";
33
+ import { FRESHNESS_CADENCE_MS } from "../freshness-cadence.js";
34
+ import { HOOK_WALL_BUDGET_MS } from "../hook-budgets.js";
31
35
  import { recordDegradationOnce } from "../degradation-ledger.js";
32
36
  import { hasCargoWorkspaceTable, readCargoWorkspaceExclude, readCargoWorkspaceMembers, readTextFileOrUndefined, } from "../cargo-manifest.js";
33
37
  import { resolveAstGrepNativeExe } from "./wait-policy/index.js";
@@ -871,30 +875,33 @@ function nodeBinLocalCandidates(root, baseName) {
871
875
  function nodeBinCandidates(root, baseName) {
872
876
  return [...nodeBinLocalCandidates(root, baseName), baseName];
873
877
  }
874
- function normalizeSlashKey(value) {
875
- const normalized = path.resolve(value).replace(/\\/g, "/");
876
- return process.platform === "win32" ? normalized.toLowerCase() : normalized;
877
- }
878
878
  function piAgentExtensionsRootKey(file) {
879
- const dirKey = normalizeSlashKey(path.dirname(path.resolve(file)));
879
+ // #1193 P3: `normalizeEphemeralMapKey` IS this module's former
880
+ // `normalizeSlashKey` — slash-fold, then lowercase only on win32 — and these
881
+ // directory keys are exactly what it is scoped for: process-local, same-run,
882
+ // derived from this process's own `path.resolve`, never persisted and never
883
+ // compared against an externally supplied spelling.
884
+ const dirKey = normalizeEphemeralMapKey(path.dirname(path.resolve(file)));
880
885
  const marker = "/.pi/agent/extensions";
881
886
  const index = dirKey.indexOf(marker);
882
887
  if (index === -1)
883
888
  return undefined;
884
889
  return dirKey.slice(0, index + marker.length);
885
890
  }
886
- function normalizeRootKey(root) {
887
- return process.platform === "win32"
888
- ? path.resolve(root).toLowerCase()
889
- : path.resolve(root);
890
- }
891
891
  function IgnoreHomeRoot(primary) {
892
- const homeKey = normalizeRootKey(os.homedir());
892
+ // #1193 P3: the former `normalizeRootKey` folded case on win32 but left the
893
+ // separator alone; both sides of the comparison below go through the same
894
+ // derivation, so adding the seam's slash fold cannot change the verdict for
895
+ // any spelling the old copy already agreed on, and it makes a mixed-separator
896
+ // root agree with the home directory it names.
897
+ const homeKey = normalizeEphemeralMapKey(path.resolve(os.homedir()));
893
898
  return withRootMarkers(async (file) => {
894
899
  const root = await primary(file);
895
900
  if (!root)
896
901
  return undefined;
897
- return normalizeRootKey(root) === homeKey ? undefined : root;
902
+ return normalizeEphemeralMapKey(path.resolve(root)) === homeKey
903
+ ? undefined
904
+ : root;
898
905
  }, primary.rootMarkers ?? []);
899
906
  }
900
907
  function rubyBinCandidates(baseName) {
@@ -991,6 +998,22 @@ function isPermissionFsError(err) {
991
998
  const code = err?.code;
992
999
  return code === "EACCES" || code === "EPERM";
993
1000
  }
1001
+ /**
1002
+ * The directory `markerExists(dir, pattern)` reads to answer for `pattern`:
1003
+ * `dir` itself for a plain basename, and the named subdirectory for a pattern
1004
+ * that carries a path segment (`"prisma/schema.prisma"`). `NearestRoot`'s
1005
+ * freshness records are keyed on these directories, so the derivation lives
1006
+ * HERE, next to the probe it describes — a second copy could drift and silently
1007
+ * stop covering a pattern shape (#3412).
1008
+ */
1009
+ function markerProbeDir(dir, pattern) {
1010
+ const normalized = toPosix(pattern);
1011
+ const slash = normalized.lastIndexOf("/");
1012
+ const parentPattern = slash >= 0 ? normalized.slice(0, slash) : "";
1013
+ return parentPattern
1014
+ ? path.join(dir, ...parentPattern.split("/").filter(Boolean))
1015
+ : dir;
1016
+ }
994
1017
  async function markerExists(dir, pattern) {
995
1018
  if (!pattern.includes("*")) {
996
1019
  try {
@@ -1006,13 +1029,10 @@ async function markerExists(dir, pattern) {
1006
1029
  }
1007
1030
  const normalized = pattern.replace(/\\/g, "/");
1008
1031
  const slash = normalized.lastIndexOf("/");
1009
- const parentPattern = slash >= 0 ? normalized.slice(0, slash) : "";
1010
1032
  const basenamePattern = slash >= 0 ? normalized.slice(slash + 1) : normalized;
1011
1033
  if (!basenamePattern)
1012
1034
  return false;
1013
- const targetDir = parentPattern
1014
- ? path.join(dir, ...parentPattern.split("/").filter(Boolean))
1015
- : dir;
1035
+ const targetDir = markerProbeDir(dir, normalized);
1016
1036
  try {
1017
1037
  const entries = await readdir(targetDir, { withFileTypes: true });
1018
1038
  // Match files/symlinks only — a directory named like the marker (e.g. a
@@ -1028,8 +1048,22 @@ async function markerExists(dir, pattern) {
1028
1048
  return false;
1029
1049
  }
1030
1050
  }
1031
- // --- Root Detection Helpers ---
1032
- // --- Interactive Install Helper ---
1051
+ /**
1052
+ * One bound for every directory-mtime read this detector awaits. `signal` is
1053
+ * `undefined` on purpose: `RootFunction` takes a file path and nothing else, so
1054
+ * no hook signal reaches this seam until #2523 AC4 threads one — the wall clock
1055
+ * is the live half, and the registry entry says so. The budget is the edit
1056
+ * `tool_result` one because that is the hook the per-file touch path runs under
1057
+ * and the only hook the contract lets block the host; a tighter number would
1058
+ * abandon a revalidation that is strictly CHEAPER than the walk it falls back
1059
+ * to, making the hook slower rather than safer.
1060
+ */
1061
+ const rootMtimeBound = (label) => ({
1062
+ ms: HOOK_WALL_BUDGET_MS.tool_result_edit,
1063
+ signal: undefined,
1064
+ hook: "tool_result_edit",
1065
+ label,
1066
+ });
1033
1067
  /**
1034
1068
  * Walk up the directory tree looking for project root markers.
1035
1069
  *
@@ -1055,21 +1089,55 @@ export function NearestRoot(includePatterns, excludePatterns, stopDir) {
1055
1089
  // present transition must work without a process restart. The uncached
1056
1090
  // re-walk cost for configless repos is a known trade-off; bounding the
1057
1091
  // walk with stopDir for in-cwd files is the tracked optimization (#1412).
1092
+ //
1093
+ // A HIT is revalidated instead of trusted for the session: it carries the
1094
+ // mtime of every directory the walk probed, and is served only while all of
1095
+ // them are unchanged. Without that, a nearer marker scaffolded below an
1096
+ // already-resolved root (`swift package init` in a subdirectory) — and a
1097
+ // marker REMOVED at the resolved root — stayed invisible for the whole
1098
+ // session, the other half of the very transition the paragraph above
1099
+ // handles (#3412; same shape as #2922 at the dispatch marker seam).
1058
1100
  const inFlight = new Map();
1101
+ // The patterns whose probe reads a subdirectory of the walked directory
1102
+ // instead of the walked directory itself: a pattern carrying a path segment
1103
+ // (`prisma/schema.prisma`) is answered from a subdirectory, so creating that
1104
+ // marker bumps the SUBDIRECTORY's mtime and the walked directory's own only
1105
+ // when the subdirectory had to be created too. Derived once from this
1106
+ // detector's pattern table, so a new pattern shape is covered on arrival
1107
+ // without a second list to maintain.
1108
+ const subdirPatterns = [];
1109
+ for (const pattern of [...includePatterns, ...(excludePatterns ?? [])]) {
1110
+ if (markerProbeDir("", pattern))
1111
+ subdirPatterns.push(pattern);
1112
+ }
1059
1113
  return withRootMarkers(async (file) => {
1060
1114
  // Cache key is the resolved directory — all files in the same dir share a root.
1061
1115
  const startDir = path.resolve(path.dirname(file));
1062
1116
  const dirKey = normalizeMapKey(startDir);
1063
- // Fast path: already resolved for this directory.
1117
+ // Fast path: already resolved for this directory, nothing the walk looked at
1118
+ // has changed since, and the entry is younger than the shared re-check
1119
+ // cadence. One stat per probed directory replaces the whole walk (markers x
1120
+ // directories, plus the hit directory's `isExcludedLspRoot` git-boundary
1121
+ // walk), so the memo still pays for itself; the cadence is what bounds the
1122
+ // axes a directory mtime cannot see — a same-tick create, and a `.gitignore`
1123
+ // above the hit changing the exclusion verdict — to one window instead of
1124
+ // the session (#3412 review round 1). A fired bound resolves `undefined`,
1125
+ // which is NOT freshness: it falls through to the walk.
1064
1126
  const cached = cache.get(dirKey);
1065
- if (cached !== undefined)
1066
- return cached;
1127
+ if (cached !== undefined &&
1128
+ Date.now() - cached.walkedAt < FRESHNESS_CADENCE_MS &&
1129
+ (await bounded(dirMtimesStillFreshAsync(cached.probedDirs), rootMtimeBound("lsp-root-memo-freshness"))) === true)
1130
+ return cached.root;
1067
1131
  // In-flight deduplication: if N parallel pipelines edit files in the same
1068
1132
  // directory simultaneously, only one stat-walk runs; the rest await the same
1069
1133
  // promise. This is the main fix for parallel-turn LSP timeout spikes.
1070
1134
  const flying = inFlight.get(dirKey);
1071
1135
  if (flying)
1072
1136
  return flying;
1137
+ const probedDirs = [];
1138
+ // Stamped before the walk so the window also covers the walk's own duration
1139
+ // — the conservative direction, by the walk's runtime (~0.2 ms measured).
1140
+ const walkedAt = Date.now();
1073
1141
  const promise = (async () => {
1074
1142
  let currentDir = startDir;
1075
1143
  const fsRoot = path.parse(currentDir).root;
@@ -1080,6 +1148,19 @@ export function NearestRoot(includePatterns, excludePatterns, stopDir) {
1080
1148
  currentDir !== stop) {
1081
1149
  break;
1082
1150
  }
1151
+ // Record every directory this step is about to read, BEFORE reading
1152
+ // it. Ordering is the invariant: a marker created between the stat
1153
+ // and the probe leaves the record STALE (the next call re-walks and
1154
+ // finds it), whereas recording afterwards would store the mtime that
1155
+ // already includes the creation and hide it for the session. Only
1156
+ // directories up to the hit are recorded — a marker created above a
1157
+ // hit cannot change which root is nearest.
1158
+ const stepDirs = [
1159
+ currentDir,
1160
+ ...subdirPatterns.map((pattern) => markerProbeDir(currentDir, pattern)),
1161
+ ];
1162
+ const stepRecords = await bounded(dirMtimeRecordsAsync(stepDirs), rootMtimeBound("lsp-root-walk-dir-mtimes"));
1163
+ probedDirs.push(...(stepRecords ?? unknownDirMtimeRecords(stepDirs)));
1083
1164
  // Check exclude patterns — skip this dir (but keep walking up)
1084
1165
  if (excludePatterns) {
1085
1166
  let excluded = false;
@@ -1114,7 +1195,7 @@ export function NearestRoot(includePatterns, excludePatterns, stopDir) {
1114
1195
  try {
1115
1196
  const result = await promise;
1116
1197
  if (result !== undefined)
1117
- cache.set(dirKey, result);
1198
+ cache.set(dirKey, { root: result, probedDirs, walkedAt });
1118
1199
  return result;
1119
1200
  }
1120
1201
  finally {
@@ -1595,7 +1676,7 @@ async function findExtensionBoundedRoot(file, extensionRootKey) {
1595
1676
  }
1596
1677
  // Stop at or beyond the extensions root — never walk into the
1597
1678
  // pi-agent-wide scope.
1598
- const currentKey = normalizeSlashKey(currentDir);
1679
+ const currentKey = normalizeEphemeralMapKey(path.resolve(currentDir));
1599
1680
  if (currentKey === extensionRootKey)
1600
1681
  return undefined;
1601
1682
  const parent = path.dirname(currentDir);
@@ -2623,7 +2704,7 @@ export const FishServer = {
2623
2704
  spawn(root, options) {
2624
2705
  return resolveAndLaunch({
2625
2706
  candidates: nodeBinCandidates(root, "fish-lsp"),
2626
- args: ["start"],
2707
+ args: ["start", "--stdio"],
2627
2708
  cwd: root,
2628
2709
  managedToolId: "fish-lsp",
2629
2710
  }, options?.allowInstall);
@@ -40,3 +40,17 @@ export function negotiateSyncKind(serverCapabilities) {
40
40
  }
41
41
  return TEXT_DOCUMENT_SYNC_KIND_FULL;
42
42
  }
43
+ export function negotiateSaveOptions(serverCapabilities) {
44
+ const sync = serverCapabilities?.textDocumentSync;
45
+ if (!sync || typeof sync !== "object")
46
+ return undefined;
47
+ const save = sync.save;
48
+ if (save === true)
49
+ return { includeText: false };
50
+ if (save && typeof save === "object") {
51
+ return {
52
+ includeText: save.includeText === true,
53
+ };
54
+ }
55
+ return undefined;
56
+ }
@@ -220,8 +220,8 @@ export const SERVER_DIAGNOSTIC_STRATEGIES = {
220
220
  // once the index has already had a full aggregateWaitMs window.
221
221
  workspaceIndexing: true,
222
222
  workspaceIndexingWarmWaitMs: 250,
223
- // #799: marksman is push-only and publishes NOTHING on a clean file —
224
- // there is no pull fallback and no sync-confirm protocol (unlike
223
+ // #799 / run 35914033696: marksman is push-only and publishes NOTHING on
224
+ // a clean transition — there is no pull fallback and no sync-confirm protocol (unlike
225
225
  // typescript's tsserver commands), so a clean markdown file's touch
226
226
  // waits its full budget with zero signal either way. Marking it
227
227
  // `silentOnClean` lets the generic push-only clean-confirm gate
@@ -243,20 +243,9 @@ export const SERVER_DIAGNOSTIC_STRATEGIES = {
243
243
  // diagnostics this strategy governs are a REAL but PARTIAL signal, not
244
244
  // full CUE validation.
245
245
  //
246
- // silentOnClean: true — the load-bearing finding. A cold `didOpen` on an
247
- // already-CLEAN file publishes NOTHING inside the wait budget (measured:
248
- // `touchFile` returns `inconclusive: true, inconclusiveReason:
249
- // "diagnostics-wait"` after the full budget, never an affirmative empty
250
- // array). This reproduces the #1520 review's original "reports
251
- // conclusively clean while still waiting" concern for the clean-cold-open
252
- // case specifically. It is NOT silent on every transition, though: once a
253
- // document is open and a `didChange` lands, the server DOES publish —
254
- // both the error (edit clean→broken: confirmed, ~930ms warm) and an
255
- // explicit empty array clearing it (edit broken→clean: confirmed,
256
- // ~320ms warm, `contentHash`-bound). That edit-triggered empty publish is
257
- // exactly what the shared push-only clean-confirm gate needs
258
- // `silentOnClean` for — it only ever governs the "no publish, notify
259
- // succeeded" case, never a case that already got a real publish.
246
+ // The repaired clean-signal probe measured cue as publishing a versioned
247
+ // clean-transition set (run 35914033696), so this marker stays absent:
248
+ // the cascade must retain its normal early-publish path.
260
249
  //
261
250
  // seedFirstPush: true — the one cold-open case that DOES publish (a
262
251
  // file that is already broken) sends the complete single diagnostic on
@@ -267,6 +256,96 @@ export const SERVER_DIAGNOSTIC_STRATEGIES = {
267
256
  // (~930ms, syntax reparse) with margin; a cold spawn's first push (~2.1s
268
257
  // end-to-end including process start) is bounded separately by
269
258
  // maxClientWaitMs, not this per-diagnostics budget.
259
+ // intelephense (php, #3310). Measured directly against the real v1.18.5
260
+ // binary over a raw JSON-RPC session answering `workspace/configuration`
261
+ // the way pi-lens does (2026-09-23, linux):
262
+ //
263
+ // +284ms initialize response — NO diagnosticProvider (push-only)
264
+ // +295ms publishDiagnostics diags=0 version=undefined <- pre-index
265
+ // +301ms indexingStarted
266
+ // +689ms indexingEnded
267
+ // +696ms publishDiagnostics diags=2 version=undefined <- the answer
268
+ //
269
+ // so `emptyFirstPublish: "indexing"`: the first publish is an artifact of
270
+ // the cold index, not an answer. The same session on a genuinely CLEAN
271
+ // file publishes `[]` at +304ms and `[]` AGAIN at +663ms (right after
272
+ // `indexingEnded`), which is what lets the held publish be released by
273
+ // the server's own second publish instead of by a timer — and a WARM
274
+ // touch (a second file, index already built) publishes the real set
275
+ // FIRST at +5ms, so the hold costs nothing once the session is warm.
276
+ //
277
+ // seedFirstPush: false — the measurement above is the direct refutation
278
+ // of "the first push is complete" for this server.
279
+ // pullRetryBudgetMs: 0 — no pull provider is advertised at all.
280
+ // aggregateWaitMs: 8000 — the budget must cover the cold index window,
281
+ // which is what the second (real) publish waits on: ~0.4s for a 139-file
282
+ // index on the dev box, ~5s on the nightly ubuntu runner (#3217's
283
+ // transcript). Per-edit callers cap this as a CEILING (#242), so an
284
+ // interactive edit still waits only its own cap; the uncapped paths (the
285
+ // tool-smoke LSP gate, which itself caps at 8000ms) get the full budget,
286
+ // and the cold index is paid once per session, not once per touch.
287
+ php: {
288
+ seedFirstPush: false,
289
+ pullRetryBudgetMs: 0,
290
+ debounceMs: 150,
291
+ aggregateWaitMs: 8000,
292
+ expectSemanticSecondPush: false,
293
+ emptyFirstPublish: "indexing",
294
+ },
295
+ // Svelte's pull diagnostics settle after the default 1500ms budget on a
296
+ // cold server. The tool-smoke gate caps waits at 8000ms, so give this
297
+ // server enough aggregate budget to return its seeded findings (#3311).
298
+ svelte: {
299
+ seedFirstPush: false,
300
+ pullRetryBudgetMs: 0,
301
+ debounceMs: 150,
302
+ aggregateWaitMs: 4000,
303
+ expectSemanticSecondPush: false,
304
+ },
305
+ // csharp-ls loads (design-time-builds) the restored project AFTER
306
+ // `initialize` returns, so the publish for a seeded CS0029 lands inside
307
+ // `waitForDiagnostics`, not inside the client wait. MEASURED A/B over two
308
+ // nightly Tool-smoke runs of the same fixture with `dotnet restore` already
309
+ // done — the ONLY difference between them was this budget:
310
+ // * `aggregateWaitMs` 1500 (the default): run 36054901266 →
311
+ // `[csharp] touched=0`, 4.05s after `Restored …/toolsmoke.csproj`.
312
+ // A real compiler error read as clean.
313
+ // * 8000: run 36058292424 → `[csharp] touched=1`, 6.49s after the same
314
+ // line; gate run 36059988117 → `lsp_diagnostics returned 1 primary
315
+ // finding`, 4.86s after it.
316
+ // Both windows INCLUDE workspace bootstrap + spawn + initialize, so they
317
+ // bound the wait from above: the required budget is in (1500, 4860] ms.
318
+ // 6000 covers the measured gate window with margin and stays under the
319
+ // tool-smoke gate's own 8000ms ceiling (that relation is pinned by
320
+ // tests/config/lsp-gate-population.test.ts), so the gate can still witness
321
+ // this budget. CONFIRMED at 6000 by run 36064829436: `✓ csharp csharp-ls 1
322
+ // lsp_diagnostics returned 1 primary finding`, 4.78s after `Restored
323
+ // …csproj`, census unchanged at gated 31 / handshake-only 8 / unavailable 6
324
+ // — so 6000 is measured as sufficient, not merely inferred from the 8000
325
+ // runs above. It is deliberately NOT the ceiling: an `lsp_diagnostics` call
326
+ // that passes no `waitMs` pays this budget in full on a file the server never
327
+ // publishes for (`tools/lsp-diagnostics.ts` leaves `maxClientWaitMs`
328
+ // undefined → `perServerTimeout` has no caller cap), so every 1000ms here
329
+ // is 1000ms of turn latency on the no-publication path (#3402 review r2).
330
+ // Every other field is DEFAULT_STRATEGY's value on purpose: csharp-ls is
331
+ // `mode=pull`/tier-1 authoritative-clean (#3311 investigator table), and
332
+ // nothing has measured its pull retry, so this entry moves the one field
333
+ // that was measured and no other (pinned in
334
+ // tests/clients/lsp/server-strategies.test.ts).
335
+ // fsharp/expert/vue deliberately have NO entry: the same two runs show
336
+ // `touched=0` at BOTH 1500 and 8000, so the budget is not what stops them
337
+ // publishing and an 8000 entry would buy nothing while costing every
338
+ // uncapped production call 6.5 extra seconds. Their fixture rows carry the
339
+ // observed-behavior exemption instead (`scripts/smoke-tools.mjs`), and a
340
+ // probe that wants a long window sets `PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS`
341
+ // — a flat harness override that needs no production budget.
342
+ csharp: {
343
+ seedFirstPush: false,
344
+ pullRetryBudgetMs: 250,
345
+ debounceMs: 150,
346
+ aggregateWaitMs: 6000,
347
+ expectSemanticSecondPush: false,
348
+ },
270
349
  cue: {
271
350
  seedFirstPush: true,
272
351
  pullRetryBudgetMs: 0,
@@ -274,6 +353,16 @@ export const SERVER_DIAGNOSTIC_STRATEGIES = {
274
353
  aggregateWaitMs: 2000,
275
354
  expectSemanticSecondPush: false,
276
355
  reopenOnResync: false,
356
+ },
357
+ // lua-language-server is push-only and was silent on clean transitions in
358
+ // the repaired probe (run 35914033696). Keep the marker on the measured
359
+ // server id so the cascade can skip its in-lane wait safely.
360
+ lua: {
361
+ seedFirstPush: true,
362
+ pullRetryBudgetMs: 0,
363
+ debounceMs: 150,
364
+ aggregateWaitMs: 2000,
365
+ expectSemanticSecondPush: false,
277
366
  silentOnClean: true,
278
367
  },
279
368
  };