@mrclrchtr/supi-code-intelligence 4.10.0 → 5.0.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 (203) hide show
  1. package/README.md +2 -1
  2. package/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  3. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +9 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +53 -13
  6. package/node_modules/@mrclrchtr/supi-code-runtime/src/request-control.ts +47 -0
  7. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +13 -1
  8. package/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  9. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  10. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +31 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +19 -3
  13. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +3 -0
  14. package/node_modules/@mrclrchtr/supi-lsp/README.md +80 -5
  15. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  16. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  17. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +9 -0
  18. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +53 -13
  19. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/request-control.ts +47 -0
  20. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +13 -1
  21. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  22. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  23. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/config/config.ts +31 -0
  24. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -0
  25. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +19 -3
  26. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +3 -0
  27. package/node_modules/@mrclrchtr/supi-lsp/package.json +7 -6
  28. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +10 -0
  29. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-cache.ts +206 -0
  30. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-capabilities.ts +78 -0
  31. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-collection.ts +114 -0
  32. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-evidence.ts +372 -0
  33. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-host.ts +14 -0
  34. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-pull.ts +62 -0
  35. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-refresh.ts +491 -0
  36. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-request.ts +9 -0
  37. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-timing.ts +71 -11
  38. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-waiters.ts +206 -0
  39. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostics.ts +365 -297
  40. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-state.ts +52 -0
  41. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-sync.ts +185 -0
  42. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-file-state.ts +25 -0
  43. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +467 -115
  44. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +161 -33
  45. package/node_modules/@mrclrchtr/supi-lsp/src/config/capabilities.ts +10 -1
  46. package/node_modules/@mrclrchtr/supi-lsp/src/config/defaults.json +1 -1
  47. package/node_modules/@mrclrchtr/supi-lsp/src/debug-telemetry.ts +45 -0
  48. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/evidence.ts +38 -0
  49. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-client-state.ts +79 -9
  50. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +13 -4
  51. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +300 -24
  52. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-symbol.ts +5 -2
  53. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +464 -96
  54. package/node_modules/@mrclrchtr/supi-lsp/src/provider/lsp-refactor-provider.ts +130 -0
  55. package/node_modules/@mrclrchtr/supi-lsp/src/provider/lsp-semantic-provider.ts +45 -329
  56. package/node_modules/@mrclrchtr/supi-lsp/src/provider/refactor-planning.ts +57 -120
  57. package/node_modules/@mrclrchtr/supi-lsp/src/provider/semantic-edit-normalizer.ts +320 -0
  58. package/node_modules/@mrclrchtr/supi-lsp/src/provider/semantic-symbol-mapper.ts +207 -0
  59. package/node_modules/@mrclrchtr/supi-lsp/src/session/readiness.ts +72 -2
  60. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-controller.ts +151 -26
  61. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostic-surface.ts +40 -0
  62. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +50 -0
  63. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +136 -120
  64. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-transition-debug.ts +49 -0
  65. package/node_modules/@mrclrchtr/supi-lsp/src/session/workspace-lsp-runtime.ts +102 -0
  66. package/node_modules/@mrclrchtr/supi-tree-sitter/README.md +17 -7
  67. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  68. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  69. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +9 -0
  70. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +53 -13
  71. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/request-control.ts +47 -0
  72. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +13 -1
  73. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  74. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  75. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/config/config.ts +31 -0
  76. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -0
  77. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +19 -3
  78. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +3 -0
  79. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/LICENSE +21 -0
  80. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/README.md +258 -0
  81. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/dist/babel.cjs +257 -0
  82. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/dist/jiti.cjs +1 -0
  83. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-cli.mjs +34 -0
  84. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-hooks.mjs +124 -0
  85. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-native.mjs +121 -0
  86. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-register.d.mts +1 -0
  87. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-register.mjs +4 -0
  88. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-static.mjs +23 -0
  89. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti.cjs +30 -0
  90. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti.d.cts +8 -0
  91. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti.d.mts +8 -0
  92. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti.mjs +29 -0
  93. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/types.d.ts +420 -0
  94. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/package.json +146 -0
  95. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +6 -3
  96. package/node_modules/@mrclrchtr/supi-tree-sitter/src/api.ts +0 -9
  97. package/node_modules/@mrclrchtr/supi-tree-sitter/src/index.ts +0 -8
  98. package/node_modules/@mrclrchtr/supi-tree-sitter/src/provider/tree-sitter-provider.ts +16 -12
  99. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/runtime-controller.ts +65 -87
  100. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/session.ts +47 -95
  101. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-timing.ts +147 -0
  102. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-client-lifecycle.ts +39 -0
  103. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-client.ts +561 -0
  104. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-message-size.ts +24 -0
  105. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-protocol.ts +452 -0
  106. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/call-sites.ts +4 -2
  107. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/callees.ts +12 -4
  108. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/exports.ts +4 -2
  109. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/imports.ts +4 -2
  110. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/node-at.ts +5 -3
  111. package/node_modules/@mrclrchtr/supi-tree-sitter/src/types.ts +33 -12
  112. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/blocking-test-bootstrap.mjs +34 -0
  113. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/bootstrap.mjs +8 -0
  114. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/parsed-file-store-helpers.ts +23 -0
  115. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/parsed-file-store.ts +400 -0
  116. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/request-control.ts +48 -0
  117. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/runtime-parser-helpers.ts +24 -0
  118. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/runtime-query-helpers.ts +43 -0
  119. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/runtime.ts +421 -0
  120. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/service.ts +116 -0
  121. package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/worker-main.ts +201 -0
  122. package/package.json +5 -5
  123. package/src/analysis/health/diagnostics.ts +236 -24
  124. package/src/analysis/health/recovery.ts +6 -2
  125. package/src/analysis/provider.ts +58 -0
  126. package/src/analysis/readiness.ts +30 -9
  127. package/src/analysis/refactor/apply.ts +45 -1
  128. package/src/analysis/refactor/mutation-authority.ts +143 -0
  129. package/src/analysis/relations/callees.ts +9 -1
  130. package/src/analysis/relations/types.ts +5 -2
  131. package/src/analysis/search/ast-scan-timing.ts +9 -2
  132. package/src/analysis/search/ast-scan.ts +1 -1
  133. package/src/analysis/search/deadline.ts +2 -2
  134. package/src/analysis/search/pattern-analysis.ts +46 -32
  135. package/src/analysis/search/pattern.ts +12 -2
  136. package/src/analysis/target/anchored.ts +20 -11
  137. package/src/analysis/target/file.ts +18 -11
  138. package/src/analysis/target/identity.ts +19 -3
  139. package/src/analysis/target/symbol.ts +36 -15
  140. package/src/api.ts +4 -0
  141. package/src/diagnostics/evidence.ts +51 -0
  142. package/src/session/capability-adapter.ts +12 -3
  143. package/src/session/find-workflow.ts +17 -7
  144. package/src/session/graph/collect.ts +18 -2
  145. package/src/session/graph-workflow.ts +19 -12
  146. package/src/session/health-refresh.ts +148 -0
  147. package/src/session/health-types.ts +37 -15
  148. package/src/session/health-workflow.ts +37 -39
  149. package/src/session/inspect/collect.ts +20 -3
  150. package/src/session/inspect-workflow.ts +10 -5
  151. package/src/session/orientation/collect.ts +10 -1
  152. package/src/session/orientation/context-facts.ts +3 -1
  153. package/src/session/orientation/context-sections.ts +20 -7
  154. package/src/session/orientation/gather.ts +17 -11
  155. package/src/session/orientation-types.ts +2 -1
  156. package/src/session/orientation-workflow.ts +15 -8
  157. package/src/session/refactor-plans.ts +2 -0
  158. package/src/session/refactor-workflow.ts +59 -14
  159. package/src/session/session.ts +4 -1
  160. package/src/session/target-workflow.ts +54 -23
  161. package/src/session/workflow-control.ts +7 -3
  162. package/src/substrate/lsp/lifecycle.ts +26 -46
  163. package/src/substrate/lsp/maintenance.ts +199 -25
  164. package/src/substrate/lsp/state.ts +2 -2
  165. package/src/substrate/workspace-provider-host.ts +25 -3
  166. package/src/tool/find/execute.ts +7 -1
  167. package/src/tool/find/render.ts +20 -3
  168. package/src/tool/find/tui.ts +23 -21
  169. package/src/tool/graph/execute.ts +23 -3
  170. package/src/tool/graph/tui.ts +59 -46
  171. package/src/tool/health/execute.ts +7 -2
  172. package/src/tool/health/markdown.ts +56 -14
  173. package/src/tool/health/refresh-status.ts +105 -0
  174. package/src/tool/health/tui.ts +95 -98
  175. package/src/tool/infra/workflow-control.ts +8 -0
  176. package/src/tool/inspect/execute.ts +7 -1
  177. package/src/tool/inspect/tui.ts +26 -18
  178. package/src/tool/orientation/execute.ts +21 -3
  179. package/src/tool/orientation/tui.ts +51 -35
  180. package/src/tool/refactor-apply/execute.ts +3 -0
  181. package/src/tool/refactor-apply/tui.ts +43 -25
  182. package/src/tool/refactor-plan/execute.ts +4 -0
  183. package/src/tool/refactor-plan/tui.ts +33 -20
  184. package/src/tool/register.ts +127 -22
  185. package/src/tool/resolve/execute.ts +18 -1
  186. package/src/tool/resolve/tui.ts +42 -24
  187. package/src/tool/result/display.ts +107 -0
  188. package/src/tool/result/errors.ts +20 -17
  189. package/src/tool/result/find.ts +52 -8
  190. package/src/tool/result/graph.ts +44 -1
  191. package/src/tool/result/health.ts +53 -1
  192. package/src/tool/result/inspect.ts +113 -1
  193. package/src/tool/result/orientation.ts +66 -14
  194. package/src/tool/result/refactor.ts +36 -4
  195. package/src/tool/result/resolve.ts +44 -7
  196. package/src/tool/result/types.ts +20 -0
  197. package/src/types/execution.ts +18 -5
  198. package/src/types/index.ts +8 -1
  199. package/src/ui/status-command.ts +5 -3
  200. package/src/ui/status-overlay.ts +4 -0
  201. package/src/ui/tui/common.ts +108 -10
  202. package/src/ui/tui/display.ts +48 -0
  203. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/runtime.ts +0 -351
