@mrclrchtr/supi-code-intelligence 6.0.1 → 6.2.0

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 (147) hide show
  1. package/README.md +2 -2
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +5 -0
  4. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  5. package/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/debug.ts +5 -0
  7. package/node_modules/@mrclrchtr/supi-lsp/README.md +26 -4
  8. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  9. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +5 -0
  10. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  11. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  12. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug.ts +5 -0
  13. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-jsonrpc/package.json +1 -1
  14. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-protocol/lib/common/protocol.colorProvider.d.ts +1 -1
  15. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-protocol/lib/common/protocol.d.ts +2 -2
  16. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-protocol/lib/common/protocol.fileOperations.d.ts +9 -9
  17. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-protocol/package.json +3 -3
  18. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-types/lib/esm/main.d.ts +2 -2
  19. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-types/lib/esm/main.js +1 -1
  20. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-types/lib/umd/main.d.ts +2 -2
  21. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-types/lib/umd/main.js +1 -1
  22. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-types/package.json +1 -1
  23. package/node_modules/@mrclrchtr/supi-lsp/package.json +4 -5
  24. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +14 -7
  25. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-cache.ts +1 -1
  26. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-capabilities.ts +0 -5
  27. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-publication.ts +6 -3
  28. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-refresh.ts +2 -1
  29. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-timing.ts +5 -2
  30. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostics.ts +2 -1
  31. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +23 -5
  32. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +40 -18
  33. package/node_modules/@mrclrchtr/supi-lsp/src/config/config.ts +164 -30
  34. package/node_modules/@mrclrchtr/supi-lsp/src/config/lsp-settings.ts +9 -87
  35. package/node_modules/@mrclrchtr/supi-lsp/src/config/server-config.ts +8 -1
  36. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-scope.ts +4 -5
  37. package/node_modules/@mrclrchtr/supi-lsp/src/config/types.ts +1 -29
  38. package/node_modules/@mrclrchtr/supi-lsp/src/debug-telemetry.ts +7 -21
  39. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-severity.ts +6 -0
  40. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-summary.ts +11 -14
  41. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/evidence.ts +0 -5
  42. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/stale-diagnostics.ts +0 -1
  43. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sentinels.ts +29 -49
  44. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-client-state.ts +1 -1
  45. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +4 -8
  46. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-helpers.ts +1 -9
  47. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-project-info.ts +12 -6
  48. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +9 -1
  49. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-symbol.ts +26 -41
  50. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +849 -117
  51. package/node_modules/@mrclrchtr/supi-lsp/src/provider/lsp-semantic-provider.ts +6 -4
  52. package/node_modules/@mrclrchtr/supi-lsp/src/provider/semantic-symbol-mapper.ts +59 -28
  53. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-controller.ts +27 -19
  54. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostic-surface.ts +10 -0
  55. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +26 -0
  56. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registration.ts +0 -17
  57. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +118 -27
  58. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-transition-debug.ts +7 -2
  59. package/node_modules/@mrclrchtr/supi-lsp/src/session/scanner.ts +24 -21
  60. package/node_modules/@mrclrchtr/supi-lsp/src/session/workspace-lsp-runtime.ts +33 -5
  61. package/node_modules/@mrclrchtr/supi-lsp/src/summary.ts +19 -40
  62. package/node_modules/@mrclrchtr/supi-lsp/src/utils.ts +13 -19
  63. package/node_modules/@mrclrchtr/supi-lsp/src/workspace-path-policy.ts +287 -0
  64. package/node_modules/@mrclrchtr/supi-tree-sitter/README.md +6 -18
  65. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  66. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +5 -0
  67. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  68. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  69. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug.ts +5 -0
  70. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +3 -5
  71. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/kotlin/tree-sitter-kotlin.wasm.json +1 -1
  72. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/sql/tree-sitter-sql.wasm.json +1 -1
  73. package/node_modules/@mrclrchtr/supi-tree-sitter/src/api.ts +1 -11
  74. package/node_modules/@mrclrchtr/supi-tree-sitter/src/coordinates.ts +19 -0
  75. package/node_modules/@mrclrchtr/supi-tree-sitter/src/language.ts +5 -23
  76. package/node_modules/@mrclrchtr/supi-tree-sitter/src/operation-support.ts +3 -21
  77. package/node_modules/@mrclrchtr/supi-tree-sitter/src/provider/tree-sitter-provider.ts +5 -9
  78. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/runtime-controller.ts +10 -20
  79. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/session.ts +7 -1
  80. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-client.ts +9 -5
  81. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-protocol.ts +134 -9
  82. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/call-name.ts +96 -0
  83. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/call-sites.ts +11 -12
  84. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/callees.ts +39 -56
  85. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/exports.ts +9 -7
  86. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/js-binding-pattern.ts +26 -0
  87. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/node-at.ts +2 -21
  88. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/outline-html-sql.ts +144 -12
  89. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/outline.ts +50 -37
  90. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/scope.ts +138 -0
  91. package/node_modules/@mrclrchtr/supi-tree-sitter/src/types.ts +0 -7
  92. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/parsed-file-store.ts +1 -14
  93. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/runtime.ts +0 -10
  94. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/service.ts +6 -8
  95. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/worker-main.ts +34 -10
  96. package/package.json +5 -5
  97. package/src/analysis/capability/capability-warnings.ts +4 -22
  98. package/src/analysis/health/recovery.ts +4 -0
  99. package/src/analysis/health/server-status.ts +64 -0
  100. package/src/analysis/health/signals.ts +1 -0
  101. package/src/analysis/provider.ts +2 -1
  102. package/src/analysis/search/ast-scan-timing.ts +4 -2
  103. package/src/analysis/search/ast-scan.ts +1 -1
  104. package/src/analysis/target/symbol.ts +28 -16
  105. package/src/analysis/target/types.ts +1 -0
  106. package/src/api.ts +1 -0
  107. package/src/extension.ts +4 -2
  108. package/src/session/find-workflow.ts +5 -12
  109. package/src/session/health-refresh.ts +30 -4
  110. package/src/session/health-types.ts +27 -2
  111. package/src/session/health-workflow.ts +5 -1
  112. package/src/session/input/health-refactor.ts +16 -14
  113. package/src/session/refactor-types.ts +14 -1
  114. package/src/session/refactor-workflow.ts +21 -16
  115. package/src/session/semantic-demand.ts +36 -0
  116. package/src/session/target-workflow.ts +9 -12
  117. package/src/substrate/lsp/lifecycle.ts +0 -3
  118. package/src/substrate/lsp/maintenance.ts +4 -5
  119. package/src/substrate/lsp/settings.ts +6 -9
  120. package/src/substrate/lsp/state.ts +0 -2
  121. package/src/substrate/workspace-provider-host.ts +2 -2
  122. package/src/tool/code_health/file-scope-markdown.ts +43 -0
  123. package/src/tool/code_health/guidance.ts +2 -2
  124. package/src/tool/code_health/markdown.ts +81 -69
  125. package/src/tool/code_health/refresh-outcome.ts +46 -0
  126. package/src/tool/code_health/refresh-status.ts +166 -23
  127. package/src/tool/code_health/result.ts +15 -5
  128. package/src/tool/code_health/spec.ts +2 -1
  129. package/src/tool/code_health/tui.ts +42 -8
  130. package/src/tool/code_refactor_apply/guidance.ts +3 -2
  131. package/src/tool/code_refactor_plan/guidance.ts +1 -1
  132. package/src/tool/register.ts +5 -2
  133. package/src/tool/result/errors.ts +1 -0
  134. package/src/tool/result/types.ts +6 -0
  135. package/src/tool/schemas.ts +21 -11
  136. package/src/ui/footer.ts +7 -5
  137. package/src/ui/status-command.ts +31 -0
  138. package/src/ui/status-overlay.ts +69 -23
  139. package/node_modules/@mrclrchtr/supi-lsp/src/config/server-actions.ts +0 -59
  140. package/node_modules/@mrclrchtr/supi-lsp/src/pattern-matcher.ts +0 -24
  141. package/node_modules/@mrclrchtr/supi-tree-sitter/scripts/generate-kotlin-wasm.mjs +0 -132
  142. package/node_modules/@mrclrchtr/supi-tree-sitter/scripts/generate-sql-wasm.mjs +0 -158
  143. package/node_modules/@mrclrchtr/supi-tree-sitter/scripts/vendor-wasm.mjs +0 -167
  144. package/node_modules/@mrclrchtr/supi-tree-sitter/src/index.ts +0 -42
  145. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/runtime-registration.ts +0 -37
  146. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/service-registry.ts +0 -30
  147. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/structure.ts +0 -8
@@ -21,18 +21,31 @@ import type { ClientDiagnosticSnapshot } from "../client/client-document-state.t
21
21
 
22
22
  export { RECOVERY_CLIENT_STARTUP_BOUND_MS } from "../client/client.ts";
23
23
 
24
+ import {
25
+ recordDebugEvent,
26
+ truncateDebugIdentity as truncateIdentity,
27
+ } from "@mrclrchtr/supi-core/debug";
28
+ import { resolveToolPath as resolveSessionPath, uriToFile } from "@mrclrchtr/supi-core/path";
24
29
  import { getServerForFile } from "../config/config.ts";
