@mrclrchtr/supi-code-intelligence 6.1.0 → 6.3.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 (141) hide show
  1. package/README.md +3 -1
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/debug.ts +5 -0
  6. package/node_modules/@mrclrchtr/supi-lsp/README.md +23 -3
  7. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  8. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  9. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  10. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug.ts +5 -0
  11. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/README.md +52 -0
  12. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/index.js +785 -129
  13. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/legacy.js +660 -98
  14. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/package.json +10 -4
  15. package/node_modules/@mrclrchtr/supi-lsp/package.json +4 -5
  16. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +16 -7
  17. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-cache.ts +1 -1
  18. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-capabilities.ts +0 -5
  19. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-publication.ts +6 -3
  20. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-refresh.ts +2 -1
  21. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-timing.ts +5 -2
  22. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostics.ts +2 -1
  23. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +17 -5
  24. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +40 -18
  25. package/node_modules/@mrclrchtr/supi-lsp/src/config/config.ts +164 -30
  26. package/node_modules/@mrclrchtr/supi-lsp/src/config/lsp-settings.ts +9 -87
  27. package/node_modules/@mrclrchtr/supi-lsp/src/config/server-config.ts +0 -1
  28. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-scope.ts +4 -5
  29. package/node_modules/@mrclrchtr/supi-lsp/src/config/types.ts +0 -29
  30. package/node_modules/@mrclrchtr/supi-lsp/src/debug-telemetry.ts +7 -21
  31. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-severity.ts +6 -0
  32. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-summary.ts +11 -14
  33. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/evidence.ts +0 -5
  34. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/stale-diagnostics.ts +0 -1
  35. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sentinels.ts +29 -49
  36. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-client-state.ts +1 -1
  37. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +4 -8
  38. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-helpers.ts +1 -9
  39. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-process-crash-report.ts +106 -0
  40. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-project-info.ts +6 -3
  41. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +9 -1
  42. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-symbol.ts +26 -41
  43. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +577 -152
  44. package/node_modules/@mrclrchtr/supi-lsp/src/provider/lsp-semantic-provider.ts +4 -1
  45. package/node_modules/@mrclrchtr/supi-lsp/src/provider/semantic-symbol-mapper.ts +59 -28
  46. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-controller.ts +27 -19
  47. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +54 -0
  48. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registration.ts +0 -17
  49. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +74 -19
  50. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-transition-debug.ts +5 -2
  51. package/node_modules/@mrclrchtr/supi-lsp/src/session/scanner.ts +24 -21
  52. package/node_modules/@mrclrchtr/supi-lsp/src/session/workspace-lsp-runtime.ts +29 -3
  53. package/node_modules/@mrclrchtr/supi-lsp/src/summary.ts +19 -40
  54. package/node_modules/@mrclrchtr/supi-lsp/src/utils.ts +13 -19
  55. package/node_modules/@mrclrchtr/supi-lsp/src/workspace-path-policy.ts +296 -0
  56. package/node_modules/@mrclrchtr/supi-tree-sitter/README.md +6 -18
  57. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  58. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  59. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  60. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug.ts +5 -0
  61. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/README.md +29 -1
  62. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.cjs +99 -19
  63. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.cjs.map +4 -4
  64. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.js +94 -18
  65. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.js.map +4 -4
  66. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm +0 -0
  67. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm.map +14 -14
  68. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/package.json +13 -8
  69. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.cjs +97 -19
  70. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.cjs.map +4 -4
  71. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.d.cts +28 -8
  72. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.d.cts.map +1 -1
  73. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.d.ts +28 -8
  74. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.d.ts.map +1 -1
  75. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.js +92 -18
  76. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.js.map +4 -4
  77. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.wasm +0 -0
  78. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.wasm.map +17 -17
  79. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +7 -6
  80. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/kotlin/tree-sitter-kotlin.wasm +0 -0
  81. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/kotlin/tree-sitter-kotlin.wasm.json +2 -2
  82. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/sql/tree-sitter-sql.wasm +0 -0
  83. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/sql/tree-sitter-sql.wasm.json +2 -2
  84. package/node_modules/@mrclrchtr/supi-tree-sitter/src/api.ts +1 -11
  85. package/node_modules/@mrclrchtr/supi-tree-sitter/src/coordinates.ts +19 -0
  86. package/node_modules/@mrclrchtr/supi-tree-sitter/src/language.ts +5 -23
  87. package/node_modules/@mrclrchtr/supi-tree-sitter/src/operation-support.ts +3 -21
  88. package/node_modules/@mrclrchtr/supi-tree-sitter/src/provider/tree-sitter-provider.ts +5 -9
  89. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/runtime-controller.ts +10 -20
  90. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/session.ts +7 -1
  91. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-client.ts +9 -5
  92. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-protocol.ts +134 -9
  93. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/call-name.ts +96 -0
  94. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/call-sites.ts +11 -12
  95. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/callees.ts +39 -56
  96. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/exports.ts +9 -7
  97. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/js-binding-pattern.ts +26 -0
  98. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/node-at.ts +2 -21
  99. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/outline-html-sql.ts +144 -12
  100. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/outline.ts +50 -37
  101. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/scope.ts +138 -0
  102. package/node_modules/@mrclrchtr/supi-tree-sitter/src/types.ts +0 -7
  103. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/parsed-file-store.ts +1 -14
  104. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/runtime.ts +0 -10
  105. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/service.ts +6 -8
  106. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/worker-main.ts +34 -10
  107. package/package.json +5 -5
  108. package/src/analysis/capability/capability-warnings.ts +4 -22
  109. package/src/analysis/search/ast-scan-timing.ts +4 -2
  110. package/src/analysis/search/ast-scan.ts +1 -1
  111. package/src/api.ts +1 -0
  112. package/src/extension.ts +4 -2
  113. package/src/session/health-refresh.ts +26 -2
  114. package/src/session/health-types.ts +19 -0
  115. package/src/session/input/health-refactor.ts +16 -14
  116. package/src/session/refactor-types.ts +14 -1
  117. package/src/session/refactor-workflow.ts +21 -16
  118. package/src/substrate/lsp/lifecycle.ts +0 -3
  119. package/src/substrate/lsp/maintenance.ts +4 -5
  120. package/src/substrate/lsp/settings.ts +7 -9
  121. package/src/substrate/lsp/state.ts +0 -2
  122. package/src/substrate/workspace-provider-host.ts +2 -2
  123. package/src/tool/code_health/file-scope-markdown.ts +43 -0
  124. package/src/tool/code_health/markdown.ts +66 -66
  125. package/src/tool/code_health/refresh-outcome.ts +76 -0
  126. package/src/tool/code_health/refresh-status.ts +212 -23
  127. package/src/tool/code_health/result.ts +1 -0
  128. package/src/tool/code_health/tui.ts +18 -5
  129. package/src/tool/code_refactor_apply/guidance.ts +3 -2
  130. package/src/tool/code_refactor_plan/guidance.ts +1 -1
  131. package/src/tool/register.ts +5 -2
  132. package/src/tool/schemas.ts +21 -11
  133. package/node_modules/@mrclrchtr/supi-lsp/src/config/server-actions.ts +0 -59
  134. package/node_modules/@mrclrchtr/supi-lsp/src/pattern-matcher.ts +0 -24
  135. package/node_modules/@mrclrchtr/supi-tree-sitter/scripts/generate-kotlin-wasm.mjs +0 -132
  136. package/node_modules/@mrclrchtr/supi-tree-sitter/scripts/generate-sql-wasm.mjs +0 -158
  137. package/node_modules/@mrclrchtr/supi-tree-sitter/scripts/vendor-wasm.mjs +0 -167
  138. package/node_modules/@mrclrchtr/supi-tree-sitter/src/index.ts +0 -42
  139. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/runtime-registration.ts +0 -37
  140. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/service-registry.ts +0 -30
  141. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/structure.ts +0 -8
