@herbertgao/pi-extensions 2026.8.5 → 2026.8.6

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 (155) hide show
  1. package/README.md +5 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +48 -6
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  7. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +10 -0
  8. package/node_modules/@herbertgao/pi-subagents/package.json +3 -3
  9. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +5 -2
  10. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
  11. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
  13. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
  14. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
  15. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  16. package/node_modules/@narumitw/pi-btw/README.md +24 -17
  17. package/node_modules/@narumitw/pi-btw/package.json +5 -5
  18. package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
  19. package/node_modules/@narumitw/pi-btw/src/menu.ts +33 -13
  20. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  21. package/node_modules/pi-lens/CHANGELOG.md +95 -0
  22. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
  23. package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
  24. package/node_modules/pi-lens/dist/clients/biome-client.js +121 -13
  25. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  26. package/node_modules/pi-lens/dist/clients/bus-publish.js +11 -3
  27. package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
  28. package/node_modules/pi-lens/dist/clients/console-guard-install.js +16 -4
  29. package/node_modules/pi-lens/dist/clients/dead-code-client.js +135 -30
  30. package/node_modules/pi-lens/dist/clients/dependency-checker.js +19 -7
  31. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +6 -4
  32. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  33. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +114 -10
  34. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +101 -16
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -4
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +230 -50
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
  40. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  41. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  42. package/node_modules/pi-lens/dist/clients/extension-log.js +296 -3
  43. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  44. package/node_modules/pi-lens/dist/clients/format-events-publish.js +39 -8
  45. package/node_modules/pi-lens/dist/clients/git-guard.js +18 -21
  46. package/node_modules/pi-lens/dist/clients/go-client.js +21 -39
  47. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +116 -7
  48. package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
  49. package/node_modules/pi-lens/dist/clients/installer/index.js +5 -1
  50. package/node_modules/pi-lens/dist/clients/jscpd-client.js +4 -9
  51. package/node_modules/pi-lens/dist/clients/knip-client.js +51 -14
  52. package/node_modules/pi-lens/dist/clients/latency-logger.js +50 -1
  53. package/node_modules/pi-lens/dist/clients/lens-events.js +56 -25
  54. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  55. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +45 -2
  56. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  57. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +58 -13
  58. package/node_modules/pi-lens/dist/clients/lsp/client.js +169 -8
  59. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +29 -0
  60. package/node_modules/pi-lens/dist/clients/lsp/index.js +264 -66
  61. package/node_modules/pi-lens/dist/clients/lsp/server.js +247 -60
  62. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
  63. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  64. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
  65. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +4 -0
  66. package/node_modules/pi-lens/dist/clients/mcp/session.js +30 -17
  67. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  68. package/node_modules/pi-lens/dist/clients/pipeline.js +29 -2
  69. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
  70. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  71. package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
  72. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  73. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +153 -7
  74. package/node_modules/pi-lens/dist/clients/runtime-context.js +104 -11
  75. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +170 -22
  76. package/node_modules/pi-lens/dist/clients/runtime-session.js +28 -5
  77. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +124 -10
  78. package/node_modules/pi-lens/dist/clients/runtime-turn.js +388 -35
  79. package/node_modules/pi-lens/dist/clients/rust-client.js +21 -37
  80. package/node_modules/pi-lens/dist/clients/security-scan-client.js +88 -5
  81. package/node_modules/pi-lens/dist/clients/sg-runner.js +141 -23
  82. package/node_modules/pi-lens/dist/clients/smells-rollup.js +18 -11
  83. package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
  84. package/node_modules/pi-lens/dist/clients/test-runner-client.js +431 -24
  85. package/node_modules/pi-lens/dist/clients/tool-policy.js +2 -0
  86. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  87. package/node_modules/pi-lens/dist/clients/warm-attach.js +17 -0
  88. package/node_modules/pi-lens/dist/clients/word-index.js +305 -33
  89. package/node_modules/pi-lens/dist/index.js +4827 -1633
  90. package/node_modules/pi-lens/dist/mcp/server.js +8 -2
  91. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  92. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  93. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  94. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +3 -2
  95. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +62 -7
  96. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  97. package/node_modules/pi-lens/docs/agent-guide.md +38 -13
  98. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +11 -3
  99. package/node_modules/pi-lens/docs/features.md +14 -1
  100. package/node_modules/pi-lens/docs/globalconfig.md +11 -0
  101. package/node_modules/pi-lens/docs/servercapabilities.md +1 -1
  102. package/node_modules/pi-lens/docs/settings.md +6 -0
  103. package/node_modules/pi-lens/docs/usage.md +23 -5
  104. package/node_modules/pi-lens/docs/word-index.md +35 -0
  105. package/node_modules/pi-lens/package.json +1 -1
  106. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
  107. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
  108. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  109. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
  110. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
  111. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
  112. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
  113. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  114. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
  115. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  116. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  117. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
  118. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
  119. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
  120. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
  121. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
  122. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +79 -0
  123. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
  124. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  125. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
  126. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
  127. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  128. package/node_modules/pi-mcp-adapter/CHANGELOG.md +13 -0
  129. package/node_modules/pi-mcp-adapter/README.md +4 -1
  130. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  131. package/node_modules/pi-mcp-adapter/cli.js +25 -4
  132. package/node_modules/pi-mcp-adapter/config.ts +4 -4
  133. package/node_modules/pi-mcp-adapter/direct-tools.ts +18 -15
  134. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  135. package/node_modules/pi-mcp-adapter/metadata-cache.ts +18 -8
  136. package/node_modules/pi-mcp-adapter/package.json +2 -1
  137. package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
  138. package/node_modules/pi-mcp-adapter/server-manager.ts +5 -0
  139. package/node_modules/pi-mcp-adapter/tool-metadata.ts +38 -18
  140. package/node_modules/pi-mcp-adapter/types.ts +93 -10
  141. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  142. package/node_modules/pi-web-access/README.md +18 -12
  143. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  144. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  145. package/node_modules/pi-web-access/curator-page.ts +5 -3
  146. package/node_modules/pi-web-access/curator-server.ts +2 -1
  147. package/node_modules/pi-web-access/extract.ts +106 -34
  148. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  149. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  150. package/node_modules/pi-web-access/gemini-search.ts +18 -4
  151. package/node_modules/pi-web-access/index.ts +120 -48
  152. package/node_modules/pi-web-access/package.json +2 -2
  153. package/node_modules/pi-web-access/summary-review.ts +11 -5
  154. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  155. package/package.json +9 -9