@@ -6,7 +6,10 @@ import { type ChildProcess, execSync, spawn } from "node:child_process";
6
6
  import * as path from "node:path";
7
7
  import {
8
8
  type CodeQueryResult,
9
+ type CodeRequestControl,
9
10
  completedCodeQuery,
11
+ isCodeRequestInterruption,
12
+ throwIfCodeRequestInterrupted,
10
13
  unavailableCodeQuery,
11
14
  } from "@mrclrchtr/supi-code-runtime/api";
12
15
  import { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
@@ -25,7 +28,6 @@ import type {
25
28
  Location,
26
29
  LocationLink,
27
30
  Position,
28
- PublishDiagnosticsParams,
29
31
  Range,
30
32
  ServerCapabilities,
31
33
  ServerConfig,
@@ -33,14 +35,35 @@ import type {
33
35
  WorkspaceEdit,
34
36
  WorkspaceSymbol,
35
37
  } from "../config/types.ts";
38
+ import { boundCwd, truncateIdentity } from "../debug-telemetry.ts";
39
+ import type { DiagnosticEvidenceSummary } from "../diagnostics/evidence.ts";
40
+ import { raceRequestControl } from "../session/readiness.ts";
36
41
  import { fileToUri } from "../utils.ts";
37
- import { ClientDiagnostics, type DiagnosticEntry } from "./client-diagnostics.ts";
42
+ import {
43
+ ClientDynamicRegistrations,
44
+ DOCUMENT_DIAGNOSTIC_METHOD,
45
+ isValidDiagnosticOptions,
46
+ } from "./client-diagnostic-capabilities.ts";
47
+ import { ClientDiagnostics } from "./client-diagnostics.ts";
48
+ import type { ClientDiagnosticSnapshot, DiagnosticEntry } from "./client-document-state.ts";
38
49
  import { JsonRpcClient, JsonRpcRequestError } from "./transport.ts";
39
50
 
40
51
  const SHUTDOWN_TIMEOUT_MS = 5_000;
41
52
 
53
+ /**
54
+ * Fixed bound after which a running client that never became ready is
55
+ * considered readiness-stalled and eligible for a recovery restart.
56
+ */
57
+ export const RECOVERY_CLIENT_STARTUP_BOUND_MS = 5_000;
58
+
59
+ /** Repeated protocol-stall failures that justify a recovery restart. */
60
+ export const RECOVERY_PROTOCOL_FAILURE_THRESHOLD = 3;
61
+
62
+ /** Stall signals that justify replacing a client's server process. */
63
+ export type RecoveryRestartReason = "readiness-stall" | "protocol-errors";
64
+
42
65
  /** Race an operation against a timeout without retaining the timer after settlement. */
43
- async function withTimeout<T>(
66
+ export async function withTimeout<T>(
44
67
  operation: Promise<T>,
45
68
  timeoutMs: number,
46
69
  message: string,
@@ -58,6 +81,41 @@ async function withTimeout<T>(
58
81
  }
59
82
  }
60
83
 
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === "object" && value !== null && !Array.isArray(value);
86
+ }
87
+
88
+ /**
89
+ * Read and validate the `registrations` array of a `client/registerCapability`
90
+ * request. Each entry must be a record with string `id` and `method`; the
91
+ * optional `registerOptions` stay unvalidated here (method-specific checks
92
+ * happen in the handler). Malformed values reject the request.
93
+ */
94
+ function readRegistrations(
95
+ params: unknown,
96
+ requestName: string,
97
+ ): Array<{ id: string; method: string; registerOptions?: unknown }> {
98
+ if (!isRecord(params) || !Array.isArray(params.registrations)) {
99
+ throw new JsonRpcRequestError(-32602, `Malformed ${requestName} params.`);
100
+ }
101
+ const registrations: Array<{ id: string; method: string; registerOptions?: unknown }> = [];
102
+ for (const registration of params.registrations) {
103
+ if (
104
+ !isRecord(registration) ||
105
+ typeof registration.id !== "string" ||
106
+ typeof registration.method !== "string"
107
+ ) {
108
+ throw new JsonRpcRequestError(-32602, `Malformed ${requestName} registration.`);
109
+ }
110
+ registrations.push({
111
+ id: registration.id,
112
+ method: registration.method,
113
+ registerOptions: registration.registerOptions,
114
+ });
115
+ }
116
+ return registrations;
117
+ }
118
+
61
119
  // ── Process-tree cleanup ──────────────────────────────────────────────
62
120
 
63
121
  /**
@@ -86,6 +144,17 @@ function killProcessTree(pid: number): void {
86
144
  // ── Types ─────────────────────────────────────────────────────────────
87
145
  export type ClientStatus = "initializing" | "running" | "error" | "shutdown";
88
146
 
147
+ /** Package-internal facts that the manager projects into workspace lifecycle transitions. */
148
+ export type LspClientLifecycleTransitionKind =
149
+ | "startup"
150
+ | "readiness"
151
+ | "crash"
152
+ | "shutdown"
153
+ | "tracked-files";
154
+
155
+ /** Observer for one concrete client's lifecycle facts. */
156
+ export type LspClientLifecycleListener = (kind: LspClientLifecycleTransitionKind) => void;
157
+
89
158
  // ── LspClient ─────────────────────────────────────────────────────────
90
159
  export class LspClient {
91
160
  readonly name: string;
@@ -96,37 +165,61 @@ export class LspClient {
96
165
  private _status: ClientStatus = "initializing";
97
166
  private capabilities: ServerCapabilities | null = null;
98
167
  private readonly diagnostics: ClientDiagnostics;
168
+ /** Dynamic capability registrations for this client instance only. */
169
+ private readonly dynamicRegistrations = new ClientDynamicRegistrations();
99
170
 
100
171
  // ── Readiness (work-done-progress) ──────────────────────────────────
101
- private trackedTokens = new Map<ProgressToken, "begin-seen" | "ended">();
172
+ private trackedTokens = new Map<ProgressToken, "created" | "active" | "ended">();
173
+ private tokenCreatedAt = new Map<ProgressToken, number>();
102
174
  private _readyPromise: Promise<void> | null = null;
103
175
  private _readyResolve: (() => void) | undefined;
104
176
  private _readyReject: ((err: Error) => void) | undefined;
105
177
  private _isReady = false;
178
+ /** Whether this client generation ever reached concrete readiness. */
179
+ private everReady = false;
106
180
  private noProgressTimer: ReturnType<typeof setTimeout> | null = null;
107
181
  private tokenTimeouts = new Map<ProgressToken, ReturnType<typeof setTimeout>>();
182
+ /** Wall-clock start of the current client generation, for stall detection. */
183
+ private startedAt = 0;
108
184
 
185
+ // biome-ignore lint/complexity/useMaxParams: internal constructor keeps positional identity for test call sites
109
186
  constructor(
110
187
  name: string,
111
188
  private readonly config: ServerConfig,
112
189
  root: string,
190
+ private readonly onLifecycleTransition?: LspClientLifecycleListener,
191
+ /** Absolute workspace root for debug-telemetry identity. */
192
+ readonly cwd?: string,
113
193
  ) {
114
194
  this.name = name;
115
195
  this.root = root;
116
196
  this.diagnostics = new ClientDiagnostics({
197
+ server: name,
198
+ cwd: cwd,
117
199
  isOperational: () => this.rpc !== null && this._status === "running",
118
200
  supportsPullDiagnostics: () => this.hasDiagnosticProvider,
119
201
  sendNotification: (method, params) => {
120
202
  if (this.rpc) void this.rpc.sendNotification(method, params);
121
203
  },
122
- pullDocumentDiagnostics: async (uri, previousResultId, timeoutMs) => {
204
+ pullDocumentDiagnostics: async (request) => {
123
205
  const rpc = this.rpc;
124
206
  if (!rpc || this._status !== "running") throw new Error("client not running");
125
- await this.getReady();
207
+ await this.getReady({
208
+ signal: request.signal,
209
+ deadline: request.deadline,
210
+ });
126
211
  return rpc.sendRequest(
127
- "textDocument/diagnostic",
128
- { textDocument: { uri }, previousResultId },
129
- { timeoutMs },
212
+ DOCUMENT_DIAGNOSTIC_METHOD,
213
+ {
214
+ textDocument: { uri: request.uri },
215
+ previousResultId: request.previousResultId,
216
+ },
217
+ {
218
+ timeoutMs: request.timeoutMs,
219
+ signal: request.signal,
220
+ deadline: request.deadline,
221
+ operationId: request.operationId,
222
+ },
130
223
  ) as Promise<DocumentDiagnosticReport>;
131
224
  },
132
225
  });
@@ -149,11 +242,21 @@ export class LspClient {
149
242
  return this._isReady;
150
243
  }
151
244
 
245
+ /** Publish one client fact without letting an observer disrupt the client. */
246
+ private publishLifecycle(kind: LspClientLifecycleTransitionKind): void {
247
+ try {
248
+ this.onLifecycleTransition?.(kind);
249
+ } catch {
250
+ // Lifecycle observers must not alter protocol behavior.
251
+ }
252
+ }
253
+
152
254
  // ── Lifecycle ───────────────────────────────────────────────────────
153
255
  /** Spawn the server process and perform the initialize handshake. */
154
256
  async start(): Promise<void> {
155
257
  const cmd = this.config.command;
156
258
  const args = this.config.args ?? [];
259
+ this.startedAt = Date.now();
157
260
 
158
261
  try {
159
262
  this.process = spawn(cmd, args, {
@@ -166,22 +269,27 @@ export class LspClient {
166
269
  detached: true,
167
270
  });
168
271
  } catch (err) {
169
- this._status = "error";
170
- throw new Error(`Failed to spawn ${cmd}: ${err}`, { cause: err });
272
+ const failure = new Error(`Failed to spawn ${cmd}: ${err}`, { cause: err });
273
+ this.handleProcessFailure(failure);
274
+ throw failure;
171
275
  }
172
276
 
173
277
  if (!this.process.stdin || !this.process.stdout) {
174
- this._status = "error";
278
+ const failure = new Error(`${cmd}: missing stdin/stdout`);
279
+ this.handleProcessFailure(failure);
175
280
  this.process.kill();
176
- throw new Error(`${cmd}: missing stdin/stdout`);
281
+ throw failure;
177
282
  }
178
283
 
179
- this.rpc = new JsonRpcClient(this.process.stdout, this.process.stdin);
284
+ this.rpc = new JsonRpcClient(this.process.stdout, this.process.stdin, {
285
+ server: this.name,
286
+ cwd: this.cwd,
287
+ });
180
288
 
181
289
  // Handle notifications
182
290
  this.rpc.onNotification((method, params) => {
183
291
  if (method === "textDocument/publishDiagnostics") {
184
- this.handlePublishDiagnostics(params as PublishDiagnosticsParams);
292
+ this.handlePublishDiagnostics(params);
185
293
  } else if (method === "$/progress") {
186
294
  this.handleProgress(params as { token: ProgressToken; value: { kind: string } });
187
295
  }
@@ -190,19 +298,11 @@ export class LspClient {
190
298
 
191
299
  // Handle crashes
192
300
  this.process.on("exit", (_code) => {
193
- if (this._status !== "shutdown") {
194
- this._status = "error";
195
- this.cancelNoProgressTimer();
196
- this.rejectReady(new Error("Client crashed"));
197
- }
198
- this.rpc?.dispose();
301
+ this.handleProcessFailure(new Error("Client crashed"));
199
302
  });
200
303
 
201
304
  this.process.on("error", (_err) => {
202
- if (this._status !== "shutdown") {
203
- this._status = "error";
204
- this.rejectReady(new Error("Client process error"));
205
- }
305
+ this.handleProcessFailure(new Error("Client process error"));
206
306
  });
207
307
 
208
308
  // Suppress stderr to avoid noise in the agent
@@ -217,15 +317,21 @@ export class LspClient {
217
317
  initializationOptions: this.config.initializationOptions,
218
318
  })) as InitializeResult;
219
319
 
320
+ const positionEncoding = result.capabilities.positionEncoding ?? "utf-16";
321
+ if (positionEncoding !== "utf-16") {
322
+ throw new Error(`Server selected unsupported position encoding "${positionEncoding}".`);
323
+ }
220
324
  this.capabilities = result.capabilities;
221
325
  void this.rpc.sendNotification("initialized", {});
222
326
  this._status = "running";
327
+ this.publishLifecycle("startup");
223
328
 
224
329
  this.armNoProgressTimer();
225
330
  } catch (err) {
226
- this._status = "error";
331
+ const failure = new Error(`${this.name}: initialize failed: ${err}`, { cause: err });
332
+ this.handleProcessFailure(failure);
227
333
  this.process.kill();
228
- throw new Error(`${this.name}: initialize failed: ${err}`, { cause: err });
334
+ throw failure;
229
335
  }
230
336
  }
231
337
 
@@ -233,6 +339,11 @@ export class LspClient {
233
339
  async shutdown(): Promise<void> {
234
340
  if (this._status === "shutdown") return;
235
341
  this._status = "shutdown";
342
+ this.diagnostics.clear();
343
+ this.dynamicRegistrations.clear();
344
+ this.cancelNoProgressTimer();
345
+ this.rejectReady(new Error("Client shutdown"));
346
+ this.publishLifecycle("shutdown");
236
347
 
237
348
  if (!this.rpc || !this.process) return;
238
349
 
@@ -271,38 +382,123 @@ export class LspClient {
271
382
  });
272
383
  }
273
384
  }
385
+ }
274
386
 
275
- this.diagnostics.clear();
387
+ /**
388
+ * Terminate the process tree without a protocol handshake.
389
+ *
390
+ * Used when a replacement startup exceeds its recovery bound, so the
391
+ * orphaned server process cannot outlive the failed restart.
392
+ */
393
+ async forceKill(): Promise<void> {
394
+ const pid = this.process?.pid;
395
+ this.rpc?.dispose();
396
+ if (pid && this.process?.exitCode === null) {
397
+ killProcessTree(pid);
398
+ if (process.platform !== "win32") {
399
+ // Escalate to SIGKILL after a brief grace period on Unix, mirroring
400
+ // the graceful shutdown path for servers that ignore SIGTERM.
401
+ await new Promise<void>((resolve) => {
402
+ setTimeout(() => {
403
+ try {
404
+ process.kill(-pid, "SIGKILL");
405
+ } catch {
406
+ // Already dead — ignore.
407
+ }
408
+ resolve();
409
+ }, 500);
410
+ });
411
+ }
412
+ }
413
+ this.handleProcessFailure(new Error("Client start bound exceeded"));
414
+ }
276
415
 
277
- // Clear readiness state
278
- if (this.noProgressTimer) {
279
- clearTimeout(this.noProgressTimer);
280
- this.noProgressTimer = null;
416
+ private handleProcessFailure(reason: Error): void {
417
+ const didCrash = this._status !== "shutdown" && this._status !== "error";
418
+ if (didCrash) {
419
+ this._status = "error";
420
+ this.cancelNoProgressTimer();
421
+ this.rejectReady(reason);
281
422
  }
282
- for (const timer of this.tokenTimeouts.values()) clearTimeout(timer);
283
- this.tokenTimeouts.clear();
284
- this.rejectReady(new Error("Client shutdown"));
423
+ this.diagnostics.clear({ preserveFailedDocuments: didCrash || this._status === "error" });
424
+ this.dynamicRegistrations.clear();
425
+ this.rpc?.dispose();
426
+ if (didCrash) this.publishLifecycle("crash");
285
427
  }
286
428
 
287
429
  // ── Document Synchronization and Diagnostics ────────────────────────
288
430
  /** Open a document, or update it when it is already open. */
289
431
  didOpen(filePath: string, content: string): void {
432
+ const trackedCount = this.openFiles.length;
290
433
  this.diagnostics.didOpen(filePath, content);
434
+ this.publishTrackedFileChange(trackedCount);
291
435
  }
292
436
 
293
437
  /** Update a document, or open it when it is not tracked yet. */
294
438
  didChange(filePath: string, content: string): void {
439
+ const trackedCount = this.openFiles.length;
295
440
  this.diagnostics.didChange(filePath, content);
441
+ this.publishTrackedFileChange(trackedCount);
296
442
  }
297
443
 
298
444
  /** Close a document and remove its cached diagnostic state. */
299
445
  didClose(filePath: string): void {
446
+ const trackedCount = this.openFiles.length;
300
447
  this.diagnostics.didClose(filePath);
448
+ this.publishTrackedFileChange(trackedCount);
301
449
  }
302
450
 
303
451
  /** Remove missing document and diagnostic state, and return the removed paths. */
304
452
  pruneMissingFiles(): string[] {
305
- return this.diagnostics.pruneMissingFiles();
453
+ const trackedCount = this.openFiles.length;
454
+ const removed = this.diagnostics.pruneMissingFiles();
455
+ this.publishTrackedFileChange(trackedCount);
456
+ return removed;
457
+ }
458
+
459
+ private publishTrackedFileChange(previousCount: number): void {
460
+ if (this.openFiles.length !== previousCount) this.publishLifecycle("tracked-files");
461
+ }
462
+
463
+ /** Retain a failed document outcome when a replacement cannot reopen it. */
464
+ markFailedFile(filePath: string): void {
465
+ this.diagnostics.markFailedFile(filePath);
466
+ }
467
+
468
+ /**
469
+ * Return the stall signal that justifies replacing this client's process,
470
+ * or null when the client is healthy. Recovery restarts clients only on
471
+ * these signals, never on unconfirmed evidence alone (ADR 0020).
472
+ */
473
+ getRecoveryStallSignal(): RecoveryRestartReason | null {
474
+ if (this._status !== "running") return null;
475
+ // The startup bound applies only before the client ever became ready: a
476
+ // later readiness loss (a normal progress begin during indexing) is not
477
+ // a startup stall.
478
+ const pastStartupBound =
479
+ this.startedAt > 0 && Date.now() - this.startedAt >= RECOVERY_CLIENT_STARTUP_BOUND_MS;
480
+ if (!this.everReady && pastStartupBound) return "readiness-stall";
481
+ if (this.hasUnbegunCreatedToken()) return "readiness-stall";
482
+ if ((this.rpc?.getProtocolFailureCount() ?? 0) >= RECOVERY_PROTOCOL_FAILURE_THRESHOLD) {
483
+ return "protocol-errors";
484
+ }
485
+ return null;
486
+ }
487
+
488
+ /** Test whether a created progress token never began within its per-token bound. */
489
+ private hasUnbegunCreatedToken(): boolean {
490
+ const boundMs = this.config.readinessTimeoutMs ?? 10_000;
491
+ for (const [token, createdAt] of this.tokenCreatedAt) {
492
+ if (this.trackedTokens.get(token) === "created" && Date.now() - createdAt >= boundMs) {
493
+ return true;
494
+ }
495
+ }
496
+ return false;
497
+ }
498
+
499
+ /** Return the current client version, or null when the document is not open. */
500
+ getOpenDocumentVersion(filePath: string): number | null {
501
+ return this.diagnostics.getOpenDocumentVersion(filePath);
306
502
  }
307
503
 
308
504
  /** Return stored diagnostics for one file. */
@@ -311,8 +507,12 @@ export class LspClient {
311
507
  }
312
508
 
313
509
  /** Return non-empty diagnostics for files that still exist. */
510
+ getDiagnosticSnapshot(): ClientDiagnosticSnapshot {
511
+ return this.diagnostics.getDiagnosticSnapshot();
512
+ }
513
+
314
514
  getAllDiagnostics(): DiagnosticEntry[] {
315
- return this.diagnostics.getAllDiagnostics();
515
+ return this.getDiagnosticSnapshot().entries;
316
516
  }
317
517
 
318
518
  /** Force the next pull refresh to request complete diagnostic reports. */
@@ -322,12 +522,20 @@ export class LspClient {
322
522
 
323
523
  /** Check if server supports pull diagnostics. */
324
524
  get hasDiagnosticProvider(): boolean {
325
- return this.capabilities?.diagnosticProvider !== undefined;
525
+ // Static state: a valid `diagnosticProvider` in the initialize result.
526
+ // Dynamic state: an active registration for the diagnostic method. A
527
+ // malformed static shape or an empty dynamic set fails closed, so an
528
+ // unsupported server never gets pull requests.
529
+ return (
530
+ isValidDiagnosticOptions(this.capabilities?.diagnosticProvider) ||
531
+ this.dynamicRegistrations.has(DOCUMENT_DIAGNOSTIC_METHOD)
532
+ );
326
533
  }
327
534
 
328
535
  /** Notify the server that watched workspace files changed. */
329
536
  notifyWorkspaceFileChanges(changes: FileEvent[]): void {
330
537
  if (!this.rpc || this._status !== "running" || changes.length === 0) return;
538
+ this.diagnostics.invalidateCachedEvidence();
331
539
  void this.rpc.sendNotification("workspace/didChangeWatchedFiles", {
332
540
  changes,
333
541
  } satisfies DidChangeWatchedFilesParams);
@@ -335,118 +543,157 @@ export class LspClient {
335
543
 
336
544
  /** Re-read open documents, then collect pull diagnostics or wait for push diagnostics. */
337
545
  async refreshOpenDiagnostics(
338
- options: { maxWaitMs?: number; quietMs?: number } = {},
339
- ): Promise<void> {
546
+ options: { maxWaitMs?: number; quietMs?: number } & CodeRequestControl = {},
547
+ ): Promise<DiagnosticEvidenceSummary> {
340
548
  return this.diagnostics.refreshOpenDiagnostics(options);
341
549
  }
342
550
 
343
- /** Sync one file and return its diagnostics after pull or push collection. */
344
- async syncAndWaitForDiagnostics(filePath: string, content: string): Promise<Diagnostic[]> {
345
- return this.diagnostics.syncAndWaitForDiagnostics(filePath, content);
551
+ /** Sync one file and return diagnostics with explicit evidence availability. */
552
+ async syncAndWaitForDiagnostics(
553
+ filePath: string,
554
+ content: string,
555
+ control?: CodeRequestControl,
556
+ ): Promise<CodeQueryResult<Diagnostic[]>> {
557
+ return this.diagnostics.syncAndWaitForDiagnostics(filePath, content, control);
346
558
  }
347
559
 
348
560
  // ── LSP Requests ───────────────────────────────────────────────────
349
- async hover(filePath: string, position: Position): Promise<CodeQueryResult<Hover | null>> {
350
- return this.query("textDocument/hover", {
351
- textDocument: { uri: fileToUri(filePath) },
352
- position,
353
- });
561
+ async hover(
562
+ filePath: string,
563
+ position: Position,
564
+ control?: CodeRequestControl,
565
+ ): Promise<CodeQueryResult<Hover | null>> {
566
+ return this.query(
567
+ "textDocument/hover",
568
+ { textDocument: { uri: fileToUri(filePath) }, position },
569
+ control,
570
+ );
354
571
  }
355
572
 
356
573
  async definition(
357
574
  filePath: string,
358
575
  position: Position,
576
+ control?: CodeRequestControl,
359
577
  ): Promise<CodeQueryResult<Location | Location[] | LocationLink[] | null>> {
360
- return this.query("textDocument/definition", {
361
- textDocument: { uri: fileToUri(filePath) },
362
- position,
363
- });
578
+ return this.query(
579
+ "textDocument/definition",
580
+ { textDocument: { uri: fileToUri(filePath) }, position },
581
+ control,
582
+ );
364
583
  }
365
584
 
366
585
  async references(
367
586
  filePath: string,
368
587
  position: Position,
588
+ control?: CodeRequestControl,
369
589
  ): Promise<CodeQueryResult<Location[] | null>> {
370
- return this.query("textDocument/references", {
371
- textDocument: { uri: fileToUri(filePath) },
372
- position,
373
- context: { includeDeclaration: true },
374
- });
590
+ return this.query(
591
+ "textDocument/references",
592
+ {
593
+ textDocument: { uri: fileToUri(filePath) },
594
+ position,
595
+ context: { includeDeclaration: true },
596
+ },
597
+ control,
598
+ );
375
599
  }
376
600
 
377
601
  async documentSymbols(
378
602
  filePath: string,
603
+ control?: CodeRequestControl,
379
604
  ): Promise<CodeQueryResult<DocumentSymbol[] | SymbolInformation[] | null>> {
380
- return this.query("textDocument/documentSymbol", {
381
- textDocument: { uri: fileToUri(filePath) },
382
- });
605
+ return this.query(
606
+ "textDocument/documentSymbol",
607
+ { textDocument: { uri: fileToUri(filePath) } },
608
+ control,
609
+ );
383
610
  }
384
611
 
385
612
  async workspaceSymbol(
386
613
  query: string,
614
+ control?: CodeRequestControl,
387
615
  ): Promise<CodeQueryResult<SymbolInformation[] | WorkspaceSymbol[] | null>> {
388
616
  if (!this.capabilities?.workspaceSymbolProvider) {
389
617
  return unavailableCodeQuery("Workspace-symbol requests are not supported by this server.");
390
618
  }
391
- return this.query("workspace/symbol", { query });
619
+ return this.query("workspace/symbol", { query }, control);
392
620
  }
393
621
 
394
622
  async rename(
395
623
  filePath: string,
396
624
  position: Position,
397
625
  newName: string,
626
+ control?: CodeRequestControl,
398
627
  ): Promise<WorkspaceEdit | null> {
399
- return this.request("textDocument/rename", {
400
- textDocument: { uri: fileToUri(filePath) },
401
- position,
402
- newName,
403
- });
628
+ return this.request(
629
+ "textDocument/rename",
630
+ { textDocument: { uri: fileToUri(filePath) }, position, newName },
631
+ control,
632
+ );
404
633
  }
405
634
 
406
635
  async codeActions(
407
636
  filePath: string,
408
637
  range: Range,
409
638
  context: CodeActionContext,
639
+ control?: CodeRequestControl,
410
640
  ): Promise<CodeAction[] | null> {
411
- return this.request("textDocument/codeAction", {
412
- textDocument: { uri: fileToUri(filePath) },
413
- range,
414
- context,
415
- });
641
+ return this.request(
642
+ "textDocument/codeAction",
643
+ { textDocument: { uri: fileToUri(filePath) }, range, context },
644
+ control,
645
+ );
416
646
  }
417
647
 
418
648
  async implementation(
419
649
  filePath: string,
420
650
  position: Position,
651
+ control?: CodeRequestControl,
421
652
  ): Promise<CodeQueryResult<Location | Location[] | LocationLink[] | null>> {
422
653
  if (!this.capabilities?.implementationProvider) {
423
654
  return unavailableCodeQuery("Implementation requests are not supported by this server.");
424
655
  }
425
- return this.query("textDocument/implementation", {
426
- textDocument: { uri: fileToUri(filePath) },
427
- position,
428
- });
656
+ return this.query(
657
+ "textDocument/implementation",
658
+ { textDocument: { uri: fileToUri(filePath) }, position },
659
+ control,
660
+ );
429
661
  }
430
662
 
431
663
  // ── Private ─────────────────────────────────────────────────────────
432
- private async query<T>(method: string, params: unknown): Promise<CodeQueryResult<T | null>> {
664
+ private async query<T>(
665
+ method: string,
666
+ params: unknown,
667
+ control?: CodeRequestControl,
668
+ ): Promise<CodeQueryResult<T | null>> {
669
+ // An already-cancelled caller gets the interruption, not an unavailable
670
+ // outcome that could mask the cancellation.
671
+ throwIfCodeRequestInterrupted(control);
433
672
  if (!this.rpc || this._status !== "running") {
434
673
  return unavailableCodeQuery(
435
674
  `LSP request ${method} is unavailable because the client is not running.`,
436
675
  );
437
676
  }
438
677
  try {
439
- await this.getReady();
440
- const data = (await this.rpc.sendRequest(method, params)) as T | null | undefined;
678
+ await this.getReady(control);
679
+ const data = (await this.rpc.sendRequest(method, params, control)) as T | null | undefined;
441
680
  return completedCodeQuery(data ?? null);
442
681
  } catch (error) {
682
+ // Cancellation and absolute-deadline expiry propagate as interruptions:
683
+ // the caller no longer awaits a result, so no unavailable outcome may
684
+ // mask the cancellation.
685
+ if (isCodeRequestInterruption(error, control)) throw error;
443
686
  const detail = error instanceof Error ? error.message : String(error);
444
687
  return unavailableCodeQuery(`LSP request ${method} failed: ${detail}`);
445
688
  }
446
689
  }
447
690
 
448
- private async request<T>(method: string, params: unknown): Promise<T | null> {
449
- const result = await this.query<T>(method, params);
691
+ private async request<T>(
692
+ method: string,
693
+ params: unknown,
694
+ control?: CodeRequestControl,
695
+ ): Promise<T | null> {
696
+ const result = await this.query<T>(method, params, control);
450
697
  return result.kind === "unavailable" ? null : result.data;
451
698
  }
452
699
 
@@ -457,23 +704,22 @@ export class LspClient {
457
704
  case "workspace/workspaceFolders":
458
705
  return [{ uri: fileToUri(this.root), name: path.basename(this.root) || this.root }];
459
706
  case "client/registerCapability":
707
+ return this.handleRegisterCapability(params);
460
708
  case "client/unregisterCapability":
709
+ return this.handleUnregisterCapability(params);
710
+ case "workspace/diagnostic/refresh":
711
+ // The client does not advertise refresh support, but pyright sends
712
+ // this request anyway when a document opens in pull mode. Answering
713
+ // with an error crashes pyright (exit 1); answering null keeps the
714
+ // server alive and pull-on-demand still serves fresh diagnostics.
461
715
  return null;
462
716
  case "window/workDoneProgress/create": {
717
+ // A create reserves a token; it does not prove active work. The
718
+ // token stays pending and readiness is untouched until a begin
719
+ // arrives, so an unused token never causes false readiness loss.
463
720
  const token = (params as { token: ProgressToken }).token;
464
- this.trackedTokens.set(token, "begin-seen");
465
- this.cancelNoProgressTimer();
466
- this._isReady = false;
467
- if (!this._readyPromise) {
468
- this._readyPromise = new Promise<void>((resolve, reject) => {
469
- this._readyResolve = resolve;
470
- this._readyReject = reject;
471
- });
472
- // Prevent unhandled rejection when rejectReady fires before any
473
- // consumer is actively awaiting this promise (e.g. during shutdown).
474
- this._readyPromise.catch(() => {});
475
- }
476
- this.startTokenTimeout(token);
721
+ this.trackedTokens.set(token, "created");
722
+ this.tokenCreatedAt.set(token, Date.now());
477
723
  return null;
478
724
  }
479
725
  default:
@@ -488,8 +734,52 @@ export class LspClient {
488
734
  return items.map(() => null);
489
735
  }
490
736
 
737
+ /**
738
+ * Apply a dynamic registration for `textDocument/diagnostic`.
739
+ *
740
+ * Registrations for other methods are ignored (status quo). Malformed
741
+ * params or malformed diagnostic registration options reject the request
742
+ * without enabling pull, so a server never gets pull requests it did not
743
+ * validly register for.
744
+ */
745
+ private handleRegisterCapability(params: unknown): null {
746
+ const registrations = readRegistrations(params, "client/registerCapability");
747
+ for (const registration of registrations) {
748
+ if (registration.method !== DOCUMENT_DIAGNOSTIC_METHOD) continue;
749
+ if (!isValidDiagnosticOptions(registration.registerOptions)) {
750
+ throw new JsonRpcRequestError(
751
+ -32602,
752
+ "Malformed textDocument/diagnostic registration options.",
753
+ );
754
+ }
755
+ this.dynamicRegistrations.register(registration.method, registration.id);
756
+ }
757
+ return null;
758
+ }
759
+
760
+ /**
761
+ * Remove dynamic registrations for `textDocument/diagnostic`.
762
+ *
763
+ * The params key is the LSP specification's documented compatibility typo
764
+ * `unregisterations` (renamed to `unregistrations` only in a future 4.x).
765
+ * Capability loss disables pull as soon as the last id is removed.
766
+ */
767
+ private handleUnregisterCapability(params: unknown): null {
768
+ if (!isRecord(params) || !Array.isArray(params.unregisterations)) {
769
+ throw new JsonRpcRequestError(-32602, "Malformed client/unregisterCapability params.");
770
+ }
771
+ for (const entry of params.unregisterations) {
772
+ if (!isRecord(entry) || typeof entry.id !== "string" || typeof entry.method !== "string") {
773
+ throw new JsonRpcRequestError(-32602, "Malformed client/unregisterCapability entry.");
774
+ }
775
+ if (entry.method !== DOCUMENT_DIAGNOSTIC_METHOD) continue;
776
+ this.dynamicRegistrations.unregister(entry.method, entry.id);
777
+ }
778
+ return null;
779
+ }
780
+
491
781
  /** Apply a diagnostic publication received from the LSP transport. */
492
- handlePublishDiagnostics(params: PublishDiagnosticsParams): void {
782
+ handlePublishDiagnostics(params: unknown): void {
493
783
  this.diagnostics.handlePublishDiagnostics(params);
494
784
  }
495
785
 
@@ -499,9 +789,17 @@ export class LspClient {
499
789
  * Wait for the server to be ready to serve queries.
500
790
  * Returns immediately if already ready; returns the ongoing promise
501
791
  * if one is pending; creates and returns a new one otherwise.
792
+ * With request control, the caller's wait stops promptly on abort or
793
+ * deadline while the shared readiness state keeps its own lifecycle.
502
794
  */
503
- async getReady(): Promise<void> {
504
- if (this._isReady) return;
795
+ async getReady(control?: CodeRequestControl): Promise<void> {
796
+ const pending = this.pendingReady();
797
+ if (!control) return pending;
798
+ return raceRequestControl(pending, control);
799
+ }
800
+
801
+ private pendingReady(): Promise<void> {
802
+ if (this._isReady) return Promise.resolve();
505
803
  if (this._readyPromise !== null) return this._readyPromise;
506
804
  // If no progress timer was ever armed and no tokens are tracked,
507
805
  // the server was either never started with a real process (test scenario)
@@ -509,8 +807,11 @@ export class LspClient {
509
807
  // only when the client is still running — a crash or shutdown clears
510
808
  // both fields but must not report the client as ready.
511
809
  if (this.noProgressTimer === null && this.trackedTokens.size === 0) {
512
- if (this._status === "running") this._isReady = true;
513
- return;
810
+ if (this._status === "running") {
811
+ this._isReady = true;
812
+ this.everReady = true;
813
+ }
814
+ return Promise.resolve();
514
815
  }
515
816
  this._readyPromise = new Promise<void>((resolve, reject) => {
516
817
  this._readyResolve = resolve;
@@ -533,14 +834,20 @@ export class LspClient {
533
834
  source: "lsp",
534
835
  level: "debug",
535
836
  category: "readiness.progress-begin",
536
- message: `Readiness progress begin for token ${token}`,
537
- data: { token },
837
+ message: `Readiness progress begin for ${this.name}`,
838
+ cwd: boundCwd(this.cwd),
839
+ data: { server: this.name, root: truncateIdentity(this.root) },
538
840
  });
539
- // Cancel the 2s no-progress grace timer a server that sends
540
- // begin without a prior create is spec-deviant but valid.
841
+ // begin is the only transition that proves active work: it cancels
842
+ // the no-progress grace timer, blocks readiness, and arms the
843
+ // bounded per-token timeout. A server that sends begin without a
844
+ // prior create is spec-deviant but valid.
541
845
  this.cancelNoProgressTimer();
542
- this.trackedTokens.set(token, "begin-seen");
846
+ this.trackedTokens.set(token, "active");
847
+ this.tokenCreatedAt.delete(token);
848
+ const wasReady = this._isReady;
543
849
  this._isReady = false;
850
+ if (wasReady) this.publishLifecycle("readiness");
544
851
  // Re-arm readiness promise if not already pending
545
852
  if (!this._readyPromise) {
546
853
  this._readyPromise = new Promise<void>((resolve, reject) => {
@@ -557,22 +864,36 @@ export class LspClient {
557
864
  source: "lsp",
558
865
  level: "debug",
559
866
  category: "readiness.progress-end",
560
- message: `Readiness progress end for token ${token}`,
561
- data: { token },
867
+ message: `Readiness progress end for ${this.name}`,
868
+ cwd: boundCwd(this.cwd),
869
+ data: { server: this.name, root: truncateIdentity(this.root) },
562
870
  });
871
+ const state = this.trackedTokens.get(token);
872
+ if (state === undefined) return; // Unknown token: ignore fail-closed.
873
+ this.tokenCreatedAt.delete(token);
874
+ if (state === "created") {
875
+ // A pending token never blocked readiness; its end removes it.
876
+ this.trackedTokens.delete(token);
877
+ return;
878
+ }
563
879
  this.trackedTokens.set(token, "ended");
564
880
  this.clearTokenTimeout(token);
565
881
  this.checkAllTokensEnded();
566
882
  }
567
- // kind: "report" — intentionally no-op
883
+ // kind: "report" — intentionally no-op; active state is retained.
568
884
  }
569
885
 
570
- /** Check whether all tracked tokens have ended and resolve readiness. */
571
- private checkAllTokensEnded(): void {
572
- if (this.trackedTokens.size === 0) return;
886
+ /** Test whether any token has active (begun) work. */
887
+ private hasActiveTokens(): boolean {
573
888
  for (const state of this.trackedTokens.values()) {
574
- if (state !== "ended") return;
889
+ if (state === "active") return true;
575
890
  }
891
+ return false;
892
+ }
893
+
894
+ /** Resolve readiness when no token is active; pending tokens do not block. */
895
+ private checkAllTokensEnded(): void {
896
+ if (this.hasActiveTokens()) return;
576
897
  this.trackedTokens.clear();
577
898
  this.resolveReady();
578
899
  }
@@ -585,15 +906,35 @@ export class LspClient {
585
906
  this._readyResolve = undefined;
586
907
  this._readyReject = undefined;
587
908
  }
909
+ // A rejected or disposed client must never be marked ready again,
910
+ // even if a stray progress end arrives after the rejection.
911
+ if (this._status !== "running") return;
912
+ const becameReady = !this._isReady;
588
913
  this._isReady = true;
914
+ if (becameReady) this.everReady = true;
915
+ if (becameReady) this.publishLifecycle("readiness");
589
916
  recordDebugEvent({
590
917
  source: "lsp",
591
918
  level: "info",
592
919
  category: "readiness.resolved",
593
- message: `LSP client ${this.name} is ready (cwd: ${this.root})`,
920
+ message: `LSP client ${this.name} is ready`,
921
+ cwd: boundCwd(this.cwd),
922
+ data: { server: this.name, root: truncateIdentity(this.root) },
594
923
  });
595
924
  }
596
925
 
926
+ /**
927
+ * Tear down readiness state without a protocol shutdown.
928
+ *
929
+ * Clears pending and active progress tokens and rejects any pending
930
+ * readiness. Called when a client is discarded or its observer is
931
+ * disposed so token state cannot outlive the client.
932
+ */
933
+ dispose(): void {
934
+ this.cancelNoProgressTimer();
935
+ this.dynamicRegistrations.clear();
936
+ this.rejectReady(new Error("Client disposed"));
937
+ }
597
938
  /**
598
939
  * Reject the current readiness promise (if any) and mark the client
599
940
  * not ready. Called on shutdown, crash, or restart.
@@ -607,6 +948,7 @@ export class LspClient {
607
948
  }
608
949
  this._isReady = false;
609
950
  this.trackedTokens.clear();
951
+ this.tokenCreatedAt.clear();
610
952
  for (const timer of this.tokenTimeouts.values()) clearTimeout(timer);
611
953
  this.tokenTimeouts.clear();
612
954
  recordDebugEvent({
@@ -614,7 +956,12 @@ export class LspClient {
614
956
  level: this._status === "shutdown" ? "debug" : "warning",
615
957
  category: "readiness.rejected",
616
958
  message: `LSP client ${this.name} readiness rejected: ${reason.message}`,
617
- data: { status: this._status },
959
+ cwd: boundCwd(this.cwd),
960
+ data: {
961
+ server: this.name,
962
+ root: truncateIdentity(this.root),
963
+ status: this._status,
964
+ },
618
965
  });
619
966
  }
620
967
 
@@ -634,8 +981,9 @@ export class LspClient {
634
981
  source: "lsp",
635
982
  level: "debug",
636
983
  category: "readiness.token-timeout",
637
- message: `Readiness per-token timeout fired for token ${token} after ${timeoutMs}ms`,
638
- data: { token, timeoutMs },
984
+ message: `Readiness per-token timeout fired for ${this.name} after ${timeoutMs}ms`,
985
+ cwd: boundCwd(this.cwd),
986
+ data: { server: this.name, root: truncateIdentity(this.root), timeoutMs },
639
987
  });
640
988
  this.checkAllTokensEnded();
641
989
  }, timeoutMs);
@@ -661,6 +1009,8 @@ export class LspClient {
661
1009
  level: "debug",
662
1010
  category: "readiness.no-progress-cancelled",
663
1011
  message: `No-progress grace timer cancelled for ${this.name}`,
1012
+ cwd: boundCwd(this.cwd),
1013
+ data: { server: this.name, root: truncateIdentity(this.root) },
664
1014
  });
665
1015
  }
666
1016
  }
@@ -676,12 +1026,14 @@ export class LspClient {
676
1026
  */
677
1027
  private armNoProgressTimer(): void {
678
1028
  this.noProgressTimer = setTimeout(() => {
679
- if (this.trackedTokens.size === 0 && this._status === "running") {
1029
+ if (!this.hasActiveTokens() && this._status === "running") {
680
1030
  recordDebugEvent({
681
1031
  source: "lsp",
682
1032
  level: "debug",
683
1033
  category: "readiness.no-progress-resolved",
684
1034
  message: `No-progress grace timer resolved for ${this.name}`,
1035
+ cwd: boundCwd(this.cwd),
1036
+ data: { server: this.name, root: truncateIdentity(this.root) },
685
1037
  });
686
1038
  this.resolveReady();
687
1039
  }