@@ -21,9 +21,18 @@ 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 { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
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";
25
29
  import { getServerForFile } from "../config/config.ts";
26
- import { getFileScopeDecision } from "../config/tsconfig-scope.ts";
30
+ import {
31
+ getFileScopeDecision,
32
+ invalidateTsconfigCacheForConfig,
33
+ invalidateTsconfigCacheForConfigDir,
34
+ isProjectConfigFileName,
35
+ } from "../config/tsconfig-scope.ts";
27
36
  import type {
28
37
  DetectedProjectServer,
29
38
  Diagnostic,
@@ -35,7 +44,8 @@ import type {
35
44
  SymbolInformation,
36
45
  WorkspaceSymbol,
37
46
  } from "../config/types.ts";
38
- import { boundCwd, truncateIdentity } from "../debug-telemetry.ts";
47
+ import { FileChangeType } from "../config/types.ts";
48
+ import { boundCwd } from "../debug-telemetry.ts";
39
49
  import {
40
50
  accumulateOutstandingDiagnostics,
41
51
  collectDiagnosticSummaryCounts,
@@ -47,11 +57,19 @@ import {
47
57
  type DiagnosticEvidenceSummary,
48
58
  summarizeDiagnosticEvidence,
49
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";
50
66
  import { raceRequestControl } from "../session/readiness.ts";
51
- import type {
52
- ScopeDecisionEntry,
53
- ScopeDecisionSummary,
54
- WorkspaceDiagnosticReport,
67
+ import {
68
+ emptyProcessCrashRecoveryReport,
69
+ type ProcessCrashRecoveryReport,
70
+ type ScopeDecisionEntry,
71
+ type ScopeDecisionSummary,
72
+ type WorkspaceDiagnosticReport,
55
73
  } from "../session/runtime-diagnostics.ts";
56
74
  import {
57
75
  displayRelativeFilePath,
@@ -61,7 +79,11 @@ import {
61
79
  normalizeRelevantPaths,
62
80
  shouldIgnoreLspPath,
63
81
  } from "../summary.ts";
64
- 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";
65
87
  import {
66
88
  closeFileAcrossClients,
67
89
  pruneMissingFilesFromClients,
@@ -71,12 +93,12 @@ import {
71
93
  collectOutstandingDiagnosticsDetailed,
72
94
  syncClientFileAndGetDiagnostics,
73
95
  } from "./manager-diagnostics.ts";
96
+ import { clientKey, rememberKnownRoot, resolveRootForFile } from "./manager-helpers.ts";
74
97
  import {
75
- clientKey,
76
- isExcludedByPattern,
77
- rememberKnownRoot,
78
- resolveRootForFile,
79
- } from "./manager-helpers.ts";
98
+ boundProcessCrashFailureMessage,
99
+ buildProcessCrashRecoveryReport,
100
+ type ProcessCrashRecoveryRouteResult,
101
+ } from "./manager-process-crash-report.ts";
80
102
  import { buildProjectServerInfo } from "./manager-project-info.ts";
81
103
  import type {
82
104
  ActiveCoverageSummaryEntry,
@@ -161,6 +183,11 @@ type FileClientRequestOptions = {
161
183
  control?: CodeRequestControl;
162
184
  };
163
185
 
186
+ interface FileReadinessResult {
187
+ client: LspClient | null;
188
+ processCrashRecovery: ProcessCrashRecoveryReport;
189
+ }
190
+
164
191
  interface ProcessCrashRecoveryState {
165
192
  /** The one automatic attempt is consumed when a replacement starts. */
166
193
  attemptConsumed: boolean;
@@ -174,25 +201,57 @@ interface ProcessCrashRecoveryState {
174
201
  pending: Promise<LspClient | null> | null;
175
202
  /** Start time for the current replacement attempt, when one exists. */
176
203
  attemptStartedAt?: number;
204
+ /** Bounded caught Error.message from a failed replacement startup. */
205
+ failureMessage?: string;
177
206
  }
178
207
 
179
- type ProcessCrashRecoveryOutcome = "attempt" | "success" | "failure" | "exhausted" | "cancelled";
180
-
181
208
  type ProcessCrashDemandResult = {
182
- /** Whether at least one required crashed route supports the operation. */
209
+ /** Whether at least one crashed route supports the operation. */
183
210
  hasSupport: boolean;
211
+ /** Whether replacement work changed the route set and needs a fresh pass. */
212
+ requiresFreshEvidence: boolean;
213
+ /** Separate route-level result for this explicit process-crash demand. */
214
+ processCrashRecovery: ProcessCrashRecoveryReport;
184
215
  /** Required routes that remain unavailable after shared recovery settles. */
185
216
  failures: string[];
186
217
  /** In-scope tracked files from required routes that remain unavailable. */
187
218
  failedFiles: string[];
188
219
  };
189
220
 
221
+ type ProcessCrashRecoverySnapshot = {
222
+ readonly key: string;
223
+ /** Mutable state handle used only to await the shared replacement. */
224
+ readonly recovery: ProcessCrashRecoveryState;
225
+ readonly failedClient: LspClient;
226
+ readonly name: string;
227
+ readonly root: string;
228
+ readonly statusReason: NonNullable<ProcessCrashRecoveryState["statusReason"]>;
229
+ readonly files: readonly string[];
230
+ };
231
+
190
232
  type RequiredProcessCrashDemand = {
191
- key: string;
192
- recovery: ProcessCrashRecoveryState;
233
+ snapshot: ProcessCrashRecoverySnapshot;
234
+ failedFiles: readonly string[];
235
+ };
236
+
237
+ type ImmediateProcessCrashDemand = {
238
+ route: ProcessCrashRecoveryRouteResult;
193
239
  failedFiles: readonly string[];
240
+ reportOnly: boolean;
194
241
  };
195
242
 
243
+ type ProcessCrashDemandSelection = {
244
+ immediate: ImmediateProcessCrashDemand[];
245
+ required: RequiredProcessCrashDemand[];
246
+ };
247
+
248
+ type ProcessCrashRecoveryTelemetryOutcome =
249
+ | "attempt"
250
+ | "success"
251
+ | "failure"
252
+ | "exhausted"
253
+ | "cancelled";
254
+
196
255
  // ── LspManager ────────────────────────────────────────────────────────
197
256
  export class LspManager {
198
257
  /** Active clients keyed by "serverName:root" */
@@ -201,14 +260,14 @@ export class LspManager {
201
260
  private unavailable = new Map<string, UnavailableReason>();
202
261
  /** Memoized per-command availability of LSP server binaries on PATH */
203
262
  private commandAvailability = new Map<string, boolean>();
204
- /** Guards against concurrent client creation for the same server:root key */
263
+ /** Guards against concurrent client creation for the same server:root key. */
205
264
  private pendingStarts = new Map<string, Promise<LspClient | null>>();
265
+ /** Shared diagnostic-recovery restart for each replaceable client route. */
266
+ private pendingRestarts = new Map<string, Promise<{ files: string[]; restarted: boolean }>>();
206
267
  /** Monotonic ownership generation for each replaceable client route. */
207
268
  private clientGenerations = new Map<string, number>();
208
269
  /** Preferred project roots discovered by proactive scan or lazy startup */
209
270
  private knownRoots = new Map<string, string[]>();
210
- /** User-configured gitignore-style exclude patterns */
211
- private excludePatterns: string[] = [];
212
271
  /** Monotonic workspace invalidation generation advanced by each invalidation event. */
213
272
  private invalidationEpoch = 0;
214
273
  /** Invalidation generation in which each route last received a recovery restart. */
@@ -231,17 +290,19 @@ export class LspManager {
231
290
  private readonly config: LspConfig,
232
291
  private readonly cwd: string,
233
292
  private readonly onLifecycleTransition?: ManagerLifecycleListener,
293
+ private readonly automaticPathPolicy: AutomaticLspPathPolicy = createDefaultAutomaticLspPathPolicy(
294
+ cwd,
295
+ ),
234
296
  ) {}
235
297
  getCwd(): string {
236
298
  return this.cwd;
237
299
  }
238
- setExcludePatterns(patterns: string[]): void {
239
- this.excludePatterns = patterns;
240
- }
241
300
 
242
301
  // ── Public API ────────────────────────────────────────────────────
243
302
  registerDetectedServers(detected: DetectedProjectServer[]): void {
244
- this.knownRoots = projectRoots.buildKnownRootsMap(detected);
303
+ this.knownRoots = projectRoots.buildKnownRootsMap(
304
+ detected.filter((entry) => this.automaticPathPolicy.isEligible(entry.root, "directory")),
305
+ );
245
306
  }
246
307
  /** Check whether a file path has an available LSP server for explicit semantic operations. */
247
308
  canServeFile(filePath: string): boolean {
@@ -252,18 +313,60 @@ export class LspManager {
252
313
  }
253
314
 
254
315
  /**
255
- * Check whether a file should participate in runtime guidance and diagnostics.
256
- * This is stricter than {@link canServeFile} and intentionally filters dependency
257
- * and tsconfig-excluded paths from UI/context behavior.
316
+ * Return an exhausted process-crash outcome for a file readiness result.
317
+ * This is a passive read and never starts recovery. A crashed or pending
318
+ * route has no final result until an explicit operation observes it.
319
+ */
320
+ getProcessCrashRecoveryReportForFile(filePath: string): ProcessCrashRecoveryReport | null {
321
+ const route = this.resolveFileRoute(resolveSessionPath(this.cwd, filePath));
322
+ const recovery = route ? this.processCrashRecoveries.get(route.key) : undefined;
323
+ if (!route || recovery?.statusReason !== "process-crash-recovery-exhausted") return null;
324
+ return buildProcessCrashRecoveryReport(
325
+ [
326
+ {
327
+ name: route.serverName,
328
+ root: route.root,
329
+ outcome: "recovery-exhausted",
330
+ },
331
+ ],
332
+ this.cwd,
333
+ );
334
+ }
335
+
336
+ /**
337
+ * Check whether a file should participate in automatic LSP work.
338
+ * This is stricter than {@link canServeFile} and applies the workspace path
339
+ * policy plus tsconfig diagnostic suppression.
258
340
  */
259
341
  isSupportedSourceFile(filePath: string): boolean {
260
- // Dependency directories are intentionally excluded from recent-path
261
- // tracking and diagnostic summaries (shouldIgnoreLspPath). Keep runtime
262
- // guidance activation consistent: reading or editing a file under
263
- // node_modules / .pnpm must not arm LSP guidance for dependency sources.
264
- if (shouldIgnoreLspPath(filePath, this.cwd)) return false;
342
+ if (!this.isAutomaticScopePath(filePath)) return false;
265
343
  return this.canServeFile(filePath);
266
344
  }
345
+
346
+ /** Inventory automatic workspace sentinel and source paths with the runtime policy. */
347
+ scanWorkspaceSentinels(options: WorkspaceSentinelScanOptions = {}): Map<string, number> {
348
+ return scanAutomaticWorkspaceSentinels(this.cwd, {
349
+ ...options,
350
+ policy: this.automaticPathPolicy,
351
+ });
352
+ }
353
+
354
+ /** Refresh the automatic workspace inventory with the runtime policy. */
355
+ syncWorkspaceSentinelSnapshot(
356
+ previous: Map<string, number>,
357
+ options: WorkspaceSentinelScanOptions = {},
358
+ ): WorkspaceSentinelSyncResult {
359
+ return syncAutomaticWorkspaceSentinelSnapshot(this.cwd, previous, {
360
+ ...options,
361
+ policy: this.automaticPathPolicy,
362
+ });
363
+ }
364
+
365
+ /** Apply automatic path exclusions plus diagnostic tsconfig suppression. */
366
+ private isAutomaticScopePath(filePath: string): boolean {
367
+ return !shouldIgnoreLspPath(filePath, this.cwd, this.automaticPathPolicy);
368
+ }
369
+
267
370
  private isServerCommandAvailable(command: string): boolean {
268
371
  // Only memoize positive lookups. A negative result may become stale if the
269
372
  // user installs the binary mid-session (e.g. `mise install`), and
@@ -431,6 +534,7 @@ export class LspManager {
431
534
  failedClient: client,
432
535
  files: this.trackedFilesForClient(client),
433
536
  pending: null,
537
+ failureMessage: undefined,
434
538
  });
435
539
  }
436
540
 
@@ -469,54 +573,192 @@ export class LspManager {
469
573
  /**
470
574
  * Recover all crashed routes selected by one evidence-producing operation.
471
575
  * Each route keeps its independent attempt budget and shared replacement.
576
+ * Selection uses one immutable route-fact snapshot so scope and retention
577
+ * facts cannot change halfway through the demand.
472
578
  */
473
579
  private async recoverProcessCrashDemand(
474
- selectFiles: (recovery: ProcessCrashRecoveryState) => readonly string[] | null,
580
+ selectFiles: (snapshot: ProcessCrashRecoverySnapshot) => readonly string[] | null,
475
581
  control?: CodeRequestControl,
582
+ options: { skipWhenNoRetainedFile?: boolean } = {},
476
583
  ): Promise<ProcessCrashDemandResult> {
477
- const required = Array.from(this.processCrashRecoveries.entries()).flatMap(
478
- ([key, recovery]): RequiredProcessCrashDemand[] => {
479
- if (recovery.statusReason === undefined) return [];
480
- const failedFiles = selectFiles(recovery);
481
- return failedFiles === null ? [] : [{ key, recovery, failedFiles }];
482
- },
584
+ const { immediate, required } = this.selectProcessCrashDemandRoutes(
585
+ this.snapshotProcessCrashRoutes(),
586
+ selectFiles,
587
+ options,
483
588
  );
484
- if (required.length === 0) return { hasSupport: false, failures: [], failedFiles: [] };
589
+ const hasSupport = immediate.length > 0 || required.length > 0;
590
+ if (required.length === 0) {
591
+ const failures = immediate
592
+ .filter((entry) => !entry.reportOnly)
593
+ .map((entry) => this.formatProcessCrashDemandFailure(entry.route, entry.route.outcome));
594
+ return {
595
+ hasSupport,
596
+ requiresFreshEvidence: false,
597
+ processCrashRecovery: buildProcessCrashRecoveryReport(
598
+ immediate.map(({ route }) => route),
599
+ this.cwd,
600
+ ),
601
+ failures,
602
+ failedFiles: Array.from(
603
+ new Set(
604
+ immediate
605
+ .filter((entry) => !entry.reportOnly)
606
+ .flatMap(({ failedFiles }) => failedFiles),
607
+ ),
608
+ ),
609
+ };
610
+ }
485
611
 
486
612
  await Promise.all(
487
- required.map(async ({ key, recovery }) => {
488
- const serverConfig = this.config.servers[recovery.failedClient.name];
613
+ required.map(async ({ snapshot }) => {
614
+ const serverConfig = this.config.servers[snapshot.name];
489
615
  if (!serverConfig) return;
490
616
  await this.acquireProcessCrashReplacement(
491
- recovery,
617
+ snapshot.recovery,
492
618
  {
493
- serverName: recovery.failedClient.name,
619
+ serverName: snapshot.name,
494
620
  serverConfig,
495
- root: recovery.failedClient.root,
496
- key,
621
+ root: snapshot.root,
622
+ key: snapshot.key,
497
623
  },
498
624
  control,
499
625
  );
500
626
  }),
501
627
  );
502
628
 
503
- const failedDemands = required.filter(({ recovery }) => recovery.statusReason !== undefined);
629
+ const observedRequired = required.filter(
630
+ ({ snapshot }) => !this.isProcessCrashDemandCancelled(snapshot),
631
+ );
632
+ const failedDemands = observedRequired.filter(
633
+ ({ snapshot }) => snapshot.recovery.statusReason !== undefined,
634
+ );
635
+ const recoveredRoutes = observedRequired.map(
636
+ ({ snapshot }): ProcessCrashRecoveryRouteResult => ({
637
+ name: snapshot.name,
638
+ root: snapshot.root,
639
+ outcome: snapshot.recovery.statusReason === undefined ? "recovered" : "recovery-failed",
640
+ ...(snapshot.recovery.statusReason === undefined || !snapshot.recovery.failureMessage
641
+ ? {}
642
+ : { failureMessage: snapshot.recovery.failureMessage }),
643
+ }),
644
+ );
645
+ const failedImmediate = immediate.filter((entry) => !entry.reportOnly);
504
646
  return {
505
- hasSupport: true,
506
- failures: failedDemands.map(({ recovery }) => this.formatProcessCrashDemandFailure(recovery)),
507
- failedFiles: Array.from(new Set(failedDemands.flatMap(({ failedFiles }) => failedFiles))),
647
+ hasSupport,
648
+ requiresFreshEvidence: observedRequired.length > 0,
649
+ processCrashRecovery: buildProcessCrashRecoveryReport(
650
+ [...immediate.map(({ route }) => route), ...recoveredRoutes],
651
+ this.cwd,
652
+ ),
653
+ failures: [
654
+ ...failedImmediate.map((entry) =>
655
+ this.formatProcessCrashDemandFailure(entry.route, entry.route.outcome),
656
+ ),
657
+ ...failedDemands.map(({ snapshot }) =>
658
+ this.formatProcessCrashDemandFailure(snapshot.recovery, "recovery-failed"),
659
+ ),
660
+ ],
661
+ failedFiles: Array.from(
662
+ new Set([
663
+ ...failedImmediate.flatMap(({ failedFiles }) => failedFiles),
664
+ ...failedDemands.flatMap(({ failedFiles }) => failedFiles),
665
+ ]),
666
+ ),
508
667
  };
509
668
  }
510
669
 
511
- private formatProcessCrashDemandFailure(recovery: ProcessCrashRecoveryState): string {
512
- const root = (path.relative(this.cwd, recovery.failedClient.root) || ".").replaceAll("\\", "/");
670
+ private isProcessCrashDemandCancelled(snapshot: ProcessCrashRecoverySnapshot): boolean {
671
+ return (
672
+ snapshot.recovery.statusReason !== undefined &&
673
+ (this.shuttingDown || this.processCrashRecoveries.get(snapshot.key) !== snapshot.recovery)
674
+ );
675
+ }
676
+
677
+ private selectProcessCrashDemandRoutes(
678
+ snapshots: readonly ProcessCrashRecoverySnapshot[],
679
+ selectFiles: (snapshot: ProcessCrashRecoverySnapshot) => readonly string[] | null,
680
+ options: { skipWhenNoRetainedFile?: boolean },
681
+ ): ProcessCrashDemandSelection {
682
+ const immediate: ImmediateProcessCrashDemand[] = [];
683
+ const required: RequiredProcessCrashDemand[] = [];
684
+
685
+ for (const snapshot of snapshots) {
686
+ const failedFiles = selectFiles(snapshot);
687
+ if (failedFiles === null) continue;
688
+
689
+ const exhausted = snapshot.statusReason === "process-crash-recovery-exhausted";
690
+ const noRetainedFile = failedFiles.length === 0;
691
+ if (exhausted) {
692
+ immediate.push({
693
+ route: {
694
+ name: snapshot.name,
695
+ root: snapshot.root,
696
+ outcome: "recovery-exhausted",
697
+ },
698
+ failedFiles,
699
+ reportOnly: options.skipWhenNoRetainedFile === true && noRetainedFile,
700
+ });
701
+ continue;
702
+ }
703
+ if (
704
+ options.skipWhenNoRetainedFile === true &&
705
+ noRetainedFile &&
706
+ snapshot.statusReason === "process-crashed"
707
+ ) {
708
+ immediate.push({
709
+ route: {
710
+ name: snapshot.name,
711
+ root: snapshot.root,
712
+ outcome: "skipped-no-retained-file",
713
+ },
714
+ failedFiles,
715
+ reportOnly: true,
716
+ });
717
+ continue;
718
+ }
719
+ required.push({ snapshot, failedFiles });
720
+ }
721
+ return { immediate, required };
722
+ }
723
+
724
+ private snapshotProcessCrashRoutes(): ProcessCrashRecoverySnapshot[] {
725
+ return Array.from(this.processCrashRecoveries.entries()).flatMap(([key, recovery]) => {
726
+ if (recovery.statusReason === undefined) return [];
727
+ return [
728
+ {
729
+ key,
730
+ recovery,
731
+ failedClient: recovery.failedClient,
732
+ name: recovery.failedClient.name,
733
+ root: recovery.failedClient.root,
734
+ statusReason: recovery.statusReason,
735
+ files: [...recovery.files],
736
+ },
737
+ ];
738
+ });
739
+ }
740
+
741
+ private formatProcessCrashDemandFailure(
742
+ recovery: ProcessCrashRecoveryState | ProcessCrashRecoveryRouteResult,
743
+ outcome?: ProcessCrashRecoveryRouteResult["outcome"],
744
+ ): string {
745
+ const isRouteResult = "outcome" in recovery;
746
+ const name = isRouteResult ? recovery.name : recovery.failedClient.name;
747
+ const root = isRouteResult
748
+ ? recovery.root
749
+ : (path.relative(this.cwd, recovery.failedClient.root) || ".").replaceAll("\\", "/");
750
+ const routeOutcome =
751
+ outcome ??
752
+ (isRouteResult
753
+ ? recovery.outcome
754
+ : recovery.statusReason === "process-crash-recovery-exhausted"
755
+ ? "recovery-exhausted"
756
+ : "recovery-failed");
513
757
  const reason =
514
- recovery.statusReason === "process-crash-recovery-exhausted"
758
+ routeOutcome === "recovery-exhausted"
515
759
  ? "process recovery exhausted; reload required"
516
- : recovery.statusReason === "process-crash-recovery-pending"
517
- ? "process recovery in progress"
518
- : "process crashed";
519
- return `${recovery.failedClient.name} @ ${root} is unavailable — ${reason}.`;
760
+ : "process recovery failed";
761
+ return `${name} @ ${root} is unavailable — ${reason}.`;
520
762
  }
521
763
 
522
764
  private startProcessCrashRecovery(
@@ -580,6 +822,7 @@ export class LspManager {
580
822
 
581
823
  this.unavailable.delete(key);
582
824
  recovery.statusReason = undefined;
825
+ recovery.failureMessage = undefined;
583
826
  this.publishLifecycle("recovery");
584
827
  this.restoreTrackedDocuments(started, recovery.files);
585
828
  this.recordProcessCrashRecoveryEvent(
@@ -626,7 +869,7 @@ export class LspManager {
626
869
  // LspClient's initialize request already has the 30-second transport
627
870
  // bound. Do not apply the 5-second diagnostic-restart bound here.
628
871
  await replacement.start();
629
- } catch {
872
+ } catch (error) {
630
873
  if (this.isProcessCrashRecoveryCancelled(recovery, route.key)) {
631
874
  await replacement.forceKill().catch(() => {});
632
875
  this.recordProcessCrashRecoveryEvent(
@@ -638,7 +881,7 @@ export class LspManager {
638
881
  return null;
639
882
  }
640
883
  if (recovery.statusReason !== "process-crash-recovery-exhausted") {
641
- this.markProcessCrashRecoveryFailed(recovery, route.key, replacement);
884
+ this.markProcessCrashRecoveryFailed(recovery, route.key, replacement, error);
642
885
  this.recordProcessCrashRecoveryEvent(
643
886
  route.serverName,
644
887
  route.root,
@@ -686,8 +929,13 @@ export class LspManager {
686
929
  recovery: ProcessCrashRecoveryState,
687
930
  key: string,
688
931
  replacement: LspClient,
932
+ error: unknown,
689
933
  ): void {
690
934
  recovery.statusReason = "process-crash-recovery-exhausted";
935
+ recovery.failureMessage =
936
+ error instanceof Error && error.message
937
+ ? boundProcessCrashFailureMessage(error.message)
938
+ : undefined;
691
939
  this.unavailable.set(key, "start-failed");
692
940
  if (this.clients.get(key) === replacement) this.clients.delete(key);
693
941
  // A failed initialize may still leave a child process alive. The
@@ -699,7 +947,7 @@ export class LspManager {
699
947
  private recordProcessCrashRecoveryEvent(
700
948
  serverName: string,
701
949
  root: string,
702
- outcome: ProcessCrashRecoveryOutcome,
950
+ outcome: ProcessCrashRecoveryTelemetryOutcome,
703
951
  startedAt?: number,
704
952
  ): void {
705
953
  try {
@@ -808,32 +1056,60 @@ export class LspManager {
808
1056
  const seen = new Set<string>();
809
1057
 
810
1058
  for (const filePath of filePaths) {
811
- const client = this.getExistingClientForFile(filePath);
812
- if (!client) continue;
813
- if (options?.pushOnly && client.hasDiagnosticProvider) continue;
814
-
815
- const key = clientKey(client.name, client.root);
816
- const processRecovery = this.processCrashRecoveries.get(key);
817
- if (processRecovery?.pending) continue;
818
- if (seen.has(key)) continue;
819
- seen.add(key);
820
- if (this.recoveryRestartEpochs.get(key) === this.invalidationEpoch) continue;
1059
+ const candidate = this.getRestartCandidate(filePath, options?.pushOnly === true, seen);
1060
+ if (!candidate) continue;
1061
+ const { client, key } = candidate;
821
1062
 
822
1063
  // Observe cancellation between route restarts so a cancelled pass does
823
1064
  // not keep restarting further clients.
824
1065
  throwIfCodeRequestInterrupted(options?.control);
825
- const result = await this.restartClient(client);
826
- this.recoveryRestartEpochs.set(key, this.invalidationEpoch);
1066
+ const restartPromise = this.pendingRestarts.get(key) ?? this.startSharedRestart(client, key);
1067
+ const result = await raceRequestControl(restartPromise, options?.control);
827
1068
  restarted.push({ key, serverName: client.name, ...result });
828
1069
  }
829
1070
 
830
1071
  return restarted;
831
1072
  }
832
1073
 
1074
+ private startSharedRestart(
1075
+ client: LspClient,
1076
+ key: string,
1077
+ ): Promise<{ files: string[]; restarted: boolean }> {
1078
+ const restartEpoch = this.invalidationEpoch;
1079
+ const restartPromise = this.restartClient(client).then((result) => {
1080
+ this.recoveryRestartEpochs.set(key, restartEpoch);
1081
+ return result;
1082
+ });
1083
+ this.pendingRestarts.set(key, restartPromise);
1084
+ void restartPromise
1085
+ .finally(() => {
1086
+ if (this.pendingRestarts.get(key) === restartPromise) this.pendingRestarts.delete(key);
1087
+ })
1088
+ .catch(() => {});
1089
+ return restartPromise;
1090
+ }
1091
+
1092
+ private getRestartCandidate(
1093
+ filePath: string,
1094
+ pushOnly: boolean,
1095
+ seen: Set<string>,
1096
+ ): { client: LspClient; key: string } | null {
1097
+ const client = this.getExistingClientForFile(filePath);
1098
+ if (!client || (pushOnly && client.hasDiagnosticProvider)) return null;
1099
+
1100
+ const key = clientKey(client.name, client.root);
1101
+ if (this.processCrashRecoveries.get(key)?.pending || seen.has(key)) return null;
1102
+ seen.add(key);
1103
+ if (this.recoveryRestartEpochs.get(key) === this.invalidationEpoch) return null;
1104
+ return { client, key };
1105
+ }
1106
+
833
1107
  private async restartClient(client: LspClient): Promise<{ files: string[]; restarted: boolean }> {
834
1108
  const key = clientKey(client.name, client.root);
835
1109
  const serverConfig = this.config.servers[client.name];
836
- if (!serverConfig) return { files: [], restarted: false };
1110
+ if (!serverConfig || this.clients.get(key) !== client) {
1111
+ return { files: [], restarted: false };
1112
+ }
837
1113
 
838
1114
  const openFiles = client.openFiles;
839
1115
  const trackedDiagnosticFiles = client
@@ -846,6 +1122,7 @@ export class LspManager {
846
1122
  // Ignore shutdown failures when forcing a restart.
847
1123
  }
848
1124
 
1125
+ if (this.clients.get(key) !== client) return { files, restarted: false };
849
1126
  this.clients.delete(key);
850
1127
  this.unavailable.delete(key);
851
1128
  this.clearWarmedWorkspaceSymbolProjects(client.name, client.root);
@@ -862,12 +1139,18 @@ export class LspManager {
862
1139
  RECOVERY_CLIENT_STARTUP_BOUND_MS,
863
1140
  "replacement client startup bound exceeded",
864
1141
  );
1142
+ if (this.clients.get(key) !== replacement || this.shuttingDown) {
1143
+ void replacement.forceKill().catch(() => {});
1144
+ return { files, restarted: false };
1145
+ }
865
1146
  this.restoreTrackedDocuments(replacement, files);
866
1147
  this.clearUnconsumedProcessCrashState(key, client);
867
1148
  return { files, restarted: true };
868
1149
  } catch {
869
- this.clients.delete(key);
870
- this.unavailable.set(key, "start-failed");
1150
+ if (this.clients.get(key) === replacement) {
1151
+ this.clients.delete(key);
1152
+ this.unavailable.set(key, "start-failed");
1153
+ }
871
1154
  // A bound-exceeded start may have spawned a live process; terminate
872
1155
  // the tree so the orphan cannot outlive the failed restart.
873
1156
  void replacement.forceKill().catch(() => {});
@@ -893,6 +1176,7 @@ export class LspManager {
893
1176
  unavailableReason:
894
1177
  this.getUnavailableReason(key, this.config.servers[serverName]?.command) ?? undefined,
895
1178
  statusReason: this.processCrashRecoveries.get(key)?.statusReason,
1179
+ includeOpenFile: (file) => this.isAutomaticScopePath(file),
896
1180
  },
897
1181
  this.cwd,
898
1182
  );
@@ -900,6 +1184,7 @@ export class LspManager {
900
1184
  getKnownProjectServers(detected: DetectedProjectServer[]): ProjectServerInfo[] {
901
1185
  const known = new Map<string, DetectedProjectServer>();
902
1186
  for (const entry of detected) {
1187
+ if (!this.automaticPathPolicy.isEligible(entry.root, "directory")) continue;
903
1188
  known.set(clientKey(entry.name, entry.root), entry);
904
1189
  }
905
1190
  for (const client of this.clients.values()) {
@@ -938,20 +1223,101 @@ export class LspManager {
938
1223
  async waitUntilFileReady(
939
1224
  filePath: string,
940
1225
  control?: CodeRequestControl,
941
- ): Promise<LspClient | null> {
1226
+ onProcessCrashRecovery?: (report: ProcessCrashRecoveryReport) => void,
1227
+ ): Promise<FileReadinessResult> {
942
1228
  throwIfCodeRequestInterrupted(control);
943
1229
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
1230
+ const route = this.resolveFileRoute(resolvedPath);
1231
+ const recovery = route ? this.processCrashRecoveries.get(route.key) : undefined;
1232
+ const statusAtStart = recovery?.statusReason;
1233
+ const processCrashRecoveryRequested = statusAtStart !== undefined;
944
1234
  const client = await this.getClientForFile(resolvedPath, {
945
1235
  recoverProcessCrash: true,
946
1236
  control,
947
1237
  });
948
- if (!client) return null;
949
- // The caller's wait stops on cancellation even though the shared
950
- // readiness state keeps its own lifecycle.
951
- await client.getReady(control);
952
- await this.warmSemanticProject(client, resolvedPath, true, control);
953
- throwIfCodeRequestInterrupted(control);
954
- return client;
1238
+ const processCrashRecovery = this.buildFileProcessCrashRecoveryReport(
1239
+ route,
1240
+ recovery,
1241
+ statusAtStart,
1242
+ client,
1243
+ );
1244
+ if (processCrashRecoveryRequested) onProcessCrashRecovery?.(processCrashRecovery);
1245
+ if (!client) return { client: null, processCrashRecovery };
1246
+ try {
1247
+ await client.getReady(control);
1248
+ await this.warmSemanticProject(client, resolvedPath, true, control);
1249
+ throwIfCodeRequestInterrupted(control);
1250
+ } catch (error) {
1251
+ const failedRecovery = this.buildFileProcessCrashFailureReport(
1252
+ route,
1253
+ recovery,
1254
+ statusAtStart,
1255
+ error,
1256
+ );
1257
+ if (failedRecovery) {
1258
+ onProcessCrashRecovery?.(failedRecovery);
1259
+ return { client: null, processCrashRecovery: failedRecovery };
1260
+ }
1261
+ throw error;
1262
+ }
1263
+ return { client, processCrashRecovery };
1264
+ }
1265
+
1266
+ private buildFileProcessCrashRecoveryReport(
1267
+ route: FileRoute | null,
1268
+ recovery: ProcessCrashRecoveryState | undefined,
1269
+ statusAtStart: ProcessCrashRecoveryState["statusReason"],
1270
+ client: LspClient | null,
1271
+ ): ProcessCrashRecoveryReport {
1272
+ if (statusAtStart === undefined) return emptyProcessCrashRecoveryReport();
1273
+ return buildProcessCrashRecoveryReport(
1274
+ [
1275
+ {
1276
+ name: route?.serverName ?? "unknown",
1277
+ root: route?.root ?? this.cwd,
1278
+ outcome:
1279
+ statusAtStart === "process-crash-recovery-exhausted"
1280
+ ? "recovery-exhausted"
1281
+ : client
1282
+ ? "recovered"
1283
+ : "recovery-failed",
1284
+ ...(client || !recovery?.failureMessage
1285
+ ? {}
1286
+ : { failureMessage: recovery.failureMessage }),
1287
+ },
1288
+ ],
1289
+ this.cwd,
1290
+ );
1291
+ }
1292
+
1293
+ private buildFileProcessCrashFailureReport(
1294
+ route: FileRoute | null,
1295
+ recovery: ProcessCrashRecoveryState | undefined,
1296
+ statusAtStart: ProcessCrashRecoveryState["statusReason"],
1297
+ error: unknown,
1298
+ ): ProcessCrashRecoveryReport | null {
1299
+ if (
1300
+ statusAtStart === undefined ||
1301
+ statusAtStart === "process-crash-recovery-exhausted" ||
1302
+ recovery?.statusReason !== "process-crash-recovery-exhausted"
1303
+ ) {
1304
+ return null;
1305
+ }
1306
+ return buildProcessCrashRecoveryReport(
1307
+ [
1308
+ {
1309
+ name: route?.serverName ?? "unknown",
1310
+ root: route?.root ?? this.cwd,
1311
+ outcome: "recovery-failed",
1312
+ ...(error instanceof Error && error.message
1313
+ ? { failureMessage: boundProcessCrashFailureMessage(error.message) }
1314
+ : recovery.failureMessage
1315
+ ? { failureMessage: recovery.failureMessage }
1316
+ : {}),
1317
+ },
1318
+ ],
1319
+ this.cwd,
1320
+ );
955
1321
  }
956
1322
 
957
1323
  /**
@@ -989,6 +1355,7 @@ export class LspManager {
989
1355
  firstReady.root,
990
1356
  serverConfig.rootMarkers,
991
1357
  serverConfig.fileTypes,
1358
+ { policy: this.automaticPathPolicy },
992
1359
  )[0];
993
1360
  if (target) await this.warmSemanticProject(firstReady, target.file, false, control);
994
1361
  }
@@ -1060,6 +1427,7 @@ export class LspManager {
1060
1427
  */
1061
1428
  noteWorkspaceChanges(changes: FileEvent[]): void {
1062
1429
  if (changes.length > 0) this.invalidationEpoch++;
1430
+ invalidateProjectConfigCaches(changes);
1063
1431
  this.notifyWorkspaceFileChanges(changes);
1064
1432
  }
1065
1433
 
@@ -1105,34 +1473,63 @@ export class LspManager {
1105
1473
  processCrashDemand?: { scopes?: readonly string[] };
1106
1474
  control?: CodeRequestControl;
1107
1475
  }): Promise<WorkspaceRecoveryResult> {
1108
- const demand = options?.processCrashDemand
1109
- ? await this.recoverProcessCrashDemand((recovery) => {
1110
- const files = this.processCrashDiagnosticFiles(recovery, options.processCrashDemand);
1111
- return files.length > 0 ? files : null;
1112
- }, options.control)
1113
- : { hasSupport: false, failures: [], failedFiles: [] };
1114
- const recoveryOptions = demand.hasSupport
1476
+ const processCrashDemand = options?.processCrashDemand;
1477
+ const demand = processCrashDemand
1478
+ ? await this.recoverProcessCrashDemand(
1479
+ (snapshot) => {
1480
+ if (!this.processCrashDiagnosticRouteInScope(snapshot, processCrashDemand)) {
1481
+ return null;
1482
+ }
1483
+ return this.processCrashDiagnosticFiles(snapshot, processCrashDemand);
1484
+ },
1485
+ options?.control,
1486
+ { skipWhenNoRetainedFile: true },
1487
+ )
1488
+ : {
1489
+ hasSupport: false,
1490
+ requiresFreshEvidence: false,
1491
+ processCrashRecovery: emptyProcessCrashRecoveryReport(),
1492
+ failures: [],
1493
+ failedFiles: [],
1494
+ };
1495
+ const recoveryOptions = demand.requiresFreshEvidence
1115
1496
  ? { ...options, initialEvidence: undefined }
1116
1497
  : options;
1117
1498
  const result = await recoverWorkspaceDiagnosticsImpl(this, recoveryOptions);
1118
- return this.addProcessCrashDiagnosticEvidence(result, demand);
1499
+ return this.addProcessCrashDiagnosticEvidence(
1500
+ { ...result, processCrashRecovery: demand.processCrashRecovery },
1501
+ demand,
1502
+ );
1503
+ }
1504
+
1505
+ private processCrashDiagnosticRouteInScope(
1506
+ snapshot: ProcessCrashRecoverySnapshot,
1507
+ demand: { scopes?: readonly string[] },
1508
+ ): boolean {
1509
+ return this.processCrashDiagnosticScopes(demand).some(
1510
+ (scope) =>
1511
+ projectRoots.isWithinOrEqual(scope, snapshot.root) ||
1512
+ projectRoots.isWithinOrEqual(snapshot.root, scope),
1513
+ );
1119
1514
  }
1120
1515
 
1121
1516
  private processCrashDiagnosticFiles(
1122
- recovery: ProcessCrashRecoveryState,
1123
- demand: { scopes?: readonly string[] } | undefined,
1517
+ snapshot: ProcessCrashRecoverySnapshot,
1518
+ demand: { scopes?: readonly string[] },
1124
1519
  ): string[] {
1125
- if (!demand) return [];
1126
- const scopes = demand.scopes?.map((scope) => resolveSessionPath(this.cwd, scope));
1127
- return recovery.files.filter(
1520
+ const scopes = this.processCrashDiagnosticScopes(demand);
1521
+ return snapshot.files.filter(
1128
1522
  (file) =>
1129
1523
  this.isDiagnosticFile(file) &&
1130
- (!scopes ||
1131
- scopes.length === 0 ||
1132
- scopes.some((scope) => projectRoots.isWithinOrEqual(scope, file))),
1524
+ scopes.some((scope) => projectRoots.isWithinOrEqual(scope, file)),
1133
1525
  );
1134
1526
  }
1135
1527
 
1528
+ private processCrashDiagnosticScopes(demand: { scopes?: readonly string[] }): string[] {
1529
+ const scopes = demand.scopes?.map((scope) => resolveSessionPath(this.cwd, scope));
1530
+ return scopes && scopes.length > 0 ? scopes : [this.cwd];
1531
+ }
1532
+
1136
1533
  private addProcessCrashDiagnosticEvidence(
1137
1534
  result: WorkspaceRecoveryResult,
1138
1535
  demand: ProcessCrashDemandResult,
@@ -1184,6 +1581,7 @@ export class LspManager {
1184
1581
  this.warmedWorkspaceSymbolProjects.clear();
1185
1582
  this.warmedSemanticProjects.clear();
1186
1583
  this.pendingWarmProbes.clear();
1584
+ this.pendingRestarts.clear();
1187
1585
  }
1188
1586
  /** Get status of all servers. */
1189
1587
  getRunningClientCount(): number {
@@ -1197,13 +1595,9 @@ export class LspManager {
1197
1595
  .map((client) => client.name);
1198
1596
  }
1199
1597
 
1200
- /** Check whether a path belongs to the configured diagnostic evidence scope. */
1598
+ /** Check whether a path belongs to the automatic diagnostic evidence scope. */
1201
1599
  isDiagnosticFile(filePath: string): boolean {
1202
- const relative = path.relative(this.cwd, path.resolve(this.cwd, filePath));
1203
- return (
1204
- !shouldIgnoreLspPath(relative, this.cwd) &&
1205
- !isExcludedByPattern(relative, this.excludePatterns)
1206
- );
1600
+ return this.isAutomaticScopePath(filePath);
1207
1601
  }
1208
1602
 
1209
1603
  getDiagnosticEvidence(): DiagnosticEvidenceSummary {
@@ -1218,7 +1612,7 @@ export class LspManager {
1218
1612
  name: client.name,
1219
1613
  status: client.status === "running" ? "running" : "error",
1220
1614
  root: client.root,
1221
- openFiles: client.openFiles,
1615
+ openFiles: client.openFiles.filter((file) => this.isAutomaticScopePath(file)),
1222
1616
  });
1223
1617
  }
1224
1618
  return { servers };
@@ -1259,8 +1653,7 @@ export class LspManager {
1259
1653
  const openFiles = activeServers.get(server.name) ?? new Set<string>();
1260
1654
  for (const file of server.openFiles) {
1261
1655
  const relativeFile = displayRelativeFilePath(file, this.cwd);
1262
- if (shouldIgnoreLspPath(relativeFile, this.cwd)) continue;
1263
- if (isExcludedByPattern(relativeFile, this.excludePatterns)) continue;
1656
+ if (!this.isAutomaticScopePath(relativeFile)) continue;
1264
1657
  openFiles.add(relativeFile);
1265
1658
  }
1266
1659
  activeServers.set(server.name, openFiles);
@@ -1286,7 +1679,7 @@ export class LspManager {
1286
1679
  .map((entry) => ({
1287
1680
  ...entry,
1288
1681
  openFiles: entry.openFiles.filter((file) =>
1289
- isPathRelevant(file, normalizedPaths, this.cwd),
1682
+ isPathRelevant(file, normalizedPaths, this.cwd, this.automaticPathPolicy),
1290
1683
  ),
1291
1684
  }))
1292
1685
  .filter((entry) => entry.openFiles.length > 0);
@@ -1318,7 +1711,9 @@ export class LspManager {
1318
1711
  const fileDiags = new Map<string, { errors: number; warnings: number }>();
1319
1712
  for (const snapshot of snapshots) {
1320
1713
  for (const entry of snapshot.entries) {
1321
- collectDiagnosticSummaryCounts(fileDiags, entry, this.cwd, this.excludePatterns);
1714
+ collectDiagnosticSummaryCounts(fileDiags, entry, this.cwd, (file) =>
1715
+ this.isAutomaticScopePath(file),
1716
+ );
1322
1717
  }
1323
1718
  }
1324
1719
 
@@ -1332,7 +1727,7 @@ export class LspManager {
1332
1727
  entries: collectOutstandingDiagnosticsDetailed(
1333
1728
  snapshots.map((snapshot) => snapshot.entries),
1334
1729
  this.cwd,
1335
- this.excludePatterns,
1730
+ (file) => this.isAutomaticScopePath(file),
1336
1731
  maxSeverity,
1337
1732
  ),
1338
1733
  current,
@@ -1364,7 +1759,9 @@ export class LspManager {
1364
1759
  });
1365
1760
  }
1366
1761
  for (const entry of snapshot.entries) {
1367
- collectDiagnosticSummaryCounts(fileDiags, entry, this.cwd, this.excludePatterns);
1762
+ collectDiagnosticSummaryCounts(fileDiags, entry, this.cwd, (file) =>
1763
+ this.isAutomaticScopePath(file),
1764
+ );
1368
1765
  }
1369
1766
  }
1370
1767
  const evidence = this.toWorkspaceDiagnosticEvidence(evidenceDocuments);
@@ -1396,7 +1793,7 @@ export class LspManager {
1396
1793
  entries: collectOutstandingDiagnosticSummaryEntries(
1397
1794
  snapshots,
1398
1795
  this.cwd,
1399
- this.excludePatterns,
1796
+ (file) => this.isAutomaticScopePath(file),
1400
1797
  maxSeverity,
1401
1798
  ),
1402
1799
  current:
@@ -1413,7 +1810,7 @@ export class LspManager {
1413
1810
  if (normalizedPaths.length === 0) return null;
1414
1811
  return formatOutstandingDiagnosticsSummaryText(
1415
1812
  this.getOutstandingDiagnosticSummary(maxSeverity).filter((entry) =>
1416
- isPathRelevant(entry.file, normalizedPaths, this.cwd),
1813
+ isPathRelevant(entry.file, normalizedPaths, this.cwd, this.automaticPathPolicy),
1417
1814
  ),
1418
1815
  maxFiles,
1419
1816
  );
@@ -1443,7 +1840,7 @@ export class LspManager {
1443
1840
  entries: collectOutstandingDiagnosticsDetailed(
1444
1841
  snapshots.map((snapshot) => snapshot.entries),
1445
1842
  this.cwd,
1446
- this.excludePatterns,
1843
+ (file) => this.isAutomaticScopePath(file),
1447
1844
  maxSeverity,
1448
1845
  ),
1449
1846
  current:
@@ -1472,6 +1869,7 @@ export class LspManager {
1472
1869
  for (const client of this.clients.values()) {
1473
1870
  for (const document of client.getDiagnosticSnapshot().documents) {
1474
1871
  const file = relativeFilePathFromUri(document.uri, this.cwd);
1872
+ if (!this.isAutomaticScopePath(file)) continue;
1475
1873
  totalFiles++;
1476
1874
  accumulateScopeDecision(accumulator, file, getFileScopeDecision(file, this.cwd));
1477
1875
  }
@@ -1491,8 +1889,7 @@ export class LspManager {
1491
1889
  const byFile = new Map<string, DiagnosticEvidenceDocument>();
1492
1890
  for (const document of documents) {
1493
1891
  const file = path.relative(this.cwd, path.resolve(this.cwd, document.file));
1494
- if (shouldIgnoreLspPath(file, this.cwd)) continue;
1495
- if (isExcludedByPattern(file, this.excludePatterns)) continue;
1892
+ if (!this.isAutomaticScopePath(file)) continue;
1496
1893
  const existing = byFile.get(file);
1497
1894
  if (!existing || evidenceStatusRank(document.status) > evidenceStatusRank(existing.status)) {
1498
1895
  byFile.set(file, { file, status: document.status });
@@ -1517,9 +1914,9 @@ export class LspManager {
1517
1914
  );
1518
1915
  }
1519
1916
  const demand = await this.recoverProcessCrashDemand(
1520
- (recovery) =>
1521
- recovery.failedClient.serverCapabilities?.workspaceSymbolProvider &&
1522
- routeMatches(recovery.failedClient)
1917
+ (snapshot) =>
1918
+ snapshot.failedClient.serverCapabilities?.workspaceSymbolProvider &&
1919
+ routeMatches(snapshot.failedClient)
1523
1920
  ? []
1524
1921
  : null,
1525
1922
  control,
@@ -1539,7 +1936,10 @@ export class LspManager {
1539
1936
  }
1540
1937
 
1541
1938
  const warmed = await this.warmWorkspaceSymbolProjectsUntilResult({
1542
- findWarmTargets: findWorkspaceSymbolWarmTargets,
1939
+ findWarmTargets: (root, rootMarkers, fileTypes) =>
1940
+ findWorkspaceSymbolWarmTargets(root, rootMarkers, fileTypes, {
1941
+ policy: this.automaticPathPolicy,
1942
+ }),
1543
1943
  getWarmPosition: getWorkspaceSymbolWarmPosition,
1544
1944
  collect,
1545
1945
  routeMatches,
@@ -1637,28 +2037,21 @@ export class LspManager {
1637
2037
  for (const target of warmTargets) {
1638
2038
  const projectKey = this.workspaceSymbolProjectKey(client.name, target.projectRoot);
1639
2039
  if (this.warmedWorkspaceSymbolProjects.has(projectKey)) continue;
1640
- if (this.hasOpenFileInProject(client, target.projectRoot)) {
1641
- this.warmedWorkspaceSymbolProjects.add(projectKey);
1642
- continue;
1643
- }
1644
2040
 
1645
2041
  const openedClient = await this.ensureFileOpen(target.file);
1646
2042
  if (!openedClient) continue;
1647
2043
 
1648
2044
  // A cancelled pass must not mark projects warmed on its way out.
1649
2045
  throwIfCodeRequestInterrupted(control);
1650
- this.warmedWorkspaceSymbolProjects.add(projectKey);
1651
- warmedAny = true;
1652
-
1653
- const symbols = control
1654
- ? await openedClient.documentSymbols(target.file, control)
1655
- : await openedClient.documentSymbols(target.file);
1656
- if (symbols.kind !== "unavailable") {
1657
- const hoverPosition = getWarmPosition(symbols.data);
1658
- if (hoverPosition) {
1659
- if (control) await openedClient.hover(target.file, hoverPosition, control);
1660
- else await openedClient.hover(target.file, hoverPosition);
1661
- }
2046
+ const available = await this.probeDocumentSymbols(
2047
+ openedClient,
2048
+ target.file,
2049
+ getWarmPosition,
2050
+ control,
2051
+ );
2052
+ if (available) {
2053
+ this.warmedWorkspaceSymbolProjects.add(projectKey);
2054
+ warmedAny = true;
1662
2055
  }
1663
2056
 
1664
2057
  const collected = await collect(this.clients.values(), query);
@@ -1703,35 +2096,56 @@ export class LspManager {
1703
2096
  try {
1704
2097
  await raceRequestControl(probe, control);
1705
2098
  } finally {
1706
- this.pendingWarmProbes.delete(projectKey);
2099
+ if (this.pendingWarmProbes.get(projectKey) === probe) {
2100
+ this.pendingWarmProbes.delete(projectKey);
2101
+ }
1707
2102
  }
1708
2103
  }
1709
2104
 
1710
2105
  private async performWarmProbe(
1711
- _client: LspClient,
2106
+ client: LspClient,
1712
2107
  resolvedFile: string,
1713
2108
  projectKey: string,
1714
2109
  control?: CodeRequestControl,
1715
2110
  ): Promise<void> {
2111
+ const key = clientKey(client.name, client.root);
2112
+ if (this.clients.get(key) !== client) return;
1716
2113
  const openedClient = await this.ensureFileOpen(resolvedFile);
1717
- if (!openedClient) return;
1718
-
1719
- this.warmedSemanticProjects.add(projectKey);
2114
+ if (!openedClient || openedClient !== client || this.clients.get(key) !== client) return;
1720
2115
 
1721
2116
  // The probe is shared state for concurrent callers. One caller's
1722
2117
  // cancellation must not cancel the shared in-flight queries, so only the
1723
2118
  // Debug Operation ID is forwarded; each caller races its own wait above.
1724
2119
  const sharedControl = { operationId: control?.operationId };
1725
- const symbols = await openedClient.documentSymbols(resolvedFile, sharedControl);
1726
- if (symbols.kind === "unavailable") return;
1727
- const hoverPosition = getWorkspaceSymbolWarmPosition(symbols.data);
1728
- if (hoverPosition) {
1729
- await openedClient.hover(resolvedFile, hoverPosition, sharedControl);
2120
+ const available = await this.probeDocumentSymbols(
2121
+ openedClient,
2122
+ resolvedFile,
2123
+ getWorkspaceSymbolWarmPosition,
2124
+ sharedControl,
2125
+ );
2126
+ if (available && this.clients.get(key) === client) {
2127
+ this.warmedSemanticProjects.add(projectKey);
1730
2128
  }
1731
2129
  }
1732
2130
 
1733
- private hasOpenFileInProject(client: LspClient, projectRoot: string): boolean {
1734
- return client.openFiles.some((openFile) => projectRoots.isWithinOrEqual(projectRoot, openFile));
2131
+ private async probeDocumentSymbols(
2132
+ client: LspClient,
2133
+ file: string,
2134
+ getWarmPosition: (
2135
+ symbols: import("../config/types.ts").DocumentSymbol[] | SymbolInformation[] | null,
2136
+ ) => import("../config/types.ts").Position | null,
2137
+ control?: CodeRequestControl,
2138
+ ): Promise<boolean> {
2139
+ const symbols = control
2140
+ ? await client.documentSymbols(file, control)
2141
+ : await client.documentSymbols(file);
2142
+ if (symbols.kind === "unavailable") return false;
2143
+ const hoverPosition = getWarmPosition(symbols.data);
2144
+ if (hoverPosition) {
2145
+ if (control) await client.hover(file, hoverPosition, control);
2146
+ else await client.hover(file, hoverPosition);
2147
+ }
2148
+ return true;
1735
2149
  }
1736
2150
 
1737
2151
  private workspaceSymbolProjectKey(serverName: string, projectRoot: string): string {
@@ -1766,6 +2180,17 @@ export class LspManager {
1766
2180
  }
1767
2181
  }
1768
2182
 
2183
+ function invalidateProjectConfigCaches(changes: readonly FileEvent[]): void {
2184
+ for (const change of changes) {
2185
+ const configPath = uriToFile(change.uri);
2186
+ if (!isProjectConfigFileName(path.basename(configPath))) continue;
2187
+ invalidateTsconfigCacheForConfig(configPath);
2188
+ if (change.type === FileChangeType.Created) {
2189
+ invalidateTsconfigCacheForConfigDir(path.dirname(configPath));
2190
+ }
2191
+ }
2192
+ }
2193
+
1769
2194
  function collectClientDiagnosticEvidenceDocuments(
1770
2195
  snapshots: Iterable<ClientDiagnosticSnapshot>,
1771
2196
  cwd: string,
@@ -1781,14 +2206,14 @@ function collectClientDiagnosticEvidenceDocuments(
1781
2206
  function collectOutstandingDiagnosticSummaryEntries(
1782
2207
  snapshots: Iterable<ClientDiagnosticSnapshot>,
1783
2208
  cwd: string,
1784
- excludePatterns: string[],
2209
+ includeFile: (file: string) => boolean,
1785
2210
  maxSeverity: number,
1786
2211
  ): OutstandingDiagnosticSummaryEntry[] {
1787
2212
  const fileDiags = new Map<string, OutstandingDiagnosticSummaryEntry>();
1788
2213
  for (const snapshot of snapshots) {
1789
2214
  for (const entry of snapshot.entries) {
1790
2215
  const file = relativeFilePathFromUri(entry.uri, cwd);
1791
- if (shouldIgnoreLspPath(file, cwd) || isExcludedByPattern(file, excludePatterns)) continue;
2216
+ if (!includeFile(file)) continue;
1792
2217
  const existing = fileDiags.get(file) ?? createOutstandingDiagnosticSummary(file);
1793
2218
  const next = accumulateOutstandingDiagnostics(existing, entry.diagnostics, maxSeverity);
1794
2219
  if (next.total > 0) fileDiags.set(file, next);