@@ -13,7 +13,7 @@ import path from "node:path";
13
13
  import { getGlobalPiLensDir, getProjectIgnoreGlobs, isPathIgnoredByProject, } from "../file-utils.js";
14
14
  import { STAGE_TMP_PATTERN } from "../atomic-write-staging.js";
15
15
  import { DOTNET_CSHARP_ROOT_MARKERS, DOTNET_FSHARP_ROOT_MARKERS, KIND_EXTENSIONS, } from "../file-kinds.js";
16
- import { direntsHaveMarkerGlobMatch, isAtOrAboveHomeDir, isFullyQualified, } from "../path-utils.js";
16
+ import { direntsHaveMarkerGlobMatch, isAtOrAboveHomeDir, isFullyQualified, isWindowsPath, } from "../path-utils.js";
17
17
  import { ensureTool, getToolEnvironment, getToolPath, } from "../installer/index.js";
18
18
  import { resolveOpengrepConfig } from "../opengrep-config.js";
19
19
  import { isZizmorAuditTarget, resolveZizmorGitHubToken } from "../zizmor-config.js";
@@ -78,17 +78,22 @@ const PROJECT_BOUNDARY_MARKERS = [
78
78
  // with FALLBACK_PROJECT_MARKERS, the shared fallback root-policy marker set.
79
79
  const loggedRootCeilingClamps = new Set();
80
80
  function isSameOrWithin(ancestor, candidate) {
81
- const relative = path.relative(path.resolve(ancestor), path.resolve(candidate));
82
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
81
+ const windowsShaped = isWindowsPath(ancestor) || isWindowsPath(candidate);
82
+ const pathApi = windowsShaped ? path.win32 : path;
83
+ const relative = pathApi.relative(pathApi.resolve(ancestor), pathApi.resolve(candidate));
84
+ return (relative === "" ||
85
+ (!relative.startsWith("..") && !pathApi.isAbsolute(relative)));
83
86
  }
84
87
  /** Enforce the session cwd as the hard boundary for LSP client root selection. */
85
88
  export function enforceLspRootCeiling(root, sessionCwd, filePath) {
86
- const resolvedRoot = path.resolve(root);
87
- const resolvedCwd = path.resolve(sessionCwd);
89
+ const windowsShaped = isWindowsPath(root) || isWindowsPath(sessionCwd);
90
+ const pathApi = windowsShaped ? path.win32 : path;
91
+ const resolvedRoot = pathApi.resolve(root);
92
+ const resolvedCwd = pathApi.resolve(sessionCwd);
88
93
  // Callers may explicitly inspect an out-of-session file (notably isolated
89
94
  // tests and API consumers). The cwd ceiling governs roots for files that are
90
95
  // actually inside the declared session project.
91
- if (filePath && !isSameOrWithin(resolvedCwd, path.resolve(filePath))) {
96
+ if (filePath && !isSameOrWithin(resolvedCwd, pathApi.resolve(filePath))) {
92
97
  return resolvedRoot;
93
98
  }
94
99
  if (isSameOrWithin(resolvedCwd, resolvedRoot))
@@ -717,6 +722,12 @@ export function NearestRoot(includePatterns, excludePatterns, stopDir) {
717
722
  // different servers (e.g. TypeScript vs Go) with different marker sets never
718
723
  // share entries. vi.resetModules() in tests resets module state between cases.
719
724
  const cache = new Map();
725
+ // Only cache successful hits. Undefined results are NOT cached so that a
726
+ // newly-created root marker (e.g. package.json or tsconfig.json scaffolded
727
+ // mid-session by the agent) is detected on the next call — the absent →
728
+ // present transition must work without a process restart. The uncached
729
+ // re-walk cost for configless repos is a known trade-off; bounding the
730
+ // walk with stopDir for in-cwd files is the tracked optimization (#1412).
720
731
  const inFlight = new Map();
721
732
  return async (file) => {
722
733
  // Cache key is the resolved directory — all files in the same dir share a root.
@@ -774,9 +785,6 @@ export function NearestRoot(includePatterns, excludePatterns, stopDir) {
774
785
  inFlight.set(dirKey, promise);
775
786
  try {
776
787
  const result = await promise;
777
- // Only cache successful hits. Undefined results are not cached so that
778
- // a newly-created root marker (e.g. package.json added mid-session) is
779
- // detected on the next call.
780
788
  if (result !== undefined)
781
789
  cache.set(dirKey, result);
782
790
  return result;
@@ -829,44 +837,173 @@ export async function tryDotnetToolInstall(tool) {
829
837
  return !updateResult.error && updateResult.status === 0;
830
838
  }
831
839
  /**
832
- * Locate tsserver.js — tries local project, then pi-lens managed TypeScript.
833
- * Returns the path to tsserver.js, or undefined if not found.
840
+ * #1412 M1: walk up from `startDir` (inclusive) looking for a file at
841
+ * `startDir/<relativeSegments>`, `dirname(startDir)/<relativeSegments>`, and so
842
+ * on — the same ancestor walk `findNativeTypeScriptLsp` uses, bounded the same
843
+ * way (`isAtOrAboveHomeDir`). A nested config root (e.g. a `cypress/tsconfig.json`
844
+ * LSP root inside a repo whose `node_modules` only exists at the repo root) must
845
+ * still resolve tooling installed at an ancestor, not just directly under the
846
+ * LSP root — mirrors how node module resolution itself walks up.
834
847
  */
835
- async function findTsserverPath(root, allowInstall) {
848
+ async function findAncestorFile(startDir, ...relativeSegments) {
849
+ return findAncestorFileAmong(startDir, [relativeSegments]);
850
+ }
851
+ /**
852
+ * Same ancestor walk as `findAncestorFile`, but checks every candidate
853
+ * relative-path in `candidateSegmentLists` AT EACH LEVEL before moving up —
854
+ * so the nearest ancestor wins regardless of which candidate name matched
855
+ * there, matching normal node_modules resolution priority (nearest install
856
+ * shadows a further one, never the reverse).
857
+ */
858
+ async function findAncestorFileAmong(startDir, candidateSegmentLists) {
836
859
  const fs = await import("node:fs/promises");
837
- const candidates = [
838
- path.join(root, "node_modules", "typescript", "lib", "tsserver.js"),
839
- path.join(process.cwd(), "node_modules", "typescript", "lib", "tsserver.js"),
860
+ let currentDir = path.resolve(startDir);
861
+ while (!isAtOrAboveHomeDir(currentDir)) {
862
+ for (const segments of candidateSegmentLists) {
863
+ const candidate = path.join(currentDir, ...segments);
864
+ try {
865
+ await fs.access(candidate);
866
+ return candidate;
867
+ }
868
+ catch {
869
+ /* not found at this level */
870
+ }
871
+ }
872
+ const parent = path.dirname(currentDir);
873
+ if (parent === currentDir)
874
+ break;
875
+ currentDir = parent;
876
+ }
877
+ return undefined;
878
+ }
879
+ /**
880
+ * A failed classic-compiler repair must not repeat. `ensureTool` caches
881
+ * successful installs, so a repair that works short-circuits later calls on
882
+ * its own. A repair that fails leaves nothing behind, and `findTsserverPath`
883
+ * has three call sites (TypeScript, Vue, Svelte). Without this guard an
884
+ * offline or partial install re-runs a 120 s forced reinstall on every spawn.
885
+ */
886
+ let classicTsRepairAttempted = false;
887
+ /** Test hook — clears the per-process classic-repair guard. */
888
+ export function _resetClassicTsRepairForTests() {
889
+ classicTsRepairAttempted = false;
890
+ }
891
+ /**
892
+ * Directories that may hold the TypeScript package next to a resolved `tsc`
893
+ * binary: `<bin>/../typescript` (npm-global layout) and
894
+ * `<bin>/../../typescript` (managed `node_modules/.bin` layout).
895
+ */
896
+ function typescriptDirsForTsc(tscPath) {
897
+ const binDir = path.dirname(tscPath);
898
+ return [
899
+ path.join(binDir, "..", "typescript"),
900
+ path.join(binDir, "..", "..", "typescript"),
840
901
  ];
841
- for (const p of candidates) {
902
+ }
903
+ /**
904
+ * Read the major version of the TypeScript package that backs a resolved
905
+ * `tsc` binary. Returns undefined when the version is unknowable: `ensureTool`
906
+ * returns the bare string `"tsc"` for a PATH hit, and `path.dirname("tsc")` is
907
+ * `"."`, so the candidates would go cwd-relative. Callers must not repair on
908
+ * an unknown version — a healthy global TypeScript 5.x would be reinstalled
909
+ * for nothing.
910
+ */
911
+ async function typescriptVersionForTsc(tscPath) {
912
+ if (!path.isAbsolute(tscPath))
913
+ return undefined;
914
+ for (const dir of typescriptDirsForTsc(tscPath)) {
915
+ let manifest;
842
916
  try {
843
- await fs.access(p);
844
- return p;
917
+ manifest = await readFile(path.join(dir, "package.json"), "utf8");
845
918
  }
846
919
  catch {
847
- /* not found */
920
+ continue;
921
+ }
922
+ let version;
923
+ try {
924
+ const parsed = JSON.parse(manifest);
925
+ if (typeof parsed !== "object" ||
926
+ parsed === null ||
927
+ !("version" in parsed) ||
928
+ typeof parsed.version !== "string") {
929
+ return undefined;
930
+ }
931
+ version = parsed.version;
848
932
  }
933
+ catch {
934
+ return undefined;
935
+ }
936
+ const majorText = version.split(".", 1)[0] ?? "";
937
+ const major = /^\d+$/.test(majorText) ? Number(majorText) : Number.NaN;
938
+ if (!Number.isFinite(major))
939
+ return undefined;
940
+ return { version, major };
849
941
  }
850
- // Discover the typescript install (PATH / npm-global) even when install is
851
- // disabled; only the download is gated by allowInstall.
852
- const tscPath = await ensureTool("typescript", {
853
- allowInstall: canInstall(allowInstall),
854
- });
855
- if (tscPath) {
856
- for (const p of [
857
- path.join(path.dirname(tscPath), "..", "typescript", "lib", "tsserver.js"),
858
- path.join(path.dirname(tscPath), "..", "..", "typescript", "lib", "tsserver.js"),
859
- ]) {
942
+ return undefined;
943
+ }
944
+ /**
945
+ * Locate tsserver.js — tries local project (walking up from root, #1412 M1),
946
+ * then process.cwd() as a last-resort fallback, then pi-lens managed
947
+ * TypeScript. Returns the path to tsserver.js, or undefined if not found.
948
+ */
949
+ async function findTsserverPath(root, allowInstall) {
950
+ const fs = await import("node:fs/promises");
951
+ const ancestorHit = await findAncestorFile(root, "node_modules", "typescript", "lib", "tsserver.js");
952
+ if (ancestorHit)
953
+ return ancestorHit;
954
+ const cwdCandidate = path.join(process.cwd(), "node_modules", "typescript", "lib", "tsserver.js");
955
+ try {
956
+ await fs.access(cwdCandidate);
957
+ return cwdCandidate;
958
+ }
959
+ catch {
960
+ /* not found */
961
+ }
962
+ const tsserverForTsc = async (tscPath) => {
963
+ if (!tscPath)
964
+ return undefined;
965
+ for (const dir of typescriptDirsForTsc(tscPath)) {
966
+ const candidate = path.join(dir, "lib", "tsserver.js");
860
967
  try {
861
- await fs.access(p);
862
- return p;
968
+ await fs.access(candidate);
969
+ return candidate;
863
970
  }
864
971
  catch {
865
972
  /* not found */
866
973
  }
867
974
  }
975
+ return undefined;
976
+ };
977
+ // Discover the TypeScript install (PATH / npm-global) even when installation
978
+ // is disabled; only the download is gated by allowInstall.
979
+ const installAllowed = canInstall(allowInstall);
980
+ const discoveredTsc = await ensureTool("typescript", {
981
+ allowInstall: installAllowed,
982
+ });
983
+ const discoveredTsserver = await tsserverForTsc(discoveredTsc);
984
+ if (discoveredTsserver ||
985
+ !discoveredTsc ||
986
+ !installAllowed ||
987
+ classicTsRepairAttempted) {
988
+ return discoveredTsserver;
868
989
  }
869
- return undefined;
990
+ // Repair only a compiler we can prove is TypeScript 7+. TypeScript 7 dropped
991
+ // lib/tsserver.js, so the classic wrapper cannot start against it. Any other
992
+ // version — or a version we cannot read, such as a bare PATH `tsc` — is left
993
+ // alone rather than force-reinstalled.
994
+ const discoveredVersion = await typescriptVersionForTsc(discoveredTsc);
995
+ if (!discoveredVersion || discoveredVersion.major < 7)
996
+ return undefined;
997
+ // An older managed tree took `latest` before the registry pinned the classic
998
+ // compiler. Reinstall the pinned version once so that tree self-heals,
999
+ // without deleting user or project-local TypeScript installations.
1000
+ classicTsRepairAttempted = true;
1001
+ logSessionStart(`lsp typescript: managed compiler resolved to TypeScript ${discoveredVersion.version}, which ships no tsserver.js; reinstalling pinned classic fallback`);
1002
+ const repairedTsc = await ensureTool("typescript", {
1003
+ allowInstall: true,
1004
+ forceReinstall: true,
1005
+ });
1006
+ return tsserverForTsc(repairedTsc);
870
1007
  }
871
1008
  /**
872
1009
  * TypeScript 7+ ships the native typescript-go language server through the
@@ -1046,10 +1183,17 @@ export async function detectPythonVenv(root) {
1046
1183
  }
1047
1184
  // --- Server Definitions ---
1048
1185
  const JS_TS_LSP_EXTENSIONS = KIND_EXTENSIONS["jsts"].filter((ext) => ext !== ".svelte" && ext !== ".vue");
1049
- // Marker set used for both the unbounded TypeScriptProjectRoot walk and the
1050
- // extension-bounded walk below. Kept in one place so both code paths look
1051
- // for the same project signals.
1052
- const TS_PROJECT_MARKERS = [
1186
+ // TypeScript identity and tooling discovery deliberately use separate marker
1187
+ // families. A governing config wins even when a package directory supplies
1188
+ // hoisted binaries. Keep configs out of PROJECT_BOUNDARY_MARKERS: #1373 still
1189
+ // coalesces a config-only nested root when an ancestor client was hosted first
1190
+ // (nested-config-first remains intentionally open-order-sensitive). #1412
1191
+ // accepted risk (M2, not fixed here): honoring nested config roots at all
1192
+ // enlarges the population of directories that can independently coalesce or
1193
+ // diverge under #1373's open-order sensitivity — the same pre-existing
1194
+ // blast-radius, just triggered by more roots than before.
1195
+ const TS_CONFIG_MARKERS = ["tsconfig.json", "jsconfig.json"];
1196
+ const TS_TOOLING_MARKERS = [
1053
1197
  "package-lock.json",
1054
1198
  "bun.lockb",
1055
1199
  "bun.lock",
@@ -1057,21 +1201,71 @@ const TS_PROJECT_MARKERS = [
1057
1201
  "yarn.lock",
1058
1202
  "package.json",
1059
1203
  ];
1060
- const TypeScriptProjectRoot = IgnoreHomeRoot(createRootDetector([...TS_PROJECT_MARKERS]));
1204
+ // #1412 M3: tsserver associates jsconfig.json with JS files only (its identity
1205
+ // probe reports a jsconfig-governed .ts file as unassociated) — so a TS-family
1206
+ // file under a jsconfig-only directory must NOT root there; keep walking up
1207
+ // for a real tsconfig.json. A JS-family file accepts either: tsconfig also
1208
+ // governs plain JS via `allowJs`, so accepting tsconfig for a .js file is
1209
+ // correct, and jsconfig obviously is too.
1210
+ const TS_FAMILY_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
1211
+ function isTsFamilyFile(file) {
1212
+ return TS_FAMILY_EXTENSIONS.has(path.extname(file).toLowerCase());
1213
+ }
1214
+ function tsConfigMarkersForFile(file) {
1215
+ return isTsFamilyFile(file) ? ["tsconfig.json"] : TS_CONFIG_MARKERS;
1216
+ }
1217
+ // Two detector instances (not one parameterized by file) so each keeps its own
1218
+ // per-directory NearestRoot cache valid for its fixed marker set — a shared
1219
+ // cache keyed only by directory would conflate the TS-only and either-config
1220
+ // answers for the same directory.
1221
+ const TypeScriptConfigRootTsOnly = IgnoreHomeRoot(createRootDetector(["tsconfig.json"]));
1222
+ const TypeScriptConfigRootEither = IgnoreHomeRoot(createRootDetector([...TS_CONFIG_MARKERS]));
1223
+ const TypeScriptToolingRoot = IgnoreHomeRoot(createRootDetector([...TS_TOOLING_MARKERS]));
1224
+ async function findTypeScriptProjectRoot(file) {
1225
+ const configDetector = isTsFamilyFile(file)
1226
+ ? TypeScriptConfigRootTsOnly
1227
+ : TypeScriptConfigRootEither;
1228
+ const [configRoot, toolingRoot] = await Promise.all([
1229
+ configDetector(file),
1230
+ TypeScriptToolingRoot(file),
1231
+ ]);
1232
+ if (!configRoot)
1233
+ return toolingRoot;
1234
+ if (!toolingRoot)
1235
+ return configRoot;
1236
+ // A config inside (or beside) the nearest package governs its files. A
1237
+ // config above a nearer package must not erase that topology boundary.
1238
+ return isSameOrWithin(toolingRoot, configRoot) ? configRoot : toolingRoot;
1239
+ }
1061
1240
  /**
1062
- * Walk up from the file's directory looking for a TypeScript project marker,
1063
- * but stop at `extensionRootKey` so we never escape the .pi/agent/extensions
1064
- * boundary into a higher-up project (e.g. ~/.pi/agent/package.json which
1065
- * would pull every extension in the directory into one LSP workspace).
1241
+ * Walk up from the file's directory looking for a TypeScript project marker
1242
+ * (a governing config first, per-directory, per #1412 M3's extension-family
1243
+ * filter; else a tooling/lockfile marker), but stop at `extensionRootKey` so
1244
+ * we never escape the .pi/agent/extensions boundary into a higher-up project
1245
+ * (e.g. ~/.pi/agent/package.json which would pull every extension in the
1246
+ * directory into one LSP workspace).
1066
1247
  *
1067
- * Returns the nearest directory containing a marker, or undefined if none
1068
- * is found between the file and the extensions root inclusive.
1248
+ * #1412 L4: each directory returns immediately on its first match (config or
1249
+ * tooling) — there is no cross-level "nearest tooling root" to carry forward,
1250
+ * since a match always wins on the spot. Returns the nearest directory
1251
+ * containing a marker, or undefined if none is found between the file and the
1252
+ * extensions root inclusive.
1069
1253
  */
1070
1254
  async function findExtensionBoundedRoot(file, extensionRootKey) {
1071
1255
  const startDir = path.resolve(path.dirname(file));
1072
1256
  let currentDir = startDir;
1257
+ const configMarkers = tsConfigMarkersForFile(file);
1073
1258
  while (true) {
1074
- for (const pattern of TS_PROJECT_MARKERS) {
1259
+ for (const pattern of configMarkers) {
1260
+ try {
1261
+ await stat(path.join(currentDir, pattern));
1262
+ return currentDir;
1263
+ }
1264
+ catch {
1265
+ /* not found, try next marker */
1266
+ }
1267
+ }
1268
+ for (const pattern of TS_TOOLING_MARKERS) {
1075
1269
  try {
1076
1270
  await stat(path.join(currentDir, pattern));
1077
1271
  return currentDir;
@@ -1102,7 +1296,7 @@ async function hasAgentLevelProjectMarker(extensionRootKey) {
1102
1296
  const agentDir = path.dirname(extensionRootKey);
1103
1297
  if (!agentDir || agentDir === extensionRootKey)
1104
1298
  return false;
1105
- for (const pattern of TS_PROJECT_MARKERS) {
1299
+ for (const pattern of [...TS_CONFIG_MARKERS, ...TS_TOOLING_MARKERS]) {
1106
1300
  try {
1107
1301
  await stat(path.join(agentDir, pattern));
1108
1302
  return true;
@@ -1133,7 +1327,7 @@ const TypeScriptRoot = DenoExcludeRoot(async (file) => {
1133
1327
  // analyze a lone .ts file with no package.json above or below).
1134
1328
  return undefined;
1135
1329
  }
1136
- const projectRoot = await TypeScriptProjectRoot(file);
1330
+ const projectRoot = await findTypeScriptProjectRoot(file);
1137
1331
  if (projectRoot)
1138
1332
  return projectRoot;
1139
1333
  return FileDirRoot(file);
@@ -1159,20 +1353,13 @@ export const TypeScriptServer = {
1159
1353
  let source = "direct";
1160
1354
  // TypeScript <=6 uses typescript-language-server + tsserver.js. Prefer a
1161
1355
  // project-local wrapper, then fall back to discovered/managed tooling.
1162
- let lspPath;
1163
- const localLsp = path.join(root, "node_modules", ".bin", "typescript-language-server");
1164
- const localLspCmd = path.join(root, "node_modules", ".bin", "typescript-language-server.cmd");
1165
- // Check for local version first (Windows .cmd first, then Unix)
1166
- for (const checkPath of [localLspCmd, localLsp]) {
1167
- try {
1168
- await fs.access(checkPath);
1169
- lspPath = checkPath;
1170
- break;
1171
- }
1172
- catch {
1173
- /* not found */
1174
- }
1175
- }
1356
+ // #1412 M1: walk up from root (Windows .cmd first, then Unix at each
1357
+ // level) — a nested config root's node_modules/.bin lives at an ancestor,
1358
+ // not necessarily directly under the LSP root.
1359
+ let lspPath = await findAncestorFileAmong(root, [
1360
+ ["node_modules", ".bin", "typescript-language-server.cmd"],
1361
+ ["node_modules", ".bin", "typescript-language-server"],
1362
+ ]);
1176
1363
  // Fall back to a discovered or managed install. ensureTool() runs PATH /
1177
1364
  // npm-global discovery even when install is disabled (only the download is
1178
1365
  // gated by canInstall), so a globally-installed typescript-language-server
@@ -32,10 +32,106 @@
32
32
  * `undefined`). The caller must handle `undefined` as "sync path unavailable,
33
33
  * fall back to existing unconfirmed/timed-out behavior".
34
34
  */
35
+ import { logLatency } from "../latency-logger.js";
36
+ import { normalizeMapKey } from "../path-utils.js";
35
37
  // ---------------------------------------------------------------------------
36
38
  // Constants
37
39
  // ---------------------------------------------------------------------------
38
40
  export const TSSERVER_REQUEST_COMMAND = "typescript.tsserverRequest";
41
+ function classifyProjectInfo(body) {
42
+ if (!body || typeof body !== "object") {
43
+ return { projectKind: "unassociated", association: "unassociated" };
44
+ }
45
+ const info = body;
46
+ const configFile = typeof info.configFileName === "string" && info.configFileName.length > 0
47
+ ? info.configFileName
48
+ : undefined;
49
+ const inferred = configFile
50
+ ? /(?:^|[/\\])inferredProject\d*\*?$/i.test(configFile) ||
51
+ /inferred[-_ ]?project/i.test(configFile)
52
+ : false;
53
+ const projectKind = configFile
54
+ ? inferred
55
+ ? "inferred"
56
+ : /(?:^|[/\\])(?:tsconfig|jsconfig)\.json$/i.test(configFile)
57
+ ? "configured"
58
+ : "unassociated"
59
+ : "unassociated";
60
+ return {
61
+ projectKind,
62
+ ...(configFile ? { configFile } : {}),
63
+ association: info.languageServiceDisabled === true
64
+ ? "language-service-disabled"
65
+ : projectKind === "unassociated"
66
+ ? "unassociated"
67
+ : "associated",
68
+ };
69
+ }
70
+ /**
71
+ * #1412: after classic TypeScript's first successful didOpen, sample the
72
+ * tsserver project association without delaying diagnostics. The supplied
73
+ * executeCommand channel owns the existing bounded anti-deadlock backstop.
74
+ */
75
+ export async function probeTsserverProjectIdentity(options) {
76
+ const normalizedFile = options.normalizedFile ?? normalizeMapKey(options.file);
77
+ const startedAt = Date.now();
78
+ // Logging starts only once a probe is actually eligible and attempted:
79
+ // ineligible servers (wrong serverId/launchVariant, no command channel) and
80
+ // already-probed dedupe are both routine, high-volume, and per-server — a
81
+ // bare return keeps them out of the telemetry stream entirely instead of
82
+ // writing an `lsp_typescript_project_identity` row per didOpen on every
83
+ // server (python, go, opengrep, ...).
84
+ const logOutcome = (outcome, metadata = {}) => logLatency({
85
+ type: "phase", phase: "lsp_typescript_project_identity", filePath: normalizedFile,
86
+ durationMs: Date.now() - startedAt,
87
+ metadata: { serverId: options.serverId, launchVariant: options.launchVariant, clientRoot: options.clientRoot, outcome, ...metadata },
88
+ });
89
+ if (options.serverId !== "typescript" ||
90
+ options.launchVariant !== "classic" ||
91
+ typeof options.commandChannel.executeCommand !== "function") {
92
+ return;
93
+ }
94
+ if (options.probedFiles.has(normalizedFile))
95
+ return;
96
+ // Claim before yielding so concurrent opens cannot issue duplicate probes.
97
+ options.probedFiles.add(normalizedFile);
98
+ try {
99
+ const outcome = await options.commandChannel.executeCommand(TSSERVER_REQUEST_COMMAND, ["projectInfo", { file: options.file, needFileNameList: false }]);
100
+ if (!outcome.executed) {
101
+ logOutcome("not-executed");
102
+ return;
103
+ }
104
+ const response = outcome.result;
105
+ if (!response) {
106
+ logOutcome("no-response");
107
+ return;
108
+ }
109
+ if (response.success !== true) {
110
+ logOutcome("unsuccessful");
111
+ return;
112
+ }
113
+ const identity = classifyProjectInfo(response.body);
114
+ logLatency({
115
+ type: "phase",
116
+ phase: "lsp_typescript_project_identity",
117
+ filePath: normalizedFile,
118
+ durationMs: Date.now() - startedAt,
119
+ metadata: {
120
+ outcome: "ok",
121
+ serverId: options.serverId,
122
+ launchVariant: options.launchVariant,
123
+ clientRoot: options.clientRoot,
124
+ projectKind: identity.projectKind,
125
+ configFile: identity.configFile,
126
+ association: identity.association,
127
+ },
128
+ });
129
+ }
130
+ catch {
131
+ logOutcome("threw");
132
+ // Best-effort telemetry: command errors/timeouts never reach diagnostics.
133
+ }
134
+ }
39
135
  // ---------------------------------------------------------------------------
40
136
  // Helpers
41
137
  // ---------------------------------------------------------------------------
@@ -26,7 +26,7 @@ export function classifyServerWaitTier(serverId, snapshot) {
26
26
  return "pull-capable";
27
27
  if (mode !== "push-only")
28
28
  return "waits";
29
- const strategy = getStrategy(serverId);
29
+ const strategy = getStrategy(serverId, snapshot.launchVariant);
30
30
  if (strategy.silentOnClean !== true)
31
31
  return "waits"; // 2*/unknown push-only
32
32
  // #524/#529/#541/#558: `silentOnClean` on a server-id-keyed strategy is
@@ -44,6 +44,24 @@ export function classifyServerWaitTier(serverId, snapshot) {
44
44
  return "waits";
45
45
  return "tier3-silent";
46
46
  }
47
+ /**
48
+ * Resolve `filePath`'s PRIMARY (non-auxiliary) server id and its live
49
+ * capability snapshot, or `undefined` when the file has no primary server
50
+ * configured. The single place that decides what "this file's server" means
51
+ * for wait-policy purposes — shared by {@link classifyCascadeWaitTier} and the
52
+ * cascade-lane wrapper in `clients/lsp/cascade-tier.ts`, which needs the same
53
+ * id/snapshot pair to apply its one cascade-only override.
54
+ */
55
+ export function resolvePrimaryServerForWaitPolicy(filePath, snapshots) {
56
+ const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary");
57
+ const primary = servers[0];
58
+ if (!primary)
59
+ return undefined;
60
+ return {
61
+ serverId: primary.id,
62
+ snapshot: snapshots.find((s) => s.serverId === primary.id),
63
+ };
64
+ }
47
65
  /**
48
66
  * Classify whether `filePath`'s PRIMARY language server is a cascade-lane
49
67
  * Tier-3 (push-only, silent-on-clean) server. Ambiguous or missing capability
@@ -54,10 +72,8 @@ export function classifyServerWaitTier(serverId, snapshot) {
54
72
  */
55
73
  export function classifyCascadeWaitTier(lspService, filePath, snapshots) {
56
74
  void lspService; // kept in the signature for call-site clarity/typing only
57
- const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary");
58
- const primary = servers[0];
75
+ const primary = resolvePrimaryServerForWaitPolicy(filePath, snapshots);
59
76
  if (!primary)
60
77
  return "waits";
61
- const snapshot = snapshots.find((s) => s.serverId === primary.id);
62
- return classifyServerWaitTier(primary.id, snapshot);
78
+ return classifyServerWaitTier(primary.serverId, primary.snapshot);
63
79
  }
@@ -138,7 +138,8 @@ export const SERVER_DIAGNOSTIC_STRATEGIES = {
138
138
  // (#242), so on a 2500ms-capped edit opengrep waits min(2500, 3500)=2500
139
139
  // and on uncapped paths it gets its full 3500. 3500 covers warm and most
140
140
  // cold; a cold scan that overruns isn't lost — late diagnostics are cached
141
- // and surface on the next edit.
141
+ // and surface on the next unchanged-content read through the content-bound
142
+ // auxiliary carry-over path.
142
143
  opengrep: {
143
144
  seedFirstPush: false,
144
145
  pullRetryBudgetMs: 0,
@@ -174,7 +175,8 @@ export const SERVER_DIAGNOSTIC_STRATEGIES = {
174
175
  // reopen-on-resync. A native single-workflow audit is sub-second offline;
175
176
  // online mode may add a GitHub-API round-trip, so 2000ms gives headroom
176
177
  // (bounded by the per-edit caller cap as a ceiling, #242), and any late online
177
- // finding is cached and surfaces on the next edit.
178
+ // finding can surface on the next unchanged-content read through the
179
+ // content-bound auxiliary carry-over path.
178
180
  zizmor: {
179
181
  seedFirstPush: true,
180
182
  pullRetryBudgetMs: 0,
@@ -231,6 +233,16 @@ export const SERVER_DIAGNOSTIC_STRATEGIES = {
231
233
  silentOnClean: true,
232
234
  },
233
235
  };
236
+ /** Native TS7 can publish multiple versionless partial-program snapshots for a
237
+ * single open. Its first push is therefore provisional; a quiet window, rather
238
+ * than a protocol-unstable publication count, establishes the settled push. */
239
+ const NATIVE_TS7_DIAGNOSTIC_STRATEGY = {
240
+ ...SERVER_DIAGNOSTIC_STRATEGIES.typescript,
241
+ seedFirstPush: false,
242
+ // Native TS7 demonstrably publishes on clean, so the classic-only marker
243
+ // must not leak through the shared server id.
244
+ silentOnClean: false,
245
+ };
234
246
  /** Fallback for unknown servers. Conservative defaults. */
235
247
  export const DEFAULT_STRATEGY = {
236
248
  seedFirstPush: false,
@@ -239,6 +251,9 @@ export const DEFAULT_STRATEGY = {
239
251
  aggregateWaitMs: 1500,
240
252
  expectSemanticSecondPush: false,
241
253
  };
242
- export function getStrategy(serverId) {
254
+ export function getStrategy(serverId, launchVariant) {
255
+ if (serverId === "typescript" && launchVariant === "native-ts7") {
256
+ return NATIVE_TS7_DIAGNOSTIC_STRATEGY;
257
+ }
243
258
  return SERVER_DIAGNOSTIC_STRATEGIES[serverId] ?? DEFAULT_STRATEGY;
244
259
  }
@@ -239,6 +239,10 @@ export async function analyzeFile(filePath, cwd, options = {}) {
239
239
  }
240
240
  const reportsBefore = getLatencyReports().length;
241
241
  const start = Date.now();
242
+ // No telemetryModel/telemetryProvider here (#1448): this MCP facade has no
243
+ // RuntimeCoordinator to hold a host-reported identity (see the module doc
244
+ // above), so any worklog entries this dispatch produces get a blank
245
+ // model/provider — that's correct, not a gap.
242
246
  const result = await dispatchLintWithResult(absPath, cwd, host, undefined, undefined, {
243
247
  blockingOnly: options.blockingOnly ?? false,
244
248
  });