25
- import { getFileScopeDecision } from "../config/tsconfig-scope.ts";
30
+ import {
31
+ getFileScopeDecision,
32
+ invalidateTsconfigCacheForConfig,
33
+ invalidateTsconfigCacheForConfigDir,
34
+ isProjectConfigFileName,
35
+ } from "../config/tsconfig-scope.ts";
26
36
  import type {
27
37
  DetectedProjectServer,
28
38
  Diagnostic,
29
39
  FileEvent,
30
40
  LspConfig,
31
41
  ProjectServerInfo,
42
+ ProjectServerStatusReason,
32
43
  ServerConfig,
33
44
  SymbolInformation,
34
45
  WorkspaceSymbol,
35
46
  } from "../config/types.ts";
47
+ import { FileChangeType } from "../config/types.ts";
48
+ import { boundCwd } from "../debug-telemetry.ts";
36
49
  import {
37
50
  accumulateOutstandingDiagnostics,
38
51
  collectDiagnosticSummaryCounts,
@@ -44,11 +57,19 @@ import {
44
57
  type DiagnosticEvidenceSummary,
45
58
  summarizeDiagnosticEvidence,
46
59
  } from "../diagnostics/evidence.ts";
60
+ import {
61
+ scanWorkspaceSentinels as scanAutomaticWorkspaceSentinels,
62
+ syncWorkspaceSentinelSnapshot as syncAutomaticWorkspaceSentinelSnapshot,
63
+ type WorkspaceSentinelScanOptions,
64
+ type WorkspaceSentinelSyncResult,
65
+ } from "../diagnostics/workspace-sentinels.ts";
47
66
  import { raceRequestControl } from "../session/readiness.ts";
48
- import type {
49
- ScopeDecisionEntry,
50
- ScopeDecisionSummary,
51
- WorkspaceDiagnosticReport,
67
+ import {
68
+ emptyProcessCrashRecoverySummary,
69
+ type ProcessCrashRecoverySummary,
70
+ type ScopeDecisionEntry,
71
+ type ScopeDecisionSummary,
72
+ type WorkspaceDiagnosticReport,
52
73
  } from "../session/runtime-diagnostics.ts";
53
74
  import {
54
75
  displayRelativeFilePath,
@@ -58,7 +79,11 @@ import {
58
79
  normalizeRelevantPaths,
59
80
  shouldIgnoreLspPath,
60
81
  } from "../summary.ts";
61
- import { commandExists, resolveSessionPath, uriToFile } from "../utils.ts";
82
+ import { commandExists } from "../utils.ts";
83
+ import {
84
+ type AutomaticLspPathPolicy,
85
+ createDefaultAutomaticLspPathPolicy,
86
+ } from "../workspace-path-policy.ts";
62
87
  import {
63
88
  closeFileAcrossClients,
64
89
  pruneMissingFilesFromClients,
@@ -68,12 +93,7 @@ import {
68
93
  collectOutstandingDiagnosticsDetailed,
69
94
  syncClientFileAndGetDiagnostics,
70
95
  } from "./manager-diagnostics.ts";
71
- import {
72
- clientKey,
73
- isExcludedByPattern,
74
- rememberKnownRoot,
75
- resolveRootForFile,
76
- } from "./manager-helpers.ts";
96
+ import { clientKey, rememberKnownRoot, resolveRootForFile } from "./manager-helpers.ts";
77
97
  import { buildProjectServerInfo } from "./manager-project-info.ts";
78
98
  import type {
79
99
  ActiveCoverageSummaryEntry,
@@ -151,6 +171,66 @@ export interface ManagerLifecycleTransition {
151
171
 
152
172
  type ManagerLifecycleListener = (transition: ManagerLifecycleTransition) => void;
153
173
 
174
+ type FileClientRequestOptions = {
175
+ /** Allow this request to start the route's one process-crash recovery. */
176
+ recoverProcessCrash?: boolean;
177
+ /** Control only this caller's wait for a shared replacement. */
178
+ control?: CodeRequestControl;
179
+ };
180
+
181
+ interface FileReadinessResult {
182
+ client: LspClient | null;
183
+ processCrashRecovery: ProcessCrashRecoverySummary;
184
+ }
185
+
186
+ interface ProcessCrashRecoveryState {
187
+ /** The one automatic attempt is consumed when a replacement starts. */
188
+ attemptConsumed: boolean;
189
+ /** Current user-facing reason, or undefined after successful recovery. */
190
+ statusReason: ProjectServerStatusReason | undefined;
191
+ /** The failed generation whose tracked files must be restored. */
192
+ failedClient: LspClient;
193
+ /** Files tracked by the failed route at the time of the crash. */
194
+ files: string[];
195
+ /** Shared replacement startup and document-restoration operation. */
196
+ pending: Promise<LspClient | null> | null;
197
+ /** Start time for the current replacement attempt, when one exists. */
198
+ attemptStartedAt?: number;
199
+ }
200
+
201
+ type ProcessCrashRecoveryOutcome = "attempt" | "success" | "failure" | "exhausted" | "cancelled";
202
+
203
+ type ProcessCrashDemandResult = {
204
+ /** Whether at least one required crashed route supports the operation. */
205
+ hasSupport: boolean;
206
+ /** Separate route-level result for this explicit process-crash demand. */
207
+ processCrashRecovery: ProcessCrashRecoverySummary;
208
+ /** Required routes that remain unavailable after shared recovery settles. */
209
+ failures: string[];
210
+ /** In-scope tracked files from required routes that remain unavailable. */
211
+ failedFiles: string[];
212
+ };
213
+
214
+ type RequiredProcessCrashDemand = {
215
+ key: string;
216
+ recovery: ProcessCrashRecoveryState;
217
+ failedFiles: readonly string[];
218
+ };
219
+
220
+ type ProcessCrashRecoveryRouteOutcome = "pending" | "recovered" | "failed";
221
+
222
+ function summarizeProcessCrashRecoveryRoutes(
223
+ outcomes: Iterable<ProcessCrashRecoveryRouteOutcome>,
224
+ ): ProcessCrashRecoverySummary {
225
+ const summary = emptyProcessCrashRecoverySummary();
226
+ for (const outcome of outcomes) {
227
+ summary.attemptedRoutes++;
228
+ if (outcome === "recovered") summary.recoveredRoutes++;
229
+ if (outcome === "failed") summary.failedRoutes++;
230
+ }
231
+ return summary;
232
+ }
233
+
154
234
  // ── LspManager ────────────────────────────────────────────────────────
155
235
  export class LspManager {
156
236
  /** Active clients keyed by "serverName:root" */
@@ -159,14 +239,14 @@ export class LspManager {
159
239
  private unavailable = new Map<string, UnavailableReason>();
160
240
  /** Memoized per-command availability of LSP server binaries on PATH */
161
241
  private commandAvailability = new Map<string, boolean>();
162
- /** Guards against concurrent client creation for the same server:root key */
242
+ /** Guards against concurrent client creation for the same server:root key. */
163
243
  private pendingStarts = new Map<string, Promise<LspClient | null>>();
244
+ /** Shared diagnostic-recovery restart for each replaceable client route. */
245
+ private pendingRestarts = new Map<string, Promise<{ files: string[]; restarted: boolean }>>();
164
246
  /** Monotonic ownership generation for each replaceable client route. */
165
247
  private clientGenerations = new Map<string, number>();
166
248
  /** Preferred project roots discovered by proactive scan or lazy startup */
167
249
  private knownRoots = new Map<string, string[]>();
168
- /** User-configured gitignore-style exclude patterns */
169
- private excludePatterns: string[] = [];
170
250
  /** Monotonic workspace invalidation generation advanced by each invalidation event. */
171
251
  private invalidationEpoch = 0;
172
252
  /** Invalidation generation in which each route last received a recovery restart. */
@@ -177,21 +257,31 @@ export class LspManager {
177
257
  private warmedSemanticProjects = new Set<string>();
178
258
  /** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
179
259
  private pendingWarmProbes = new Map<string, Promise<void>>();
260
+ /** One process-crash recovery budget and shared replacement per route. */
261
+ private processCrashRecoveries = new Map<string, ProcessCrashRecoveryState>();
262
+ /** Client generations that completed the initialize handshake. */
263
+ private initializedClientGenerations = new Map<string, number>();
264
+ /** Client generations whose process-failure fact was already handled. */
265
+ private handledCrashGenerations = new Map<string, number>();
266
+ /** Prevent late startup and lifecycle callbacks from republishing after shutdown. */
267
+ private shuttingDown = false;
180
268
  constructor(
181
269
  private readonly config: LspConfig,
182
270
  private readonly cwd: string,
183
271
  private readonly onLifecycleTransition?: ManagerLifecycleListener,
272
+ private readonly automaticPathPolicy: AutomaticLspPathPolicy = createDefaultAutomaticLspPathPolicy(
273
+ cwd,
274
+ ),
184
275
  ) {}
185
276
  getCwd(): string {
186
277
  return this.cwd;
187
278
  }
188
- setExcludePatterns(patterns: string[]): void {
189
- this.excludePatterns = patterns;
190
- }
191
279
 
192
280
  // ── Public API ────────────────────────────────────────────────────
193
281
  registerDetectedServers(detected: DetectedProjectServer[]): void {
194
- this.knownRoots = projectRoots.buildKnownRootsMap(detected);
282
+ this.knownRoots = projectRoots.buildKnownRootsMap(
283
+ detected.filter((entry) => this.automaticPathPolicy.isEligible(entry.root, "directory")),
284
+ );
195
285
  }
196
286
  /** Check whether a file path has an available LSP server for explicit semantic operations. */
197
287
  canServeFile(filePath: string): boolean {
@@ -202,18 +292,56 @@ export class LspManager {
202
292
  }
203
293
 
204
294
  /**
205
- * Check whether a file should participate in runtime guidance and diagnostics.
206
- * This is stricter than {@link canServeFile} and intentionally filters dependency
207
- * and tsconfig-excluded paths from UI/context behavior.
295
+ * Return the consumed process-crash outcome for a file readiness result.
296
+ * The result supports unavailable and non-resolved waits; it is null before
297
+ * an attempt is consumed and after successful recovery, so old success is
298
+ * not reported as a new outcome. This is a passive read and never starts
299
+ * recovery.
300
+ */
301
+ getProcessCrashRecoverySummaryForFile(filePath: string): ProcessCrashRecoverySummary | null {
302
+ const route = this.resolveFileRoute(resolveSessionPath(this.cwd, filePath));
303
+ const recovery = route ? this.processCrashRecoveries.get(route.key) : undefined;
304
+ if (!recovery?.attemptConsumed || recovery.statusReason === undefined) return null;
305
+
306
+ const outcome: ProcessCrashRecoveryRouteOutcome =
307
+ recovery.statusReason === "process-crash-recovery-exhausted" ? "failed" : "pending";
308
+ return summarizeProcessCrashRecoveryRoutes([outcome]);
309
+ }
310
+
311
+ /**
312
+ * Check whether a file should participate in automatic LSP work.
313
+ * This is stricter than {@link canServeFile} and applies the workspace path
314
+ * policy plus tsconfig diagnostic suppression.
208
315
  */
209
316
  isSupportedSourceFile(filePath: string): boolean {
210
- // Dependency directories are intentionally excluded from recent-path
211
- // tracking and diagnostic summaries (shouldIgnoreLspPath). Keep runtime
212
- // guidance activation consistent: reading or editing a file under
213
- // node_modules / .pnpm must not arm LSP guidance for dependency sources.
214
- if (shouldIgnoreLspPath(filePath, this.cwd)) return false;
317
+ if (!this.isAutomaticScopePath(filePath)) return false;
215
318
  return this.canServeFile(filePath);
216
319
  }
320
+
321
+ /** Inventory automatic workspace sentinel and source paths with the runtime policy. */
322
+ scanWorkspaceSentinels(options: WorkspaceSentinelScanOptions = {}): Map<string, number> {
323
+ return scanAutomaticWorkspaceSentinels(this.cwd, {
324
+ ...options,
325
+ policy: this.automaticPathPolicy,
326
+ });
327
+ }
328
+
329
+ /** Refresh the automatic workspace inventory with the runtime policy. */
330
+ syncWorkspaceSentinelSnapshot(
331
+ previous: Map<string, number>,
332
+ options: WorkspaceSentinelScanOptions = {},
333
+ ): WorkspaceSentinelSyncResult {
334
+ return syncAutomaticWorkspaceSentinelSnapshot(this.cwd, previous, {
335
+ ...options,
336
+ policy: this.automaticPathPolicy,
337
+ });
338
+ }
339
+
340
+ /** Apply automatic path exclusions plus diagnostic tsconfig suppression. */
341
+ private isAutomaticScopePath(filePath: string): boolean {
342
+ return !shouldIgnoreLspPath(filePath, this.cwd, this.automaticPathPolicy);
343
+ }
344
+
217
345
  private isServerCommandAvailable(command: string): boolean {
218
346
  // Only memoize positive lookups. A negative result may become stale if the
219
347
  // user installs the binary mid-session (e.g. `mise install`), and
@@ -247,11 +375,27 @@ export class LspManager {
247
375
  }
248
376
 
249
377
  /** Get or create an LSP client for the given file. */
250
- async getClientForFile(filePath: string): Promise<LspClient | null> {
378
+ async getClientForFile(
379
+ filePath: string,
380
+ options: FileClientRequestOptions = {},
381
+ ): Promise<LspClient | null> {
382
+ throwIfCodeRequestInterrupted(options.control);
383
+ if (this.shuttingDown) return null;
251
384
  const route = this.resolveFileRoute(filePath);
252
- return route ? this.startServerForRoot(route.serverName, route.root) : null;
385
+ if (!route) return null;
386
+
387
+ const recovery = this.processCrashRecoveries.get(route.key);
388
+ if (recovery && (recovery.pending || recovery.statusReason)) {
389
+ if (!options.recoverProcessCrash) return null;
390
+ return this.acquireProcessCrashReplacement(recovery, route, options.control);
391
+ }
392
+ // A recovered route keeps its consumed budget but has no active reason.
393
+ // Passive callers can use its running client normally.
394
+
395
+ return this.startServerForRoot(route.serverName, route.root);
253
396
  }
254
397
  async startServerForRoot(serverName: string, root: string): Promise<LspClient | null> {
398
+ if (this.shuttingDown) return null;
255
399
  const serverConfig = this.config.servers[serverName];
256
400
  if (!serverConfig) return null;
257
401
  const key = clientKey(serverName, root);
@@ -261,6 +405,12 @@ export class LspManager {
261
405
  const existing = this.clients.get(key);
262
406
  if (existing && existing.status === "running") return existing;
263
407
 
408
+ // A crashed route is retained until evidence demand starts its shared
409
+ // replacement. Proactive starts must not consume that budget.
410
+ if (existing && existing.status === "error" && this.processCrashRecoveries.has(key)) {
411
+ return null;
412
+ }
413
+
264
414
  // If existing client errored, remove it
265
415
  if (existing && existing.status === "error") {
266
416
  this.clients.delete(key);
@@ -315,13 +465,353 @@ export class LspManager {
315
465
  client: LspClient,
316
466
  kind: LspClientLifecycleTransitionKind,
317
467
  ): void {
468
+ if (this.shuttingDown) return;
318
469
  if (this.clientGenerations.get(key) !== generation) return;
319
470
  if (this.clients.get(key) !== client) return;
471
+
472
+ if (kind === "startup") {
473
+ this.initializedClientGenerations.set(key, generation);
474
+ }
475
+ if (kind === "crash") {
476
+ if (this.handledCrashGenerations.get(key) === generation) return;
477
+ this.handledCrashGenerations.set(key, generation);
478
+ if (this.initializedClientGenerations.get(key) === generation) {
479
+ this.handleInitializedClientCrash(key, client);
480
+ }
481
+ }
482
+
320
483
  const aggregateKind =
321
484
  kind === "readiness" && generation > 1 && client.ready ? "recovery" : kind;
322
485
  this.publishLifecycle(aggregateKind);
323
486
  }
324
487
 
488
+ private handleInitializedClientCrash(key: string, client: LspClient): void {
489
+ const existing = this.processCrashRecoveries.get(key);
490
+ if (existing?.attemptConsumed) {
491
+ existing.failedClient = client;
492
+ existing.files = this.trackedFilesForClient(client);
493
+ this.unavailable.set(key, "runtime-error");
494
+ if (existing.statusReason !== "process-crash-recovery-exhausted") {
495
+ existing.statusReason = "process-crash-recovery-exhausted";
496
+ this.recordProcessCrashRecoveryEvent(
497
+ client.name,
498
+ client.root,
499
+ "exhausted",
500
+ existing.pending ? existing.attemptStartedAt : Date.now(),
501
+ );
502
+ }
503
+ return;
504
+ }
505
+
506
+ this.processCrashRecoveries.set(key, {
507
+ attemptConsumed: false,
508
+ statusReason: "process-crashed",
509
+ failedClient: client,
510
+ files: this.trackedFilesForClient(client),
511
+ pending: null,
512
+ });
513
+ }
514
+
515
+ private trackedFilesForClient(client: LspClient): string[] {
516
+ const files = new Set(client.openFiles);
517
+ for (const document of client.getDiagnosticSnapshot().documents) {
518
+ files.add(uriToFile(document.uri));
519
+ }
520
+ return Array.from(files);
521
+ }
522
+
523
+ private waitForProcessCrashRecovery(
524
+ recovery: ProcessCrashRecoveryState,
525
+ control?: CodeRequestControl,
526
+ ): Promise<LspClient | null> {
527
+ if (!recovery.pending) return Promise.resolve(null);
528
+ return raceRequestControl(recovery.pending, control);
529
+ }
530
+
531
+ private acquireProcessCrashReplacement(
532
+ recovery: ProcessCrashRecoveryState,
533
+ route: FileRoute,
534
+ control?: CodeRequestControl,
535
+ ): Promise<LspClient | null> {
536
+ if (recovery.pending) return this.waitForProcessCrashRecovery(recovery, control);
537
+ if (
538
+ recovery.statusReason === "process-crashed" &&
539
+ !recovery.attemptConsumed &&
540
+ !this.shuttingDown
541
+ ) {
542
+ return this.startProcessCrashRecovery(recovery, route, control);
543
+ }
544
+ return Promise.resolve(null);
545
+ }
546
+
547
+ /**
548
+ * Recover all crashed routes selected by one evidence-producing operation.
549
+ * Each route keeps its independent attempt budget and shared replacement.
550
+ */
551
+ private async recoverProcessCrashDemand(
552
+ selectFiles: (recovery: ProcessCrashRecoveryState) => readonly string[] | null,
553
+ control?: CodeRequestControl,
554
+ ): Promise<ProcessCrashDemandResult> {
555
+ const required = Array.from(this.processCrashRecoveries.entries()).flatMap(
556
+ ([key, recovery]): RequiredProcessCrashDemand[] => {
557
+ if (recovery.statusReason === undefined) return [];
558
+ const failedFiles = selectFiles(recovery);
559
+ return failedFiles === null ? [] : [{ key, recovery, failedFiles }];
560
+ },
561
+ );
562
+ if (required.length === 0) {
563
+ return {
564
+ hasSupport: false,
565
+ processCrashRecovery: emptyProcessCrashRecoverySummary(),
566
+ failures: [],
567
+ failedFiles: [],
568
+ };
569
+ }
570
+
571
+ await Promise.all(
572
+ required.map(async ({ key, recovery }) => {
573
+ const serverConfig = this.config.servers[recovery.failedClient.name];
574
+ if (!serverConfig) return;
575
+ await this.acquireProcessCrashReplacement(
576
+ recovery,
577
+ {
578
+ serverName: recovery.failedClient.name,
579
+ serverConfig,
580
+ root: recovery.failedClient.root,
581
+ key,
582
+ },
583
+ control,
584
+ );
585
+ }),
586
+ );
587
+
588
+ const failedDemands = required.filter(({ recovery }) => recovery.statusReason !== undefined);
589
+ const outcomes = required.map(
590
+ ({ recovery }): ProcessCrashRecoveryRouteOutcome =>
591
+ recovery.statusReason === undefined ? "recovered" : "failed",
592
+ );
593
+ return {
594
+ hasSupport: true,
595
+ processCrashRecovery: summarizeProcessCrashRecoveryRoutes(outcomes),
596
+ failures: failedDemands.map(({ recovery }) => this.formatProcessCrashDemandFailure(recovery)),
597
+ failedFiles: Array.from(new Set(failedDemands.flatMap(({ failedFiles }) => failedFiles))),
598
+ };
599
+ }
600
+
601
+ private formatProcessCrashDemandFailure(recovery: ProcessCrashRecoveryState): string {
602
+ const root = (path.relative(this.cwd, recovery.failedClient.root) || ".").replaceAll("\\", "/");
603
+ const reason =
604
+ recovery.statusReason === "process-crash-recovery-exhausted"
605
+ ? "process recovery exhausted; reload required"
606
+ : recovery.statusReason === "process-crash-recovery-pending"
607
+ ? "process recovery in progress"
608
+ : "process crashed";
609
+ return `${recovery.failedClient.name} @ ${root} is unavailable — ${reason}.`;
610
+ }
611
+
612
+ private startProcessCrashRecovery(
613
+ recovery: ProcessCrashRecoveryState,
614
+ route: FileRoute,
615
+ control?: CodeRequestControl,
616
+ ): Promise<LspClient | null> {
617
+ throwIfCodeRequestInterrupted(control);
618
+ recovery.attemptConsumed = true;
619
+ recovery.statusReason = "process-crash-recovery-pending";
620
+ recovery.attemptStartedAt = Date.now();
621
+ const pending = this.performProcessCrashRecovery(recovery, route);
622
+ recovery.pending = pending;
623
+ // A cancelled caller may be the only current waiter. Keep the shared
624
+ // replacement promise observed until it settles without adopting that
625
+ // caller's cancellation or deadline.
626
+ pending.catch(() => {});
627
+ this.recordProcessCrashRecoveryEvent(
628
+ route.serverName,
629
+ route.root,
630
+ "attempt",
631
+ recovery.attemptStartedAt,
632
+ );
633
+ this.publishLifecycle("recovery");
634
+ return raceRequestControl(pending, control);
635
+ }
636
+
637
+ private async performProcessCrashRecovery(
638
+ recovery: ProcessCrashRecoveryState,
639
+ route: FileRoute,
640
+ ): Promise<LspClient | null> {
641
+ const key = route.key;
642
+ const attemptStartedAt = recovery.attemptStartedAt;
643
+
644
+ try {
645
+ // A process-error event can leave the child alive. Stop the old tree
646
+ // before installing its replacement, but retain its captured evidence.
647
+ await recovery.failedClient.forceKill().catch(() => {});
648
+ if (this.isProcessCrashRecoveryCancelled(recovery, key)) {
649
+ this.recordProcessCrashRecoveryEvent(
650
+ route.serverName,
651
+ route.root,
652
+ "cancelled",
653
+ attemptStartedAt,
654
+ );
655
+ return null;
656
+ }
657
+
658
+ const replacement = this.installProcessCrashReplacement(recovery, route);
659
+ const started = await this.startProcessCrashReplacement(recovery, route, replacement);
660
+ if (!started) return null;
661
+
662
+ // A replacement can crash after initialize and before this operation
663
+ // resumes. Its lifecycle callback has already exhausted the route.
664
+ if (
665
+ started.status !== "running" ||
666
+ recovery.statusReason === "process-crash-recovery-exhausted"
667
+ ) {
668
+ return null;
669
+ }
670
+
671
+ this.unavailable.delete(key);
672
+ recovery.statusReason = undefined;
673
+ this.publishLifecycle("recovery");
674
+ this.restoreTrackedDocuments(started, recovery.files);
675
+ this.recordProcessCrashRecoveryEvent(
676
+ route.serverName,
677
+ route.root,
678
+ "success",
679
+ attemptStartedAt,
680
+ );
681
+ return started;
682
+ } finally {
683
+ if (this.processCrashRecoveries.get(key) === recovery) recovery.pending = null;
684
+ }
685
+ }
686
+
687
+ private isProcessCrashRecoveryCancelled(
688
+ recovery: ProcessCrashRecoveryState,
689
+ key: string,
690
+ ): boolean {
691
+ return this.shuttingDown || this.processCrashRecoveries.get(key) !== recovery;
692
+ }
693
+
694
+ private installProcessCrashReplacement(
695
+ recovery: ProcessCrashRecoveryState,
696
+ route: FileRoute,
697
+ ): LspClient {
698
+ const key = route.key;
699
+ if (this.clients.get(key) === recovery.failedClient) this.clients.delete(key);
700
+ this.clearWarmedWorkspaceSymbolProjects(route.serverName, route.root);
701
+ this.clearWarmedSemanticProjects(route.serverName, route.root);
702
+ this.clearPendingWarmProbes(route.serverName, route.root);
703
+
704
+ const replacement = this.createClient(route.serverName, route.serverConfig, route.root, key);
705
+ this.clients.set(key, replacement);
706
+ rememberKnownRoot(this.knownRoots, route.serverName, route.root);
707
+ return replacement;
708
+ }
709
+
710
+ private async startProcessCrashReplacement(
711
+ recovery: ProcessCrashRecoveryState,
712
+ route: FileRoute,
713
+ replacement: LspClient,
714
+ ): Promise<LspClient | null> {
715
+ try {
716
+ // LspClient's initialize request already has the 30-second transport
717
+ // bound. Do not apply the 5-second diagnostic-restart bound here.
718
+ await replacement.start();
719
+ } catch {
720
+ if (this.isProcessCrashRecoveryCancelled(recovery, route.key)) {
721
+ await replacement.forceKill().catch(() => {});
722
+ this.recordProcessCrashRecoveryEvent(
723
+ route.serverName,
724
+ route.root,
725
+ "cancelled",
726
+ recovery.attemptStartedAt,
727
+ );
728
+ return null;
729
+ }
730
+ if (recovery.statusReason !== "process-crash-recovery-exhausted") {
731
+ this.markProcessCrashRecoveryFailed(recovery, route.key, replacement);
732
+ this.recordProcessCrashRecoveryEvent(
733
+ route.serverName,
734
+ route.root,
735
+ "failure",
736
+ recovery.attemptStartedAt,
737
+ );
738
+ }
739
+ return null;
740
+ }
741
+
742
+ if (
743
+ this.isProcessCrashRecoveryCancelled(recovery, route.key) ||
744
+ this.clients.get(route.key) !== replacement
745
+ ) {
746
+ await replacement.forceKill().catch(() => {});
747
+ this.recordProcessCrashRecoveryEvent(
748
+ route.serverName,
749
+ route.root,
750
+ "cancelled",
751
+ recovery.attemptStartedAt,
752
+ );
753
+ return null;
754
+ }
755
+ return replacement;
756
+ }
757
+
758
+ /** Restore route documents from disk and retain failed-document evidence. */
759
+ private restoreTrackedDocuments(client: LspClient, files: readonly string[]): void {
760
+ const failedFiles: string[] = [];
761
+ for (const filePath of files) {
762
+ if (!fs.existsSync(filePath)) {
763
+ failedFiles.push(filePath);
764
+ continue;
765
+ }
766
+ try {
767
+ client.didOpen(filePath, fs.readFileSync(filePath, "utf-8"));
768
+ } catch {
769
+ failedFiles.push(filePath);
770
+ }
771
+ }
772
+ for (const filePath of failedFiles) client.markFailedFile(filePath);
773
+ }
774
+
775
+ private markProcessCrashRecoveryFailed(
776
+ recovery: ProcessCrashRecoveryState,
777
+ key: string,
778
+ replacement: LspClient,
779
+ ): void {
780
+ recovery.statusReason = "process-crash-recovery-exhausted";
781
+ this.unavailable.set(key, "start-failed");
782
+ if (this.clients.get(key) === replacement) this.clients.delete(key);
783
+ // A failed initialize may still leave a child process alive. The
784
+ // replacement is no longer owned by the manager before it is terminated.
785
+ void replacement.forceKill().catch(() => {});
786
+ this.publishLifecycle("recovery");
787
+ }
788
+
789
+ private recordProcessCrashRecoveryEvent(
790
+ serverName: string,
791
+ root: string,
792
+ outcome: ProcessCrashRecoveryOutcome,
793
+ startedAt?: number,
794
+ ): void {
795
+ try {
796
+ recordDebugEvent({
797
+ source: "lsp",
798
+ level: outcome === "failure" || outcome === "exhausted" ? "warning" : "debug",
799
+ category: "runtime.recovery",
800
+ message: `LSP process-crash recovery ${outcome}`,
801
+ cwd: boundCwd(this.cwd),
802
+ data: {
803
+ reason: "process-crash",
804
+ outcome,
805
+ server: truncateIdentity(serverName),
806
+ root: truncateIdentity(root),
807
+ elapsedMs: startedAt === undefined ? 0 : Math.max(0, Date.now() - startedAt),
808
+ },
809
+ });
810
+ } catch {
811
+ // Recovery telemetry must never change route behavior.
812
+ }
813
+ }
814
+
325
815
  private publishLifecycle(kind: ManagerLifecycleTransitionKind): void {
326
816
  if (!this.onLifecycleTransition) return;
327
817
  const projectServers = this.getKnownProjectServers([]);
@@ -360,11 +850,16 @@ export class LspManager {
360
850
  rememberKnownRoot(this.knownRoots, serverName, root);
361
851
  try {
362
852
  await client.start();
853
+ if (this.shuttingDown) {
854
+ await client.forceKill().catch(() => {});
855
+ if (this.clients.get(key) === client) this.clients.delete(key);
856
+ return null;
857
+ }
363
858
  this.unavailable.delete(key);
364
859
  return client;
365
860
  } catch {
366
861
  this.unavailable.set(key, "start-failed");
367
- this.clients.delete(key);
862
+ if (this.clients.get(key) === client) this.clients.delete(key);
368
863
  return null;
369
864
  }
370
865
  }
@@ -403,30 +898,60 @@ export class LspManager {
403
898
  const seen = new Set<string>();
404
899
 
405
900
  for (const filePath of filePaths) {
406
- const client = this.getExistingClientForFile(filePath);
407
- if (!client) continue;
408
- if (options?.pushOnly && client.hasDiagnosticProvider) continue;
409
-
410
- const key = clientKey(client.name, client.root);
411
- if (seen.has(key)) continue;
412
- seen.add(key);
413
- if (this.recoveryRestartEpochs.get(key) === this.invalidationEpoch) continue;
901
+ const candidate = this.getRestartCandidate(filePath, options?.pushOnly === true, seen);
902
+ if (!candidate) continue;
903
+ const { client, key } = candidate;
414
904
 
415
905
  // Observe cancellation between route restarts so a cancelled pass does
416
906
  // not keep restarting further clients.
417
907
  throwIfCodeRequestInterrupted(options?.control);
418
- const result = await this.restartClient(client);
419
- this.recoveryRestartEpochs.set(key, this.invalidationEpoch);
908
+ const restartPromise = this.pendingRestarts.get(key) ?? this.startSharedRestart(client, key);
909
+ const result = await raceRequestControl(restartPromise, options?.control);
420
910
  restarted.push({ key, serverName: client.name, ...result });
421
911
  }
422
912
 
423
913
  return restarted;
424
914
  }
425
915
 
916
+ private startSharedRestart(
917
+ client: LspClient,
918
+ key: string,
919
+ ): Promise<{ files: string[]; restarted: boolean }> {
920
+ const restartEpoch = this.invalidationEpoch;
921
+ const restartPromise = this.restartClient(client).then((result) => {
922
+ this.recoveryRestartEpochs.set(key, restartEpoch);
923
+ return result;
924
+ });
925
+ this.pendingRestarts.set(key, restartPromise);
926
+ void restartPromise
927
+ .finally(() => {
928
+ if (this.pendingRestarts.get(key) === restartPromise) this.pendingRestarts.delete(key);
929
+ })
930
+ .catch(() => {});
931
+ return restartPromise;
932
+ }
933
+
934
+ private getRestartCandidate(
935
+ filePath: string,
936
+ pushOnly: boolean,
937
+ seen: Set<string>,
938
+ ): { client: LspClient; key: string } | null {
939
+ const client = this.getExistingClientForFile(filePath);
940
+ if (!client || (pushOnly && client.hasDiagnosticProvider)) return null;
941
+
942
+ const key = clientKey(client.name, client.root);
943
+ if (this.processCrashRecoveries.get(key)?.pending || seen.has(key)) return null;
944
+ seen.add(key);
945
+ if (this.recoveryRestartEpochs.get(key) === this.invalidationEpoch) return null;
946
+ return { client, key };
947
+ }
948
+
426
949
  private async restartClient(client: LspClient): Promise<{ files: string[]; restarted: boolean }> {
427
950
  const key = clientKey(client.name, client.root);
428
951
  const serverConfig = this.config.servers[client.name];
429
- if (!serverConfig) return { files: [], restarted: false };
952
+ if (!serverConfig || this.clients.get(key) !== client) {
953
+ return { files: [], restarted: false };
954
+ }
430
955
 
431
956
  const openFiles = client.openFiles;
432
957
  const trackedDiagnosticFiles = client
@@ -439,6 +964,7 @@ export class LspManager {
439
964
  // Ignore shutdown failures when forcing a restart.
440
965
  }
441
966
 
967
+ if (this.clients.get(key) !== client) return { files, restarted: false };
442
968
  this.clients.delete(key);
443
969
  this.unavailable.delete(key);
444
970
  this.clearWarmedWorkspaceSymbolProjects(client.name, client.root);
@@ -455,23 +981,18 @@ export class LspManager {
455
981
  RECOVERY_CLIENT_STARTUP_BOUND_MS,
456
982
  "replacement client startup bound exceeded",
457
983
  );
458
- const failedFiles: string[] = [];
459
- for (const filePath of files) {
460
- if (!fs.existsSync(filePath)) {
461
- failedFiles.push(filePath);
462
- continue;
463
- }
464
- try {
465
- replacement.didOpen(filePath, fs.readFileSync(filePath, "utf-8"));
466
- } catch {
467
- failedFiles.push(filePath);
468
- }
984
+ if (this.clients.get(key) !== replacement || this.shuttingDown) {
985
+ void replacement.forceKill().catch(() => {});
986
+ return { files, restarted: false };
469
987
  }
470
- for (const filePath of failedFiles) replacement.markFailedFile(filePath);
988
+ this.restoreTrackedDocuments(replacement, files);
989
+ this.clearUnconsumedProcessCrashState(key, client);
471
990
  return { files, restarted: true };
472
991
  } catch {
473
- this.clients.delete(key);
474
- this.unavailable.set(key, "start-failed");
992
+ if (this.clients.get(key) === replacement) {
993
+ this.clients.delete(key);
994
+ this.unavailable.set(key, "start-failed");
995
+ }
475
996
  // A bound-exceeded start may have spawned a live process; terminate
476
997
  // the tree so the orphan cannot outlive the failed restart.
477
998
  void replacement.forceKill().catch(() => {});
@@ -479,6 +1000,13 @@ export class LspManager {
479
1000
  }
480
1001
  }
481
1002
 
1003
+ private clearUnconsumedProcessCrashState(key: string, failedClient: LspClient): void {
1004
+ const recovery = this.processCrashRecoveries.get(key);
1005
+ if (recovery?.failedClient !== failedClient || recovery.attemptConsumed) return;
1006
+ this.processCrashRecoveries.delete(key);
1007
+ this.publishLifecycle("recovery");
1008
+ }
1009
+
482
1010
  getProjectServerInfo(serverName: string, root: string, fileTypes: string[]): ProjectServerInfo {
483
1011
  const key = clientKey(serverName, root);
484
1012
  return buildProjectServerInfo(
@@ -489,6 +1017,8 @@ export class LspManager {
489
1017
  client: this.clients.get(key),
490
1018
  unavailableReason:
491
1019
  this.getUnavailableReason(key, this.config.servers[serverName]?.command) ?? undefined,
1020
+ statusReason: this.processCrashRecoveries.get(key)?.statusReason,
1021
+ includeOpenFile: (file) => this.isAutomaticScopePath(file),
492
1022
  },
493
1023
  this.cwd,
494
1024
  );
@@ -496,6 +1026,7 @@ export class LspManager {
496
1026
  getKnownProjectServers(detected: DetectedProjectServer[]): ProjectServerInfo[] {
497
1027
  const known = new Map<string, DetectedProjectServer>();
498
1028
  for (const entry of detected) {
1029
+ if (!this.automaticPathPolicy.isEligible(entry.root, "directory")) continue;
499
1030
  known.set(clientKey(entry.name, entry.root), entry);
500
1031
  }
501
1032
  for (const client of this.clients.values()) {
@@ -507,6 +1038,19 @@ export class LspManager {
507
1038
  fileTypes: [...(this.config.servers[client.name]?.fileTypes ?? [])],
508
1039
  });
509
1040
  }
1041
+ // Keep an exhausted recovery route visible after its failed replacement
1042
+ // is removed from the active client pool.
1043
+ for (const recovery of this.processCrashRecoveries.values()) {
1044
+ const name = recovery.failedClient.name;
1045
+ const root = recovery.failedClient.root;
1046
+ const key = clientKey(name, root);
1047
+ if (known.has(key)) continue;
1048
+ known.set(key, {
1049
+ name,
1050
+ root,
1051
+ fileTypes: [...(this.config.servers[name]?.fileTypes ?? [])],
1052
+ });
1053
+ }
510
1054
  return Array.from(known.values())
511
1055
  .map((entry) => this.getProjectServerInfo(entry.name, entry.root, entry.fileTypes))
512
1056
  .sort(
@@ -521,17 +1065,31 @@ export class LspManager {
521
1065
  async waitUntilFileReady(
522
1066
  filePath: string,
523
1067
  control?: CodeRequestControl,
524
- ): Promise<LspClient | null> {
1068
+ onProcessCrashRecovery?: (summary: ProcessCrashRecoverySummary) => void,
1069
+ ): Promise<FileReadinessResult> {
525
1070
  throwIfCodeRequestInterrupted(control);
526
1071
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
527
- const client = await this.getClientForFile(resolvedPath);
528
- if (!client) return null;
1072
+ const route = this.resolveFileRoute(resolvedPath);
1073
+ const recovery = route ? this.processCrashRecoveries.get(route.key) : undefined;
1074
+ const processCrashRecoveryRequested =
1075
+ recovery !== undefined && (recovery.pending !== null || recovery.statusReason !== undefined);
1076
+ if (processCrashRecoveryRequested) {
1077
+ onProcessCrashRecovery?.(summarizeProcessCrashRecoveryRoutes(["pending"]));
1078
+ }
1079
+ const client = await this.getClientForFile(resolvedPath, {
1080
+ recoverProcessCrash: true,
1081
+ control,
1082
+ });
1083
+ const processCrashRecovery = processCrashRecoveryRequested
1084
+ ? summarizeProcessCrashRecoveryRoutes([client ? "recovered" : "failed"])
1085
+ : emptyProcessCrashRecoverySummary();
1086
+ if (!client) return { client: null, processCrashRecovery };
529
1087
  // The caller's wait stops on cancellation even though the shared
530
1088
  // readiness state keeps its own lifecycle.
531
1089
  await client.getReady(control);
532
1090
  await this.warmSemanticProject(client, resolvedPath, true, control);
533
1091
  throwIfCodeRequestInterrupted(control);
534
- return client;
1092
+ return { client, processCrashRecovery };
535
1093
  }
536
1094
 
537
1095
  /**
@@ -569,6 +1127,7 @@ export class LspManager {
569
1127
  firstReady.root,
570
1128
  serverConfig.rootMarkers,
571
1129
  serverConfig.fileTypes,
1130
+ { policy: this.automaticPathPolicy },
572
1131
  )[0];
573
1132
  if (target) await this.warmSemanticProject(firstReady, target.file, false, control);
574
1133
  }
@@ -582,7 +1141,10 @@ export class LspManager {
582
1141
  control?: CodeRequestControl,
583
1142
  ): Promise<CodeQueryResult<Diagnostic[]>> {
584
1143
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
585
- const client = await this.getClientForFile(resolvedPath);
1144
+ const client = await this.getClientForFile(resolvedPath, {
1145
+ recoverProcessCrash: true,
1146
+ control,
1147
+ });
586
1148
  if (!client) {
587
1149
  return unavailableCodeQuery(`No LSP client can collect diagnostics for ${resolvedPath}.`);
588
1150
  }
@@ -637,6 +1199,7 @@ export class LspManager {
637
1199
  */
638
1200
  noteWorkspaceChanges(changes: FileEvent[]): void {
639
1201
  if (changes.length > 0) this.invalidationEpoch++;
1202
+ invalidateProjectConfigCaches(changes);
640
1203
  this.notifyWorkspaceFileChanges(changes);
641
1204
  }
642
1205
 
@@ -678,26 +1241,98 @@ export class LspManager {
678
1241
  quietMs?: number;
679
1242
  /** Evidence from a refresh the caller already completed; skips this pass's own refresh when no watched-file changes apply. */
680
1243
  initialEvidence?: DiagnosticEvidenceSummary;
1244
+ /** Explicit demand to recover crashed routes with tracked files in scope. */
1245
+ processCrashDemand?: { scopes?: readonly string[] };
681
1246
  control?: CodeRequestControl;
682
1247
  }): Promise<WorkspaceRecoveryResult> {
683
- return recoverWorkspaceDiagnosticsImpl(this, options);
1248
+ const demand = options?.processCrashDemand
1249
+ ? await this.recoverProcessCrashDemand((recovery) => {
1250
+ const files = this.processCrashDiagnosticFiles(recovery, options.processCrashDemand);
1251
+ return files.length > 0 ? files : null;
1252
+ }, options.control)
1253
+ : {
1254
+ hasSupport: false,
1255
+ processCrashRecovery: emptyProcessCrashRecoverySummary(),
1256
+ failures: [],
1257
+ failedFiles: [],
1258
+ };
1259
+ const recoveryOptions = demand.hasSupport
1260
+ ? { ...options, initialEvidence: undefined }
1261
+ : options;
1262
+ const result = await recoverWorkspaceDiagnosticsImpl(this, recoveryOptions);
1263
+ return this.addProcessCrashDiagnosticEvidence(
1264
+ { ...result, processCrashRecovery: demand.processCrashRecovery },
1265
+ demand,
1266
+ );
1267
+ }
1268
+
1269
+ private processCrashDiagnosticFiles(
1270
+ recovery: ProcessCrashRecoveryState,
1271
+ demand: { scopes?: readonly string[] } | undefined,
1272
+ ): string[] {
1273
+ if (!demand) return [];
1274
+ const scopes = demand.scopes?.map((scope) => resolveSessionPath(this.cwd, scope));
1275
+ return recovery.files.filter(
1276
+ (file) =>
1277
+ this.isDiagnosticFile(file) &&
1278
+ (!scopes ||
1279
+ scopes.length === 0 ||
1280
+ scopes.some((scope) => projectRoots.isWithinOrEqual(scope, file))),
1281
+ );
1282
+ }
1283
+
1284
+ private addProcessCrashDiagnosticEvidence(
1285
+ result: WorkspaceRecoveryResult,
1286
+ demand: ProcessCrashDemandResult,
1287
+ ): WorkspaceRecoveryResult {
1288
+ if (demand.failures.length === 0) return result;
1289
+ const byFile = new Map(
1290
+ result.diagnosticEvidence.documents.map((document) => [document.file, document] as const),
1291
+ );
1292
+ for (const file of demand.failedFiles) {
1293
+ const relativeFile = path.relative(this.cwd, file);
1294
+ byFile.set(relativeFile, { file: relativeFile, status: "failed" });
1295
+ }
1296
+ const diagnosticEvidence = summarizeDiagnosticEvidence(Array.from(byFile.values()));
1297
+ const diagnosticReport = {
1298
+ summary: { ...result.diagnosticReport.summary, current: false, evidence: diagnosticEvidence },
1299
+ outstanding: {
1300
+ ...result.diagnosticReport.outstanding,
1301
+ current: false,
1302
+ evidence: diagnosticEvidence,
1303
+ },
1304
+ };
1305
+ return {
1306
+ ...result,
1307
+ diagnosticEvidence,
1308
+ diagnosticReport,
1309
+ refreshFailureReason: [result.refreshFailureReason, ...demand.failures]
1310
+ .filter(Boolean)
1311
+ .join("; "),
1312
+ };
684
1313
  }
685
1314
 
686
1315
  /** Shut down all running LSP servers. */
687
1316
  async shutdownAll(): Promise<void> {
688
- const shutdowns = Array.from(this.clients.values()).map((c) => c.shutdown().catch(() => {}));
1317
+ this.shuttingDown = true;
1318
+ const clients = Array.from(this.clients.values());
1319
+ const shutdowns = clients.map((client) => client.shutdown().catch(() => {}));
689
1320
  await Promise.all(shutdowns);
690
1321
  // Dispose every client so pending or active progress tokens and
691
1322
  // readiness promises cannot outlive the manager.
692
- for (const client of this.clients.values()) client.dispose();
1323
+ for (const client of clients) client.dispose();
693
1324
  this.clients.clear();
694
1325
  this.clientGenerations.clear();
1326
+ this.initializedClientGenerations.clear();
1327
+ this.handledCrashGenerations.clear();
1328
+ this.processCrashRecoveries.clear();
695
1329
  this.recoveryRestartEpochs.clear();
696
1330
  this.unavailable.clear();
697
1331
  this.knownRoots.clear();
698
1332
  this.warmedWorkspaceSymbolProjects.clear();
699
1333
  this.warmedSemanticProjects.clear();
700
1334
  this.pendingWarmProbes.clear();
1335
+ this.pendingRestarts.clear();
701
1336
  }
702
1337
  /** Get status of all servers. */
703
1338
  getRunningClientCount(): number {
@@ -711,13 +1346,9 @@ export class LspManager {
711
1346
  .map((client) => client.name);
712
1347
  }
713
1348
 
714
- /** Check whether a path belongs to the configured diagnostic evidence scope. */
1349
+ /** Check whether a path belongs to the automatic diagnostic evidence scope. */
715
1350
  isDiagnosticFile(filePath: string): boolean {
716
- const relative = path.relative(this.cwd, path.resolve(this.cwd, filePath));
717
- return (
718
- !shouldIgnoreLspPath(relative, this.cwd) &&
719
- !isExcludedByPattern(relative, this.excludePatterns)
720
- );
1351
+ return this.isAutomaticScopePath(filePath);
721
1352
  }
722
1353
 
723
1354
  getDiagnosticEvidence(): DiagnosticEvidenceSummary {
@@ -732,7 +1363,7 @@ export class LspManager {
732
1363
  name: client.name,
733
1364
  status: client.status === "running" ? "running" : "error",
734
1365
  root: client.root,
735
- openFiles: client.openFiles,
1366
+ openFiles: client.openFiles.filter((file) => this.isAutomaticScopePath(file)),
736
1367
  });
737
1368
  }
738
1369
  return { servers };
@@ -773,8 +1404,7 @@ export class LspManager {
773
1404
  const openFiles = activeServers.get(server.name) ?? new Set<string>();
774
1405
  for (const file of server.openFiles) {
775
1406
  const relativeFile = displayRelativeFilePath(file, this.cwd);
776
- if (shouldIgnoreLspPath(relativeFile, this.cwd)) continue;
777
- if (isExcludedByPattern(relativeFile, this.excludePatterns)) continue;
1407
+ if (!this.isAutomaticScopePath(relativeFile)) continue;
778
1408
  openFiles.add(relativeFile);
779
1409
  }
780
1410
  activeServers.set(server.name, openFiles);
@@ -800,7 +1430,7 @@ export class LspManager {
800
1430
  .map((entry) => ({
801
1431
  ...entry,
802
1432
  openFiles: entry.openFiles.filter((file) =>
803
- isPathRelevant(file, normalizedPaths, this.cwd),
1433
+ isPathRelevant(file, normalizedPaths, this.cwd, this.automaticPathPolicy),
804
1434
  ),
805
1435
  }))
806
1436
  .filter((entry) => entry.openFiles.length > 0);
@@ -832,7 +1462,9 @@ export class LspManager {
832
1462
  const fileDiags = new Map<string, { errors: number; warnings: number }>();
833
1463
  for (const snapshot of snapshots) {
834
1464
  for (const entry of snapshot.entries) {
835
- collectDiagnosticSummaryCounts(fileDiags, entry, this.cwd, this.excludePatterns);
1465
+ collectDiagnosticSummaryCounts(fileDiags, entry, this.cwd, (file) =>
1466
+ this.isAutomaticScopePath(file),
1467
+ );
836
1468
  }
837
1469
  }
838
1470
 
@@ -846,7 +1478,7 @@ export class LspManager {
846
1478
  entries: collectOutstandingDiagnosticsDetailed(
847
1479
  snapshots.map((snapshot) => snapshot.entries),
848
1480
  this.cwd,
849
- this.excludePatterns,
1481
+ (file) => this.isAutomaticScopePath(file),
850
1482
  maxSeverity,
851
1483
  ),
852
1484
  current,
@@ -878,7 +1510,9 @@ export class LspManager {
878
1510
  });
879
1511
  }
880
1512
  for (const entry of snapshot.entries) {
881
- collectDiagnosticSummaryCounts(fileDiags, entry, this.cwd, this.excludePatterns);
1513
+ collectDiagnosticSummaryCounts(fileDiags, entry, this.cwd, (file) =>
1514
+ this.isAutomaticScopePath(file),
1515
+ );
882
1516
  }
883
1517
  }
884
1518
  const evidence = this.toWorkspaceDiagnosticEvidence(evidenceDocuments);
@@ -910,7 +1544,7 @@ export class LspManager {
910
1544
  entries: collectOutstandingDiagnosticSummaryEntries(
911
1545
  snapshots,
912
1546
  this.cwd,
913
- this.excludePatterns,
1547
+ (file) => this.isAutomaticScopePath(file),
914
1548
  maxSeverity,
915
1549
  ),
916
1550
  current:
@@ -927,7 +1561,7 @@ export class LspManager {
927
1561
  if (normalizedPaths.length === 0) return null;
928
1562
  return formatOutstandingDiagnosticsSummaryText(
929
1563
  this.getOutstandingDiagnosticSummary(maxSeverity).filter((entry) =>
930
- isPathRelevant(entry.file, normalizedPaths, this.cwd),
1564
+ isPathRelevant(entry.file, normalizedPaths, this.cwd, this.automaticPathPolicy),
931
1565
  ),
932
1566
  maxFiles,
933
1567
  );
@@ -957,7 +1591,7 @@ export class LspManager {
957
1591
  entries: collectOutstandingDiagnosticsDetailed(
958
1592
  snapshots.map((snapshot) => snapshot.entries),
959
1593
  this.cwd,
960
- this.excludePatterns,
1594
+ (file) => this.isAutomaticScopePath(file),
961
1595
  maxSeverity,
962
1596
  ),
963
1597
  current:
@@ -986,6 +1620,7 @@ export class LspManager {
986
1620
  for (const client of this.clients.values()) {
987
1621
  for (const document of client.getDiagnosticSnapshot().documents) {
988
1622
  const file = relativeFilePathFromUri(document.uri, this.cwd);
1623
+ if (!this.isAutomaticScopePath(file)) continue;
989
1624
  totalFiles++;
990
1625
  accumulateScopeDecision(accumulator, file, getFileScopeDecision(file, this.cwd));
991
1626
  }
@@ -1005,8 +1640,7 @@ export class LspManager {
1005
1640
  const byFile = new Map<string, DiagnosticEvidenceDocument>();
1006
1641
  for (const document of documents) {
1007
1642
  const file = path.relative(this.cwd, path.resolve(this.cwd, document.file));
1008
- if (shouldIgnoreLspPath(file, this.cwd)) continue;
1009
- if (isExcludedByPattern(file, this.excludePatterns)) continue;
1643
+ if (!this.isAutomaticScopePath(file)) continue;
1010
1644
  const existing = byFile.get(file);
1011
1645
  if (!existing || evidenceStatusRank(document.status) > evidenceStatusRank(existing.status)) {
1012
1646
  byFile.set(file, { file, status: document.status });
@@ -1020,24 +1654,95 @@ export class LspManager {
1020
1654
  async workspaceSymbol(
1021
1655
  query: string,
1022
1656
  control?: CodeRequestControl,
1657
+ scopes?: readonly string[],
1023
1658
  ): Promise<CodeQueryResult<(SymbolInformation | WorkspaceSymbol)[]>> {
1024
- const initial = await collectWorkspaceSymbols(this.clients.values(), query, control);
1659
+ const resolvedScopes = scopes?.map((scope) => resolveSessionPath(this.cwd, scope));
1660
+ const routeMatches = (client: LspClient) =>
1661
+ this.workspaceSymbolRouteMatchesScopes(client, resolvedScopes);
1662
+ if (resolvedScopes && !this.hasWorkspaceSymbolRouteForScopes(resolvedScopes)) {
1663
+ return unavailableCodeQuery(
1664
+ "No known LSP route intersects the requested workspace-symbol scope.",
1665
+ );
1666
+ }
1667
+ const demand = await this.recoverProcessCrashDemand(
1668
+ (recovery) =>
1669
+ recovery.failedClient.serverCapabilities?.workspaceSymbolProvider &&
1670
+ routeMatches(recovery.failedClient)
1671
+ ? []
1672
+ : null,
1673
+ control,
1674
+ );
1675
+ const collect = async (clients: Iterable<LspClient>, value: string) =>
1676
+ this.addWorkspaceSymbolDemandEvidence(
1677
+ await collectWorkspaceSymbols(
1678
+ Array.from(clients).filter((client) => routeMatches(client)),
1679
+ value,
1680
+ control,
1681
+ ),
1682
+ demand,
1683
+ );
1684
+ const initial = await collect(this.clients.values(), query);
1025
1685
  if (!initial.hasSupport || initial.results.length > 0) {
1026
1686
  return workspaceSymbolCollectionResult(initial);
1027
1687
  }
1028
1688
 
1029
1689
  const warmed = await this.warmWorkspaceSymbolProjectsUntilResult({
1030
- findWarmTargets: findWorkspaceSymbolWarmTargets,
1690
+ findWarmTargets: (root, rootMarkers, fileTypes) =>
1691
+ findWorkspaceSymbolWarmTargets(root, rootMarkers, fileTypes, {
1692
+ policy: this.automaticPathPolicy,
1693
+ }),
1031
1694
  getWarmPosition: getWorkspaceSymbolWarmPosition,
1032
- collect: (clients, value) => collectWorkspaceSymbols(clients, value, control),
1695
+ collect,
1696
+ routeMatches,
1033
1697
  query,
1034
1698
  control,
1035
1699
  });
1036
1700
  return workspaceSymbolCollectionResult(warmed.collection ?? initial);
1037
1701
  }
1038
- async ensureFileOpen(filePath: string): Promise<LspClient | null> {
1702
+
1703
+ private addWorkspaceSymbolDemandEvidence(
1704
+ collection: WorkspaceSymbolCollection,
1705
+ demand: ProcessCrashDemandResult,
1706
+ ): WorkspaceSymbolCollection {
1707
+ return {
1708
+ ...collection,
1709
+ hasSupport: collection.hasSupport || demand.hasSupport,
1710
+ failures: [...collection.failures, ...demand.failures],
1711
+ };
1712
+ }
1713
+
1714
+ private hasWorkspaceSymbolRouteForScopes(scopes: readonly string[]): boolean {
1715
+ const clients = [
1716
+ ...this.clients.values(),
1717
+ ...Array.from(this.processCrashRecoveries.values(), (recovery) => recovery.failedClient),
1718
+ ];
1719
+ return clients.some((client) => this.workspaceSymbolRouteMatchesScopes(client, scopes));
1720
+ }
1721
+
1722
+ private workspaceSymbolRouteMatchesScopes(
1723
+ client: LspClient,
1724
+ scopes: readonly string[] | undefined,
1725
+ ): boolean {
1726
+ if (!scopes || scopes.length === 0) return true;
1727
+ const key = clientKey(client.name, client.root);
1728
+ return scopes.some((scope) => {
1729
+ try {
1730
+ if (fs.statSync(scope).isFile()) return this.resolveFileRoute(scope)?.key === key;
1731
+ } catch {
1732
+ return false;
1733
+ }
1734
+ return (
1735
+ projectRoots.isWithinOrEqual(client.root, scope) ||
1736
+ projectRoots.isWithinOrEqual(scope, client.root)
1737
+ );
1738
+ });
1739
+ }
1740
+ async ensureFileOpen(
1741
+ filePath: string,
1742
+ options: FileClientRequestOptions = {},
1743
+ ): Promise<LspClient | null> {
1039
1744
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
1040
- const client = await this.getClientForFile(resolvedPath);
1745
+ const client = await this.getClientForFile(resolvedPath, options);
1041
1746
  if (!client) return null;
1042
1747
  try {
1043
1748
  client.didOpen(resolvedPath, fs.readFileSync(resolvedPath, "utf-8"));
@@ -1059,13 +1764,15 @@ export class LspManager {
1059
1764
  symbols: import("../config/types.ts").DocumentSymbol[] | SymbolInformation[] | null,
1060
1765
  ) => import("../config/types.ts").Position | null;
1061
1766
  collect: (clients: Iterable<LspClient>, query: string) => Promise<WorkspaceSymbolCollection>;
1767
+ routeMatches?: (client: LspClient) => boolean;
1062
1768
  query: string;
1063
1769
  control?: CodeRequestControl;
1064
1770
  }): Promise<{ warmedAny: boolean; collection: WorkspaceSymbolCollection | null }> {
1065
- const { findWarmTargets, getWarmPosition, collect, query, control } = options;
1771
+ const { findWarmTargets, getWarmPosition, collect, routeMatches, query, control } = options;
1066
1772
  let warmedAny = false;
1067
1773
 
1068
1774
  for (const client of Array.from(this.clients.values())) {
1775
+ if (routeMatches && !routeMatches(client)) continue;
1069
1776
  if (client.status !== "running") continue;
1070
1777
  if (!client.serverCapabilities?.workspaceSymbolProvider) continue;
1071
1778
 
@@ -1081,28 +1788,21 @@ export class LspManager {
1081
1788
  for (const target of warmTargets) {
1082
1789
  const projectKey = this.workspaceSymbolProjectKey(client.name, target.projectRoot);
1083
1790
  if (this.warmedWorkspaceSymbolProjects.has(projectKey)) continue;
1084
- if (this.hasOpenFileInProject(client, target.projectRoot)) {
1085
- this.warmedWorkspaceSymbolProjects.add(projectKey);
1086
- continue;
1087
- }
1088
1791
 
1089
1792
  const openedClient = await this.ensureFileOpen(target.file);
1090
1793
  if (!openedClient) continue;
1091
1794
 
1092
1795
  // A cancelled pass must not mark projects warmed on its way out.
1093
1796
  throwIfCodeRequestInterrupted(control);
1094
- this.warmedWorkspaceSymbolProjects.add(projectKey);
1095
- warmedAny = true;
1096
-
1097
- const symbols = control
1098
- ? await openedClient.documentSymbols(target.file, control)
1099
- : await openedClient.documentSymbols(target.file);
1100
- if (symbols.kind !== "unavailable") {
1101
- const hoverPosition = getWarmPosition(symbols.data);
1102
- if (hoverPosition) {
1103
- if (control) await openedClient.hover(target.file, hoverPosition, control);
1104
- else await openedClient.hover(target.file, hoverPosition);
1105
- }
1797
+ const available = await this.probeDocumentSymbols(
1798
+ openedClient,
1799
+ target.file,
1800
+ getWarmPosition,
1801
+ control,
1802
+ );
1803
+ if (available) {
1804
+ this.warmedWorkspaceSymbolProjects.add(projectKey);
1805
+ warmedAny = true;
1106
1806
  }
1107
1807
 
1108
1808
  const collected = await collect(this.clients.values(), query);
@@ -1147,35 +1847,56 @@ export class LspManager {
1147
1847
  try {
1148
1848
  await raceRequestControl(probe, control);
1149
1849
  } finally {
1150
- this.pendingWarmProbes.delete(projectKey);
1850
+ if (this.pendingWarmProbes.get(projectKey) === probe) {
1851
+ this.pendingWarmProbes.delete(projectKey);
1852
+ }
1151
1853
  }
1152
1854
  }
1153
1855
 
1154
1856
  private async performWarmProbe(
1155
- _client: LspClient,
1857
+ client: LspClient,
1156
1858
  resolvedFile: string,
1157
1859
  projectKey: string,
1158
1860
  control?: CodeRequestControl,
1159
1861
  ): Promise<void> {
1862
+ const key = clientKey(client.name, client.root);
1863
+ if (this.clients.get(key) !== client) return;
1160
1864
  const openedClient = await this.ensureFileOpen(resolvedFile);
1161
- if (!openedClient) return;
1162
-
1163
- this.warmedSemanticProjects.add(projectKey);
1865
+ if (!openedClient || openedClient !== client || this.clients.get(key) !== client) return;
1164
1866
 
1165
1867
  // The probe is shared state for concurrent callers. One caller's
1166
1868
  // cancellation must not cancel the shared in-flight queries, so only the
1167
1869
  // Debug Operation ID is forwarded; each caller races its own wait above.
1168
1870
  const sharedControl = { operationId: control?.operationId };
1169
- const symbols = await openedClient.documentSymbols(resolvedFile, sharedControl);
1170
- if (symbols.kind === "unavailable") return;
1171
- const hoverPosition = getWorkspaceSymbolWarmPosition(symbols.data);
1172
- if (hoverPosition) {
1173
- await openedClient.hover(resolvedFile, hoverPosition, sharedControl);
1871
+ const available = await this.probeDocumentSymbols(
1872
+ openedClient,
1873
+ resolvedFile,
1874
+ getWorkspaceSymbolWarmPosition,
1875
+ sharedControl,
1876
+ );
1877
+ if (available && this.clients.get(key) === client) {
1878
+ this.warmedSemanticProjects.add(projectKey);
1174
1879
  }
1175
1880
  }
1176
1881
 
1177
- private hasOpenFileInProject(client: LspClient, projectRoot: string): boolean {
1178
- return client.openFiles.some((openFile) => projectRoots.isWithinOrEqual(projectRoot, openFile));
1882
+ private async probeDocumentSymbols(
1883
+ client: LspClient,
1884
+ file: string,
1885
+ getWarmPosition: (
1886
+ symbols: import("../config/types.ts").DocumentSymbol[] | SymbolInformation[] | null,
1887
+ ) => import("../config/types.ts").Position | null,
1888
+ control?: CodeRequestControl,
1889
+ ): Promise<boolean> {
1890
+ const symbols = control
1891
+ ? await client.documentSymbols(file, control)
1892
+ : await client.documentSymbols(file);
1893
+ if (symbols.kind === "unavailable") return false;
1894
+ const hoverPosition = getWarmPosition(symbols.data);
1895
+ if (hoverPosition) {
1896
+ if (control) await client.hover(file, hoverPosition, control);
1897
+ else await client.hover(file, hoverPosition);
1898
+ }
1899
+ return true;
1179
1900
  }
1180
1901
 
1181
1902
  private workspaceSymbolProjectKey(serverName: string, projectRoot: string): string {
@@ -1210,6 +1931,17 @@ export class LspManager {
1210
1931
  }
1211
1932
  }
1212
1933
 
1934
+ function invalidateProjectConfigCaches(changes: readonly FileEvent[]): void {
1935
+ for (const change of changes) {
1936
+ const configPath = uriToFile(change.uri);
1937
+ if (!isProjectConfigFileName(path.basename(configPath))) continue;
1938
+ invalidateTsconfigCacheForConfig(configPath);
1939
+ if (change.type === FileChangeType.Created) {
1940
+ invalidateTsconfigCacheForConfigDir(path.dirname(configPath));
1941
+ }
1942
+ }
1943
+ }
1944
+
1213
1945
  function collectClientDiagnosticEvidenceDocuments(
1214
1946
  snapshots: Iterable<ClientDiagnosticSnapshot>,
1215
1947
  cwd: string,
@@ -1225,14 +1957,14 @@ function collectClientDiagnosticEvidenceDocuments(
1225
1957
  function collectOutstandingDiagnosticSummaryEntries(
1226
1958
  snapshots: Iterable<ClientDiagnosticSnapshot>,
1227
1959
  cwd: string,
1228
- excludePatterns: string[],
1960
+ includeFile: (file: string) => boolean,
1229
1961
  maxSeverity: number,
1230
1962
  ): OutstandingDiagnosticSummaryEntry[] {
1231
1963
  const fileDiags = new Map<string, OutstandingDiagnosticSummaryEntry>();
1232
1964
  for (const snapshot of snapshots) {
1233
1965
  for (const entry of snapshot.entries) {
1234
1966
  const file = relativeFilePathFromUri(entry.uri, cwd);
1235
- if (shouldIgnoreLspPath(file, cwd) || isExcludedByPattern(file, excludePatterns)) continue;
1967
+ if (!includeFile(file)) continue;
1236
1968
  const existing = fileDiags.get(file) ?? createOutstandingDiagnosticSummary(file);
1237
1969
  const next = accumulateOutstandingDiagnostics(existing, entry.diagnostics, maxSeverity);
1238
1970
  if (next.total > 0) fileDiags.set(file, next);