ultimate-pi 0.18.1 → 0.19.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 (284) hide show
  1. package/.agents/skills/harness-debate-plan/SKILL.md +1 -1
  2. package/.agents/skills/harness-decisions/SKILL.md +1 -2
  3. package/.agents/skills/harness-governor/SKILL.md +6 -5
  4. package/.pi/PACKAGING.md +4 -4
  5. package/.pi/SYSTEM.md +54 -120
  6. package/.pi/agents/harness/incident-recorder.md +0 -1
  7. package/.pi/agents/harness/planning/decompose.md +0 -2
  8. package/.pi/agents/harness/planning/execution-plan-author.md +0 -2
  9. package/.pi/agents/harness/planning/hypothesis-validator.md +0 -2
  10. package/.pi/agents/harness/planning/hypothesis.md +0 -2
  11. package/.pi/agents/harness/planning/implementation-researcher.md +0 -2
  12. package/.pi/agents/harness/planning/plan-adversary.md +0 -2
  13. package/.pi/agents/harness/planning/plan-evaluator.md +1 -3
  14. package/.pi/agents/harness/planning/planning-context.md +0 -2
  15. package/.pi/agents/harness/planning/review-integrator.md +0 -2
  16. package/.pi/agents/harness/planning/sprint-contract-auditor.md +0 -2
  17. package/.pi/agents/harness/planning/stack-researcher.md +0 -2
  18. package/.pi/agents/harness/reviewing/adversary.md +0 -2
  19. package/.pi/agents/harness/reviewing/evaluator.md +0 -2
  20. package/.pi/agents/harness/reviewing/tie-breaker.md +0 -2
  21. package/.pi/agents/harness/running/executor.md +0 -2
  22. package/.pi/agents/harness/sentrux-bootstrap.md +0 -1
  23. package/.pi/agents/harness/sentrux-steward.md +0 -2
  24. package/.pi/agents/harness/trace-librarian.md +0 -1
  25. package/.pi/extensions/00-posthog-network-bootstrap.ts +1 -1
  26. package/.pi/extensions/agt-kill-switch.ts +57 -0
  27. package/.pi/extensions/agt-prompt-guard.ts +32 -0
  28. package/.pi/extensions/custom-footer.ts +46 -145
  29. package/.pi/extensions/custom-header.ts +1 -1
  30. package/.pi/extensions/custom-system-prompt.ts +1 -1
  31. package/.pi/extensions/debate-orchestrator.ts +6 -6
  32. package/.pi/extensions/harness-ask-user.ts +7 -7
  33. package/.pi/extensions/harness-debate-tools.ts +26 -42
  34. package/.pi/extensions/harness-lens.ts +94 -0
  35. package/.pi/extensions/harness-plan-approval.ts +11 -11
  36. package/.pi/extensions/harness-run-context.ts +1070 -876
  37. package/.pi/extensions/harness-subagent-governance.ts +8 -0
  38. package/.pi/extensions/harness-subagent-submit.ts +34 -163
  39. package/.pi/extensions/harness-subagents.ts +3 -3
  40. package/.pi/extensions/harness-telemetry.ts +2 -2
  41. package/.pi/extensions/harness-web-tools.ts +2 -2
  42. package/.pi/extensions/policy-gate.ts +25 -5
  43. package/.pi/extensions/sentrux-rules-sync.ts +1 -1
  44. package/.pi/extensions/subagent-governance.ts +92 -0
  45. package/.pi/extensions/trace-recorder.ts +1 -1
  46. package/.pi/extensions/{ultimate-pi-vcc.ts → vcc-compaction.ts} +1 -1
  47. package/.pi/harness/README.md +6 -2
  48. package/.pi/harness/agents.manifest.json +22 -25
  49. package/.pi/harness/agents.policy.yaml +275 -0
  50. package/.pi/harness/docs/adrs/0030-inhouse-vcc-compaction.md +1 -1
  51. package/.pi/harness/docs/adrs/0035-plan-phase-review-gate.md +1 -1
  52. package/.pi/harness/docs/adrs/0045-harness-lens-minimal-contract.md +49 -0
  53. package/.pi/harness/docs/adrs/0046-agt-policy-engine.md +51 -0
  54. package/.pi/harness/docs/adrs/0047-agt-layered-security.md +39 -0
  55. package/.pi/harness/docs/adrs/0048-tool-call-hook-order.md +25 -0
  56. package/.pi/harness/docs/adrs/0049-agents-policy-manifest.md +36 -0
  57. package/.pi/harness/docs/adrs/README.md +5 -0
  58. package/.pi/harness/evolution/README.md +1 -2
  59. package/.pi/harness/examples/agents.policy.project.yaml +19 -0
  60. package/.pi/harness/examples/policies/custom-deny-bash.yaml +9 -0
  61. package/.pi/harness/policies/bash-denylists.yaml +5 -0
  62. package/.pi/harness/policies/defaults.yaml +51 -0
  63. package/.pi/harness/policies/orchestrator.yaml +18 -0
  64. package/.pi/harness/policies/phases.yaml +10 -0
  65. package/.pi/harness/policies/roles.yaml +5 -0
  66. package/.pi/harness/policies/web-guard.yaml +5 -0
  67. package/.pi/harness/policies/workflow-sequences.yaml +9 -0
  68. package/.pi/harness/sentrux/architecture.manifest.json +26 -4
  69. package/.pi/harness/specs/observation.schema.json +2 -1
  70. package/.pi/lib/agents-policy.d.mts +70 -0
  71. package/.pi/lib/agents-policy.mjs +325 -0
  72. package/.pi/lib/agents-policy.ts +19 -0
  73. package/.pi/lib/agt/audit-run-sink.ts +52 -0
  74. package/.pi/lib/agt/build-evaluation-context.ts +285 -0
  75. package/.pi/lib/agt/config.ts +28 -0
  76. package/.pi/lib/agt/delegation.ts +69 -0
  77. package/.pi/lib/agt/evaluate-policy.ts +56 -0
  78. package/.pi/lib/agt/identity-registry.ts +41 -0
  79. package/.pi/lib/agt/index.ts +55 -0
  80. package/.pi/lib/agt/kill-switch-state.ts +11 -0
  81. package/.pi/lib/agt/legacy-evaluate.ts +101 -0
  82. package/.pi/lib/agt/policy-engine.ts +154 -0
  83. package/.pi/lib/agt/rings.ts +21 -0
  84. package/.pi/lib/agt/sre-hooks.ts +45 -0
  85. package/.pi/lib/agt/trust-run-store.ts +26 -0
  86. package/.pi/lib/agt/workflow-history.ts +29 -0
  87. package/.pi/lib/agt-governance-active.ts +14 -0
  88. package/.pi/lib/agt-tool-guard.ts +78 -0
  89. package/.pi/lib/ask-user/dialog.ts +314 -0
  90. package/.pi/{extensions/lib → lib}/debate-bus-core.ts +10 -10
  91. package/.pi/{extensions/lib → lib}/debate-bus-state.ts +1 -1
  92. package/.pi/{extensions/lib → lib}/extension-load-guard.ts +13 -2
  93. package/.pi/lib/harness-agt-tool-guard.ts +5 -0
  94. package/.pi/{extensions/lib → lib}/harness-artifact-gate.ts +1 -1
  95. package/.pi/lib/harness-debate-core-deps.ts +14 -0
  96. package/.pi/lib/harness-debate-workflow-deps.ts +43 -0
  97. package/.pi/lib/harness-lens/.gitattributes +1 -0
  98. package/.pi/lib/harness-lens/clients/edit-autopatch.ts +88 -0
  99. package/.pi/lib/harness-lens/clients/file-kinds.ts +380 -0
  100. package/.pi/lib/harness-lens/clients/file-time.ts +215 -0
  101. package/.pi/lib/harness-lens/clients/file-utils.ts +484 -0
  102. package/.pi/lib/harness-lens/clients/format-service.ts +276 -0
  103. package/.pi/lib/harness-lens/clients/formatters.ts +1000 -0
  104. package/.pi/lib/harness-lens/clients/git-guard.ts +31 -0
  105. package/.pi/lib/harness-lens/clients/indent-retarget.ts +90 -0
  106. package/.pi/lib/harness-lens/clients/installer/index.ts +2368 -0
  107. package/.pi/lib/harness-lens/clients/latency-logger.ts +80 -0
  108. package/.pi/lib/harness-lens/clients/lens-config.ts +43 -0
  109. package/.pi/lib/harness-lens/clients/lens-events.ts +164 -0
  110. package/.pi/lib/harness-lens/clients/lsp/aggregation.ts +91 -0
  111. package/.pi/lib/harness-lens/clients/lsp/client.ts +1466 -0
  112. package/.pi/lib/harness-lens/clients/lsp/config.ts +216 -0
  113. package/.pi/lib/harness-lens/clients/lsp/edits.ts +297 -0
  114. package/.pi/lib/harness-lens/clients/lsp/index.ts +1355 -0
  115. package/.pi/lib/harness-lens/clients/lsp/interactive-install.ts +424 -0
  116. package/.pi/lib/harness-lens/clients/lsp/language.ts +223 -0
  117. package/.pi/lib/harness-lens/clients/lsp/launch.ts +939 -0
  118. package/.pi/lib/harness-lens/clients/lsp/lsp-index.ts +11 -0
  119. package/.pi/lib/harness-lens/clients/lsp/path-utils.ts +12 -0
  120. package/.pi/lib/harness-lens/clients/lsp/server-strategies.ts +81 -0
  121. package/.pi/lib/harness-lens/clients/lsp/server.ts +1971 -0
  122. package/.pi/lib/harness-lens/clients/path-utils.ts +182 -0
  123. package/.pi/lib/harness-lens/clients/pipeline.ts +360 -0
  124. package/.pi/lib/harness-lens/clients/project-profile.ts +117 -0
  125. package/.pi/lib/harness-lens/clients/runtime-agent-end.ts +112 -0
  126. package/.pi/lib/harness-lens/clients/runtime-config.ts +33 -0
  127. package/.pi/lib/harness-lens/clients/runtime-coordinator.ts +186 -0
  128. package/.pi/lib/harness-lens/clients/runtime-tool-result.ts +171 -0
  129. package/.pi/lib/harness-lens/clients/safe-spawn.ts +339 -0
  130. package/.pi/lib/harness-lens/clients/secrets-scanner.ts +214 -0
  131. package/.pi/lib/harness-lens/clients/tool-policy.ts +2072 -0
  132. package/.pi/lib/harness-lens/clients/types.ts +59 -0
  133. package/.pi/lib/harness-lens/clients/widget-state.ts +283 -0
  134. package/.pi/lib/harness-lens/index.ts +532 -0
  135. package/.pi/lib/harness-lens/tools/lsp-diagnostics.ts +706 -0
  136. package/.pi/lib/harness-lens/tools/lsp-navigation.ts +1246 -0
  137. package/.pi/{extensions/lib → lib}/harness-posthog.ts +3 -0
  138. package/.pi/lib/harness-run-context-responses.ts +9 -0
  139. package/.pi/lib/harness-run-context.ts +0 -2
  140. package/.pi/{extensions/lib/spawn-policy.ts → lib/harness-spawn-policy.ts} +1 -0
  141. package/.pi/{extensions/lib → lib}/harness-spawn-topology.ts +1 -1
  142. package/.pi/lib/harness-subagent-auth.ts +51 -0
  143. package/.pi/{extensions/lib → lib}/harness-subagent-precheck.ts +10 -7
  144. package/.pi/{extensions/lib → lib}/harness-subagent-submit-pipeline.ts +3 -3
  145. package/.pi/lib/harness-subagent-submit-register.ts +163 -0
  146. package/.pi/{extensions/lib → lib}/harness-subagent-submit-registry.ts +1 -37
  147. package/.pi/{extensions/lib → lib}/harness-subagents-bridge.ts +53 -14
  148. package/.pi/{extensions/lib → lib}/harness-subprocess-bootstrap.ts +1 -1
  149. package/.pi/{extensions/lib → lib}/plan-approval/create-plan.ts +2 -2
  150. package/.pi/{extensions/lib → lib}/plan-approval/format-plan.ts +2 -2
  151. package/.pi/{extensions/lib → lib}/plan-approval/plan-review.ts +162 -201
  152. package/.pi/{extensions/lib → lib}/plan-approval/render.ts +1 -1
  153. package/.pi/{extensions/lib → lib}/plan-approval/resolve-disk.ts +2 -2
  154. package/.pi/{extensions/lib → lib}/plan-approval/types.ts +1 -1
  155. package/.pi/{extensions/lib → lib}/plan-approval/validate.ts +3 -3
  156. package/.pi/{extensions/lib → lib}/plan-debate-envelope.ts +1 -1
  157. package/.pi/{extensions/lib → lib}/plan-debate-gate.ts +1 -1
  158. package/.pi/{extensions/lib → lib}/plan-debate-lane.ts +1 -4
  159. package/.pi/{extensions/lib → lib}/plan-messenger.ts +1 -1
  160. package/.pi/prompts/harness-plan.md +1 -1
  161. package/.pi/prompts/harness-setup.md +37 -64
  162. package/.pi/scripts/README.md +2 -5
  163. package/.pi/scripts/generate-agents-policy-yaml.mjs +148 -0
  164. package/.pi/scripts/harness-agents-manifest.mjs +60 -3
  165. package/.pi/scripts/harness-agt-doctor.ts +36 -0
  166. package/.pi/scripts/harness-cli-verify.sh +9 -2
  167. package/.pi/scripts/harness-verify.mjs +113 -39
  168. package/.pi/scripts/harness-web-policy-guard.mjs +2 -2
  169. package/.pi/scripts/validate-plan-dag.mjs +65 -74
  170. package/.pi/scripts/vendor-pi-vcc-settings.stub.ts +2 -2
  171. package/.pi/scripts/vendor-sync-pi-vcc.sh +1 -1
  172. package/.pi/skills/architecture/broker-domain/SKILL.md +65 -0
  173. package/.pi/skills/architecture/cqrs/SKILL.md +63 -0
  174. package/.pi/skills/architecture/event-driven/SKILL.md +60 -0
  175. package/.pi/skills/architecture/hexagonal-ports-adapters/SKILL.md +66 -0
  176. package/.pi/skills/architecture/layered/SKILL.md +68 -0
  177. package/.pi/skills/architecture/microkernel/SKILL.md +62 -0
  178. package/.pi/skills/architecture/microservices/SKILL.md +64 -0
  179. package/.pi/skills/architecture/modular-monolith/SKILL.md +65 -0
  180. package/.pi/skills/architecture/orchestration-driven-soa/SKILL.md +61 -0
  181. package/.pi/skills/architecture/pipeline/SKILL.md +63 -0
  182. package/.pi/skills/architecture/service-based/SKILL.md +64 -0
  183. package/.pi/skills/architecture/service-mesh/SKILL.md +60 -0
  184. package/.pi/skills/architecture/space-based/SKILL.md +60 -0
  185. package/.pi/skills/ast-grep/SKILL.md +40 -321
  186. package/.pi/skills/delivery/debugging-discipline/SKILL.md +36 -0
  187. package/.pi/skills/delivery/documentation-update/SKILL.md +33 -0
  188. package/.pi/skills/delivery/requirements-to-implementation/SKILL.md +34 -0
  189. package/.pi/skills/delivery/risk-based-verification/SKILL.md +43 -0
  190. package/.pi/skills/delivery/tradeoff-analysis/SKILL.md +34 -0
  191. package/.pi/skills/engineering/api-contract-design/SKILL.md +38 -0
  192. package/.pi/skills/engineering/cohesion-coupling/SKILL.md +43 -0
  193. package/.pi/skills/engineering/complexity-control/SKILL.md +31 -0
  194. package/.pi/skills/engineering/defensive-programming/SKILL.md +38 -0
  195. package/.pi/skills/engineering/dependency-management/SKILL.md +29 -0
  196. package/.pi/skills/engineering/domain-modeling/SKILL.md +32 -0
  197. package/.pi/skills/engineering/error-handling/SKILL.md +37 -0
  198. package/.pi/skills/engineering/legacy-code-seams/SKILL.md +35 -0
  199. package/.pi/skills/engineering/naming-and-intent/SKILL.md +29 -0
  200. package/.pi/skills/engineering/refactoring-safe-evolution/SKILL.md +35 -0
  201. package/.pi/skills/engineering/routine-function-design/SKILL.md +34 -0
  202. package/.pi/skills/engineering/small-change-discipline/SKILL.md +35 -0
  203. package/.pi/skills/lsp-navigation/SKILL.md +89 -0
  204. package/.pi/skills/quality/code-review-self-check/SKILL.md +35 -0
  205. package/.pi/skills/quality/privacy-data-handling/SKILL.md +26 -0
  206. package/.pi/skills/quality/security-review/SKILL.md +34 -0
  207. package/.pi/skills/quality/test-strategy/SKILL.md +33 -0
  208. package/.pi/skills/quality/testability-design/SKILL.md +33 -0
  209. package/.pi/skills/systems/concurrency-safety/SKILL.md +32 -0
  210. package/.pi/skills/systems/data-modeling-migrations/SKILL.md +31 -0
  211. package/.pi/skills/systems/observability-instrumentation/SKILL.md +32 -0
  212. package/.pi/skills/systems/performance-measurement/SKILL.md +35 -0
  213. package/.pi/skills/systems/reliability-design/SKILL.md +32 -0
  214. package/.sentrux/rules.toml +20 -4
  215. package/AGENTS.md +5 -0
  216. package/CHANGELOG.md +14 -0
  217. package/README.md +3 -12
  218. package/THIRD_PARTY_NOTICES.md +12 -21
  219. package/package.json +15 -7
  220. package/vendor/pi-subagents/src/agents.ts +45 -1
  221. package/vendor/pi-subagents/src/subagents.ts +866 -811
  222. package/vendor/pi-vcc/src/core/brief.ts +68 -99
  223. package/vendor/pi-vcc/src/core/settings.ts +2 -2
  224. package/.agents/skills/caveman/SKILL.md +0 -67
  225. package/.pi/agents/harness/meta-optimizer.md +0 -36
  226. package/.pi/extensions/lib/ask-user/dialog.ts +0 -260
  227. package/.pi/extensions/lib/harness-subagent-auth.ts +0 -207
  228. package/.pi/extensions/lib/harness-subagent-policy.ts +0 -236
  229. package/.pi/extensions/pi-model-router-harness.ts +0 -42
  230. package/.pi/harness/evolution/meta-optimizer.mjs +0 -99
  231. package/.pi/harness/specs/router-tuning-proposal.schema.json +0 -114
  232. package/.pi/model-router.example.json +0 -36
  233. package/.pi/prompts/harness-critic.md +0 -10
  234. package/.pi/prompts/harness-eval.md +0 -10
  235. package/.pi/prompts/harness-router-tune.md +0 -52
  236. package/.pi/scripts/harness-generate-model-router.mjs +0 -327
  237. package/.pi/scripts/harness-model-router-routing.test.mjs +0 -97
  238. package/.pi/scripts/harness-sync-model-router.mjs +0 -97
  239. package/.pi/scripts/vendor-sync-pi-model-router.sh +0 -47
  240. package/vendor/pi-model-router/.prettierignore +0 -4
  241. package/vendor/pi-model-router/.prettierrc +0 -5
  242. package/vendor/pi-model-router/AGENTS.md +0 -39
  243. package/vendor/pi-model-router/LICENSE +0 -21
  244. package/vendor/pi-model-router/README.md +0 -99
  245. package/vendor/pi-model-router/UPSTREAM_PIN.md +0 -10
  246. package/vendor/pi-model-router/docs/ARCHITECTURE.md +0 -54
  247. package/vendor/pi-model-router/extensions/commands.ts +0 -720
  248. package/vendor/pi-model-router/extensions/config.ts +0 -348
  249. package/vendor/pi-model-router/extensions/constants.ts +0 -1
  250. package/vendor/pi-model-router/extensions/index.ts +0 -478
  251. package/vendor/pi-model-router/extensions/provider.ts +0 -580
  252. package/vendor/pi-model-router/extensions/routing.ts +0 -564
  253. package/vendor/pi-model-router/extensions/state.ts +0 -52
  254. package/vendor/pi-model-router/extensions/types.ts +0 -95
  255. package/vendor/pi-model-router/extensions/ui.ts +0 -144
  256. package/vendor/pi-model-router/model-router.example.json +0 -48
  257. package/vendor/pi-model-router/package.json +0 -48
  258. package/vendor/pi-model-router/tsconfig.json +0 -16
  259. /package/.pi/{prompts → harness/docs}/planning-rubrics.md +0 -0
  260. /package/.pi/{extensions/lib → lib}/ask-user/fallback.ts +0 -0
  261. /package/.pi/{extensions/lib → lib}/ask-user/render.ts +0 -0
  262. /package/.pi/{extensions/lib → lib}/ask-user/schema.ts +0 -0
  263. /package/.pi/{extensions/lib → lib}/ask-user/types.ts +0 -0
  264. /package/.pi/{extensions/lib → lib}/ask-user/validate-core.mjs +0 -0
  265. /package/.pi/{extensions/lib → lib}/ask-user/validate.ts +0 -0
  266. /package/.pi/{extensions/lib → lib}/harness-cocoindex-refresh.ts +0 -0
  267. /package/.pi/{extensions/lib → lib}/harness-paths.ts +0 -0
  268. /package/.pi/{extensions/lib → lib}/harness-spawn-budget.ts +0 -0
  269. /package/.pi/{extensions/lib → lib}/harness-vcc-settings.ts +0 -0
  270. /package/.pi/{extensions/lib → lib}/harness-web/run-cli.ts +0 -0
  271. /package/.pi/{extensions/lib → lib}/plan-approval/dialog.ts +0 -0
  272. /package/.pi/{extensions/lib → lib}/plan-approval/schema.ts +0 -0
  273. /package/.pi/{extensions/lib → lib}/plan-approval-readiness.ts +0 -0
  274. /package/.pi/{extensions/lib → lib}/plan-debate-eligibility.ts +0 -0
  275. /package/.pi/{extensions/lib → lib}/plan-debate-focus.ts +0 -0
  276. /package/.pi/{extensions/lib → lib}/plan-debate-id.ts +0 -0
  277. /package/.pi/{extensions/lib → lib}/plan-debate-lanes.ts +0 -0
  278. /package/.pi/{extensions/lib → lib}/plan-debate-round-status.ts +0 -0
  279. /package/.pi/{extensions/lib → lib}/plan-debate-write-guard.ts +0 -0
  280. /package/.pi/{extensions/lib → lib}/plan-review-gate.ts +0 -0
  281. /package/.pi/{extensions/lib → lib}/plan-review-integrator-rules.ts +0 -0
  282. /package/.pi/{extensions/lib → lib}/plan-scope-guard.ts +0 -0
  283. /package/.pi/{extensions/lib → lib}/posthog-client.ts +0 -0
  284. /package/.pi/{extensions/lib → lib}/posthog-node.d.ts +0 -0
@@ -0,0 +1,1355 @@
1
+ /**
2
+ * LSP Service Layer for pi-lens
3
+ *
4
+ * Manages multiple LSP clients per workspace with:
5
+ * - Auto-spawning based on file type
6
+ * - Effect-TS service composition
7
+ * - Bus event integration
8
+ * - Resource cleanup
9
+ */
10
+
11
+ import * as nodeFs from "node:fs";
12
+ import fs from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { logLatency } from "../latency-logger.js";
15
+ import { normalizeMapKey, uriToPath } from "../path-utils.js";
16
+ import { recordLsp } from "../widget-state.js";
17
+ import { raceToCompletion } from "./aggregation.js";
18
+ import type { LSPClientInfo } from "./client.js";
19
+ import { createLSPClient } from "./client.js";
20
+ import { getServersForFileWithConfig } from "./config.js";
21
+ import { getLanguageId } from "./language.js";
22
+ import type { LSPServerInfo } from "./server.js";
23
+ import { isDirectLspCommandTemporarilyUnavailable } from "./server.js";
24
+ import { getStrategy } from "./server-strategies.js";
25
+
26
+ // --- Types ---
27
+
28
+ export interface LSPState {
29
+ clients: Map<string, LSPClientInfo>; // key: "serverId:root"
30
+ servers: Map<string, LSPServerInfo>;
31
+ broken: Map<string, number>; // servers that failed to initialize with retry-at timestamp
32
+ inFlight: Map<string, Promise<SpawnedServer | undefined>>; // prevent duplicate spawns
33
+ clientSpawnedAt: Map<string, number>; // key: "serverId:root" → epoch ms of last successful spawn
34
+ }
35
+
36
+ const BROKEN_BASE_COOLDOWN_MS = 15_000;
37
+ const BROKEN_MAX_COOLDOWN_MS = 5 * 60_000; // cap at 5 minutes
38
+ const BROKEN_PERMANENT_AFTER = 5; // disable for session after N consecutive failures
39
+ const OPTIONAL_LSP_RETRY_COOLDOWN_MS = 5 * 60_000;
40
+ const OPTIONAL_LSP_SERVER_IDS = new Set<string>();
41
+ const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(
42
+ 0,
43
+ Number.parseInt(process.env.PI_LENS_LSP_NAV_CLIENT_WAIT_MS ?? "1500", 10) ||
44
+ 1500,
45
+ );
46
+ const TOUCH_DEBOUNCE_MS = Math.max(
47
+ 0,
48
+ Number.parseInt(process.env.PI_LENS_LSP_TOUCH_DEBOUNCE_MS ?? "1500", 10) ||
49
+ 1500,
50
+ );
51
+ const DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS = Math.max(
52
+ 0,
53
+ Number.parseInt(
54
+ process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_THRESHOLD_MS ?? "250",
55
+ 10,
56
+ ) || 250,
57
+ );
58
+ const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(
59
+ 0,
60
+ Number.parseInt(
61
+ process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_SETTLE_MS ?? "400",
62
+ 10,
63
+ ) || 400,
64
+ );
65
+ // Once the fastest client has diagnostics, remaining clients get this window before
66
+ // we proceed with whatever results are ready. 0 disables early-unblock.
67
+ const EARLY_UNBLOCK_GRACE_MS = Math.max(
68
+ 0,
69
+ Number.parseInt(
70
+ process.env.PI_LENS_LSP_EARLY_UNBLOCK_GRACE_MS ?? "400",
71
+ 10,
72
+ ) || 400,
73
+ );
74
+ const CASCADE_DIAGNOSTICS_TTL_MS = 240_000;
75
+ const SESSIONSTART_LOG_DIR = path.join(
76
+ process.cwd(),
77
+ ".pi",
78
+ "harness",
79
+ ".lens",
80
+ );
81
+ const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log");
82
+
83
+ function logSessionStart(msg: string): void {
84
+ if (
85
+ process.env.PI_LENS_TEST_MODE === "1" ||
86
+ (process.env.VITEST && process.env.PI_LENS_TEST_MODE !== "0")
87
+ ) {
88
+ return;
89
+ }
90
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
91
+ void fs
92
+ .mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
93
+ .then(() => fs.appendFile(SESSIONSTART_LOG, line))
94
+ .catch(() => {
95
+ // best-effort logging
96
+ });
97
+ }
98
+
99
+ export interface SpawnedServer {
100
+ client: LSPClientInfo;
101
+ info: LSPServerInfo;
102
+ }
103
+
104
+ export interface LSPDiagnosticsHealth {
105
+ health: "ok" | "ok_empty" | "no_clients" | "no_clients_stale" | "destroyed";
106
+ failureKind: string;
107
+ serverCountAttempted: number;
108
+ serverCountReady: number;
109
+ candidateServerIds: string[];
110
+ mergedCount: number;
111
+ dedupDroppedCount: number;
112
+ checkedAt: string;
113
+ }
114
+
115
+ function mergeLspDiagnostics(
116
+ diagnostics: import("./client.js").LSPDiagnostic[],
117
+ ): import("./client.js").LSPDiagnostic[] {
118
+ const merged: import("./client.js").LSPDiagnostic[] = [];
119
+ const seen = new Set<string>();
120
+ for (const diagnostic of diagnostics) {
121
+ const key = [
122
+ diagnostic.range.start.line,
123
+ diagnostic.range.start.character,
124
+ diagnostic.message,
125
+ ].join(":");
126
+ if (seen.has(key)) continue;
127
+ seen.add(key);
128
+ merged.push(diagnostic);
129
+ }
130
+ return merged;
131
+ }
132
+
133
+ export type LSPDiagnosticsMode = "none" | "document" | "full";
134
+ export type LSPTouchClientScope = "primary" | "all";
135
+
136
+ export interface LSPTouchFileOptions {
137
+ diagnostics?: LSPDiagnosticsMode;
138
+ source?: string;
139
+ clientScope?: LSPTouchClientScope;
140
+ maxClientWaitMs?: number;
141
+ /** Return merged diagnostics from the clients touched by this call. */
142
+ collectDiagnostics?: boolean;
143
+ /** Skip workspace/didChangeWatchedFiles — use for cascade reads, not real fs changes */
144
+ silent?: boolean;
145
+ }
146
+
147
+ // --- Service ---
148
+
149
+ export class LSPService {
150
+ private state: LSPState;
151
+ private readonly workspaceProbeLogged = new Set<string>();
152
+ private readonly warmStartLogged = new Set<string>();
153
+ private readonly optionalFailureLogged = new Set<string>();
154
+ private readonly optionalDisabled = new Set<string>();
155
+ /** Consecutive failure counts for exponential backoff circuit breaker */
156
+ private readonly failureCounts = new Map<string, number>();
157
+ /** Server/root keys disabled for the rest of this session after repeated failures. */
158
+ private readonly permanentlyBroken = new Set<string>();
159
+ /**
160
+ * Last non-empty diagnostic result per normalized file path.
161
+ * Returned as a fallback when no live LSP clients are available so the
162
+ * widget keeps showing the last known issues rather than going blank.
163
+ */
164
+ private readonly lastKnownDiagnostics = new Map<
165
+ string,
166
+ import("./client.js").LSPDiagnostic[]
167
+ >();
168
+ private readonly lastDiagnosticsHealth = new Map<
169
+ string,
170
+ LSPDiagnosticsHealth
171
+ >();
172
+ private readonly recentTouches = new Map<
173
+ string,
174
+ { fingerprint: string; touchedAt: number; clientScope: "primary" | "all" }
175
+ >();
176
+ /** True after shutdown() has been called; blocks new operations */
177
+ private isDestroyed = false;
178
+
179
+ constructor() {
180
+ this.state = {
181
+ clients: new Map(),
182
+ servers: new Map(),
183
+ broken: new Map(),
184
+ inFlight: new Map(),
185
+ clientSpawnedAt: new Map(),
186
+ };
187
+ }
188
+
189
+ /** Guard: return true if service is shutting down or shut down */
190
+ private checkDestroyed(): boolean {
191
+ return this.isDestroyed;
192
+ }
193
+
194
+ private fingerprintContent(content: string): string {
195
+ if (content.length <= 96) {
196
+ return `${content.length}:${content}`;
197
+ }
198
+ return `${content.length}:${content.slice(0, 48)}:${content.slice(-48)}`;
199
+ }
200
+
201
+ private shouldSkipTouch(
202
+ filePath: string,
203
+ content: string,
204
+ clientScope: "primary" | "all",
205
+ waitForDiagnostics: boolean,
206
+ ): boolean {
207
+ if (waitForDiagnostics || TOUCH_DEBOUNCE_MS <= 0) {
208
+ return false;
209
+ }
210
+
211
+ const key = `${normalizeMapKey(filePath)}:${clientScope}`;
212
+ const previous = this.recentTouches.get(key);
213
+ if (!previous) return false;
214
+
215
+ const now = Date.now();
216
+ if (now - previous.touchedAt > TOUCH_DEBOUNCE_MS) {
217
+ return false;
218
+ }
219
+
220
+ return previous.fingerprint === this.fingerprintContent(content);
221
+ }
222
+
223
+ private markTouched(
224
+ filePath: string,
225
+ content: string,
226
+ clientScope: "primary" | "all",
227
+ ): void {
228
+ const key = `${normalizeMapKey(filePath)}:${clientScope}`;
229
+ const now = Date.now();
230
+ this.recentTouches.set(key, {
231
+ fingerprint: this.fingerprintContent(content),
232
+ touchedAt: now,
233
+ clientScope,
234
+ });
235
+ // Trim entries that are already past the debounce window — shouldSkipTouch
236
+ // ignores them anyway, so they serve no purpose. Only sweep when the map
237
+ // exceeds the threshold to avoid iterating on every call.
238
+ if (this.recentTouches.size > 200) {
239
+ for (const [k, v] of this.recentTouches) {
240
+ if (now - v.touchedAt > TOUCH_DEBOUNCE_MS) {
241
+ this.recentTouches.delete(k);
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Get or create LSP client for a file
249
+ * Prevents duplicate client creation via in-flight promise tracking
250
+ */
251
+ async getClientForFile(
252
+ filePath: string,
253
+ maxWaitMs?: number,
254
+ hardCapMs?: number,
255
+ ): Promise<SpawnedServer | undefined> {
256
+ if (this.checkDestroyed()) return undefined;
257
+ const servers = getServersForFileWithConfig(filePath);
258
+ const serverWaitOverrideMs = servers.reduce(
259
+ (max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0),
260
+ 0,
261
+ );
262
+ // hardCapMs is a caller-imposed ceiling (e.g. pipeline budget) that
263
+ // prevents tool_result from blocking the TUI for the full LSP cold-start
264
+ // window. When no server config sets a wait (serverWaitOverrideMs = 0),
265
+ // hardCapMs is used directly — Math.min(0, cap) = 0 would otherwise
266
+ // take the no-timeout branch and block indefinitely (e.g. pyright, which
267
+ // has no clientWaitTimeoutMs but can take 30s to initialize on cold start).
268
+ const serverBaseMs = Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
269
+ const effectiveMaxWaitMs =
270
+ hardCapMs !== undefined
271
+ ? serverBaseMs > 0
272
+ ? Math.min(serverBaseMs, hardCapMs)
273
+ : hardCapMs
274
+ : serverBaseMs;
275
+
276
+ const withBudget = async (): Promise<SpawnedServer | undefined> => {
277
+ if (servers.length === 0) return undefined;
278
+
279
+ // Try each matching server
280
+ for (const server of servers) {
281
+ const spawned = await this.ensureClientForServer(filePath, server);
282
+ if (spawned) {
283
+ logLatency({
284
+ type: "phase",
285
+ phase: "lsp_client_selected",
286
+ filePath,
287
+ durationMs: 0,
288
+ metadata: {
289
+ serverId: server.id,
290
+ candidateCount: servers.length,
291
+ },
292
+ });
293
+ return spawned;
294
+ }
295
+ }
296
+
297
+ logLatency({
298
+ type: "phase",
299
+ phase: "lsp_client_unavailable",
300
+ filePath,
301
+ durationMs: 0,
302
+ metadata: {
303
+ candidateCount: servers.length,
304
+ servers: servers.map((server) => server.id),
305
+ },
306
+ });
307
+
308
+ return undefined;
309
+ };
310
+
311
+ if (!effectiveMaxWaitMs || effectiveMaxWaitMs <= 0) {
312
+ return withBudget();
313
+ }
314
+
315
+ const timeoutSentinel = Symbol("lsp-client-wait-timeout");
316
+ const waitResult = await Promise.race<
317
+ SpawnedServer | undefined | typeof timeoutSentinel
318
+ >([
319
+ withBudget(),
320
+ new Promise<typeof timeoutSentinel>((resolve) =>
321
+ setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs),
322
+ ),
323
+ ]);
324
+
325
+ if (waitResult === timeoutSentinel) {
326
+ // Snapshot known client health — scan by serverId prefix (no root needed)
327
+ const knownHealth = [...this.state.clients.entries()]
328
+ .filter(([k]) => servers.some((s) => k.startsWith(`${s.id}:`)))
329
+ .map(([k, c]) => ({
330
+ serverId: k.split(":")[0],
331
+ alive: c.isAlive(),
332
+ spawnedAt: this.state.clientSpawnedAt.get(k) ?? null,
333
+ }));
334
+ logLatency({
335
+ type: "phase",
336
+ phase: "lsp_client_wait_timeout",
337
+ filePath,
338
+ durationMs: effectiveMaxWaitMs,
339
+ metadata: {
340
+ maxWaitMs: effectiveMaxWaitMs,
341
+ serverIds: servers.map((s) => s.id),
342
+ // servers absent from knownHealth were never spawned or are still spawning
343
+ knownClientHealth: knownHealth,
344
+ },
345
+ });
346
+ return undefined;
347
+ }
348
+
349
+ return waitResult;
350
+ }
351
+
352
+ /**
353
+ * Get or create ALL LSP clients that can serve a file.
354
+ * Used for diagnostics aggregation across complementary servers.
355
+ */
356
+ async getClientsForFile(
357
+ filePath: string,
358
+ ): Promise<{ clients: SpawnedServer[]; serverCountAttempted: number }> {
359
+ const servers = getServersForFileWithConfig(filePath);
360
+ if (servers.length === 0) return { clients: [], serverCountAttempted: 0 };
361
+
362
+ // Count servers with a valid root as "attempted" — extension-only matches
363
+ // that fail the root check are not real spawn attempts.
364
+ const roots = await Promise.all(servers.map((s) => s.root(filePath)));
365
+ const serverCountAttempted = roots.filter(Boolean).length;
366
+
367
+ const spawned = await Promise.all(
368
+ servers.map((server) => this.ensureClientForServer(filePath, server)),
369
+ );
370
+ return {
371
+ clients: spawned.filter((entry): entry is SpawnedServer =>
372
+ Boolean(entry),
373
+ ),
374
+ serverCountAttempted,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Get a warm LSP client for a file without spawning.
380
+ * Returns undefined if no matching client is already connected and alive.
381
+ */
382
+ async getWarmClientForFile(
383
+ filePath: string,
384
+ ): Promise<SpawnedServer | undefined> {
385
+ if (this.checkDestroyed()) return undefined;
386
+ const servers = getServersForFileWithConfig(filePath);
387
+ for (const server of servers) {
388
+ const root = await server.root(filePath);
389
+ if (!root) continue;
390
+ const key = `${server.id}:${normalizeMapKey(root)}`;
391
+ const existing = this.state.clients.get(key);
392
+ if (existing?.isAlive()) {
393
+ return { client: existing, info: server };
394
+ }
395
+ }
396
+ return undefined;
397
+ }
398
+
399
+ private async ensureClientForServer(
400
+ filePath: string,
401
+ server: LSPServerInfo,
402
+ ): Promise<SpawnedServer | undefined> {
403
+ const root = await server.root(filePath);
404
+ if (!root) return undefined;
405
+ const allowInstall = this.shouldAllowInstall(filePath, root);
406
+
407
+ const normalizedRoot = normalizeMapKey(root);
408
+ const key = `${server.id}:${normalizedRoot}`;
409
+ const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
410
+
411
+ if (
412
+ server.availabilityKey &&
413
+ isDirectLspCommandTemporarilyUnavailable(server.availabilityKey)
414
+ ) {
415
+ logLatency({
416
+ type: "phase",
417
+ phase: "lsp_client_skipped_unavailable_command",
418
+ filePath,
419
+ durationMs: 0,
420
+ metadata: {
421
+ serverId: server.id,
422
+ command: server.availabilityKey,
423
+ },
424
+ });
425
+ return undefined;
426
+ }
427
+
428
+ if (isOptionalServer && this.optionalDisabled.has(key)) {
429
+ return undefined;
430
+ }
431
+ if (this.permanentlyBroken.has(key)) {
432
+ logLatency({
433
+ type: "phase",
434
+ phase: "lsp_client_skipped_broken",
435
+ filePath,
436
+ durationMs: 0,
437
+ metadata: {
438
+ serverId: server.id,
439
+ permanent: true,
440
+ },
441
+ });
442
+ return undefined;
443
+ }
444
+
445
+ const existing = this.state.clients.get(key);
446
+ if (existing) {
447
+ if (existing.isAlive()) {
448
+ if (!this.warmStartLogged.has(key)) {
449
+ logSessionStart(
450
+ `lsp warm-start ${server.id}: reused root=${root} file=${filePath}`,
451
+ );
452
+ this.warmStartLogged.add(key);
453
+ }
454
+ return { client: existing, info: server };
455
+ }
456
+ // Dead client — was previously alive, now needs respawn
457
+ const spawnedAt = this.state.clientSpawnedAt.get(key);
458
+ logLatency({
459
+ type: "phase",
460
+ phase: "lsp_server_respawn",
461
+ filePath,
462
+ durationMs: 0,
463
+ metadata: {
464
+ serverId: server.id,
465
+ root,
466
+ uptimeMs: spawnedAt != null ? Date.now() - spawnedAt : null,
467
+ },
468
+ });
469
+ try {
470
+ await existing.shutdown();
471
+ } catch {
472
+ /* ignore dead client shutdown errors */
473
+ }
474
+ this.state.clients.delete(key);
475
+ this.state.clientSpawnedAt.delete(key);
476
+ this.state.broken.delete(key);
477
+ }
478
+
479
+ const brokenUntil = this.state.broken.get(key);
480
+ if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
481
+ logLatency({
482
+ type: "phase",
483
+ phase: "lsp_client_skipped_broken",
484
+ filePath,
485
+ durationMs: 0,
486
+ metadata: {
487
+ serverId: server.id,
488
+ retryInMs: Math.max(0, brokenUntil - Date.now()),
489
+ },
490
+ });
491
+ return undefined;
492
+ }
493
+ if (typeof brokenUntil === "number" && brokenUntil <= Date.now()) {
494
+ this.state.broken.delete(key);
495
+ if (isOptionalServer) this.optionalDisabled.delete(key);
496
+ }
497
+
498
+ const inFlight = this.state.inFlight.get(key);
499
+ if (inFlight) {
500
+ return inFlight;
501
+ }
502
+
503
+ const spawnPromise = this.spawnClient(
504
+ server,
505
+ root,
506
+ key,
507
+ filePath,
508
+ allowInstall,
509
+ );
510
+ this.state.inFlight.set(key, spawnPromise);
511
+
512
+ try {
513
+ return await spawnPromise;
514
+ } finally {
515
+ this.state.inFlight.delete(key);
516
+ }
517
+ }
518
+
519
+ private shouldAllowInstall(_filePath: string, _root: string): boolean {
520
+ return process.env.PI_LENS_DISABLE_LSP_INSTALL !== "1";
521
+ }
522
+
523
+ /**
524
+ * Internal: spawn a client for a server/root combination
525
+ */
526
+ private async spawnClient(
527
+ server: LSPServerInfo,
528
+ root: string,
529
+ key: string,
530
+ filePath: string,
531
+ allowInstall: boolean,
532
+ ): Promise<SpawnedServer | undefined> {
533
+ const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
534
+ const startedAt = Date.now();
535
+ logSessionStart(
536
+ `lsp spawn ${server.id}: start root=${root} install=${allowInstall ? "enabled" : "disabled"} file=${filePath}`,
537
+ );
538
+ recordLsp(server.id, root, "spawn_start");
539
+ try {
540
+ const spawned = await server.spawn(root, { allowInstall });
541
+ if (!spawned) {
542
+ logSessionStart(
543
+ `lsp spawn ${server.id}: unavailable (${Date.now() - startedAt}ms)`,
544
+ );
545
+ recordLsp(server.id, root, "spawn_failed", Date.now() - startedAt);
546
+ const uCount = (this.failureCounts.get(key) ?? 0) + 1;
547
+ this.failureCounts.set(key, uCount);
548
+ const uCooldown = Math.min(
549
+ BROKEN_BASE_COOLDOWN_MS * 2 ** (uCount - 1),
550
+ BROKEN_MAX_COOLDOWN_MS,
551
+ );
552
+ this.state.broken.set(key, Date.now() + uCooldown);
553
+ if (uCount >= BROKEN_PERMANENT_AFTER) {
554
+ this.permanentlyBroken.add(key);
555
+ logSessionStart(
556
+ `lsp spawn ${server.id}: permanently disabled after ${uCount} failures`,
557
+ );
558
+ }
559
+ return undefined;
560
+ }
561
+
562
+ const client = await createLSPClient({
563
+ serverId: server.id,
564
+ process: spawned.process,
565
+ root,
566
+ initialization: spawned.initialization,
567
+ initializeTimeoutMs: server.initializeTimeoutMs,
568
+ });
569
+ const wsDiag =
570
+ typeof client.getWorkspaceDiagnosticsSupport === "function"
571
+ ? client.getWorkspaceDiagnosticsSupport()
572
+ : {
573
+ advertised: false,
574
+ mode: "push-only" as const,
575
+ diagnosticProviderKind: "unavailable",
576
+ };
577
+
578
+ this.state.clients.set(key, client);
579
+ this.state.clientSpawnedAt.set(key, Date.now());
580
+ this.failureCounts.delete(key);
581
+ if (isOptionalServer) {
582
+ this.optionalDisabled.delete(key);
583
+ this.optionalFailureLogged.delete(key);
584
+ }
585
+ logSessionStart(
586
+ `lsp spawn ${server.id}: success source=${spawned.source ?? "unknown"} (${Date.now() - startedAt}ms)`,
587
+ );
588
+ recordLsp(server.id, root, "spawn_success", Date.now() - startedAt);
589
+ if (!this.workspaceProbeLogged.has(key)) {
590
+ logSessionStart(
591
+ `lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`,
592
+ );
593
+ this.workspaceProbeLogged.add(key);
594
+ }
595
+ return { client, info: server };
596
+ } catch (err) {
597
+ recordLsp(server.id, root, "spawn_failed", Date.now() - startedAt);
598
+ if (!isOptionalServer || !this.optionalFailureLogged.has(key)) {
599
+ logSessionStart(
600
+ `lsp spawn ${server.id}: failed (${Date.now() - startedAt}ms) error=${err instanceof Error ? err.message : String(err)}`,
601
+ );
602
+ if (isOptionalServer) {
603
+ this.optionalFailureLogged.add(key);
604
+ }
605
+ }
606
+ const eCount = (this.failureCounts.get(key) ?? 0) + 1;
607
+ this.failureCounts.set(key, eCount);
608
+ const eCooldown = isOptionalServer
609
+ ? OPTIONAL_LSP_RETRY_COOLDOWN_MS
610
+ : Math.min(
611
+ BROKEN_BASE_COOLDOWN_MS * 2 ** (eCount - 1),
612
+ BROKEN_MAX_COOLDOWN_MS,
613
+ );
614
+ this.state.broken.set(key, Date.now() + eCooldown);
615
+ if (!isOptionalServer && eCount >= BROKEN_PERMANENT_AFTER) {
616
+ this.permanentlyBroken.add(key);
617
+ logSessionStart(
618
+ `lsp spawn ${server.id}: permanently disabled after ${eCount} failures`,
619
+ );
620
+ }
621
+ if (isOptionalServer) {
622
+ this.optionalDisabled.add(key);
623
+ }
624
+ return undefined;
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Open a file in LSP (sends textDocument/didOpen)
630
+ */
631
+ async openFile(
632
+ filePath: string,
633
+ content: string,
634
+ options?: { preserveDiagnostics?: boolean; spawnBudgetMs?: number },
635
+ ): Promise<void> {
636
+ if (this.checkDestroyed()) return;
637
+ const spawned = await this.getClientForFile(
638
+ filePath,
639
+ undefined,
640
+ options?.spawnBudgetMs,
641
+ );
642
+ if (!spawned) return;
643
+
644
+ const languageId = getLanguageId(filePath) ?? "plaintext";
645
+ await spawned.client.notify.open(
646
+ filePath,
647
+ content,
648
+ languageId,
649
+ options?.preserveDiagnostics,
650
+ );
651
+ }
652
+
653
+ /**
654
+ * Update file content (sends textDocument/didChange)
655
+ */
656
+ async updateFile(filePath: string, content: string): Promise<void> {
657
+ if (this.checkDestroyed()) return;
658
+ const spawned = await this.getClientForFile(filePath);
659
+ if (!spawned) return;
660
+
661
+ await spawned.client.notify.change(filePath, content);
662
+ }
663
+
664
+ /**
665
+ * Touch a file like OpenCode's LSP flow: ensure document is open/synced,
666
+ * and optionally collect diagnostics with explicit scope.
667
+ */
668
+ async touchFile(
669
+ filePath: string,
670
+ content: string,
671
+ options: LSPTouchFileOptions = {},
672
+ ): Promise<import("./client.js").LSPDiagnostic[] | undefined> {
673
+ if (this.checkDestroyed()) return;
674
+ const startedAt = Date.now();
675
+ const normalizedPath = normalizeMapKey(filePath);
676
+ const diagnosticsMode = options.collectDiagnostics
677
+ ? (options.diagnostics ?? "document")
678
+ : (options.diagnostics ?? "none");
679
+ const source = options.source ?? "unknown";
680
+ const clientScope: LSPTouchClientScope =
681
+ options.clientScope ?? (diagnosticsMode === "full" ? "all" : "primary");
682
+ const useAllClients = clientScope === "all";
683
+ let spawned: SpawnedServer[];
684
+ let serverCountAttempted: number;
685
+ if (useAllClients) {
686
+ const result = await this.getClientsForFile(filePath);
687
+ spawned = result.clients;
688
+ serverCountAttempted = result.serverCountAttempted;
689
+ } else {
690
+ const entry = await this.getClientForFile(
691
+ filePath,
692
+ options.maxClientWaitMs,
693
+ );
694
+ spawned = entry ? [entry] : [];
695
+ serverCountAttempted =
696
+ spawned.length > 0
697
+ ? 1
698
+ : getServersForFileWithConfig(filePath).length > 0
699
+ ? 1
700
+ : 0;
701
+ }
702
+ if (spawned.length === 0) {
703
+ logLatency({
704
+ type: "phase",
705
+ phase: "lsp_touch_file",
706
+ filePath: normalizedPath,
707
+ durationMs: Date.now() - startedAt,
708
+ metadata: {
709
+ serverCountAttempted,
710
+ serverCountReady: 0,
711
+ clientScope,
712
+ diagnosticsMode,
713
+ source,
714
+ maxClientWaitMs: options.maxClientWaitMs,
715
+ failureKind: "no_clients",
716
+ },
717
+ });
718
+ return;
719
+ }
720
+
721
+ if (
722
+ this.shouldSkipTouch(
723
+ filePath,
724
+ content,
725
+ clientScope,
726
+ diagnosticsMode !== "none",
727
+ )
728
+ ) {
729
+ logLatency({
730
+ type: "phase",
731
+ phase: "lsp_touch_file",
732
+ filePath: normalizedPath,
733
+ durationMs: Date.now() - startedAt,
734
+ metadata: {
735
+ serverCountReady: spawned.length,
736
+ clientScope,
737
+ diagnosticsMode,
738
+ source,
739
+ failureKind: "success",
740
+ skipped: true,
741
+ reason: "debounced_unchanged_content",
742
+ },
743
+ });
744
+ return [];
745
+ }
746
+
747
+ const languageId = getLanguageId(filePath) ?? "plaintext";
748
+ const silent = options.silent ?? false;
749
+ await Promise.all(
750
+ spawned.map((entry) =>
751
+ entry.client.notify.open(
752
+ filePath,
753
+ content,
754
+ languageId,
755
+ undefined,
756
+ silent,
757
+ ),
758
+ ),
759
+ );
760
+
761
+ if (diagnosticsMode !== "none") {
762
+ const timeoutMs =
763
+ options.maxClientWaitMs ?? (diagnosticsMode === "full" ? 3000 : 1200);
764
+ await Promise.all(
765
+ spawned.map((entry) =>
766
+ entry.client
767
+ .waitForDiagnostics(filePath, timeoutMs)
768
+ .catch(() => undefined),
769
+ ),
770
+ );
771
+ }
772
+
773
+ const collected = options.collectDiagnostics
774
+ ? mergeLspDiagnostics(
775
+ spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)),
776
+ )
777
+ : undefined;
778
+
779
+ this.markTouched(filePath, content, clientScope);
780
+
781
+ logLatency({
782
+ type: "phase",
783
+ phase: "lsp_touch_file",
784
+ filePath: normalizedPath,
785
+ durationMs: Date.now() - startedAt,
786
+ metadata: {
787
+ serverCountReady: spawned.length,
788
+ clientScope,
789
+ diagnosticsMode,
790
+ source,
791
+ failureKind: "success",
792
+ collectedDiagnostics: collected?.length,
793
+ },
794
+ });
795
+ return collected ?? [];
796
+ }
797
+
798
+ /**
799
+ * Get diagnostics for a file
800
+ */
801
+ getDiagnosticsHealth(filePath: string): LSPDiagnosticsHealth | undefined {
802
+ return this.lastDiagnosticsHealth.get(normalizeMapKey(filePath));
803
+ }
804
+
805
+ async getDiagnostics(
806
+ filePath: string,
807
+ diagnosticsMode: LSPDiagnosticsMode = "full",
808
+ ): Promise<import("./client.js").LSPDiagnostic[]> {
809
+ const normalizedPath = normalizeMapKey(filePath);
810
+ if (this.checkDestroyed()) {
811
+ this.lastDiagnosticsHealth.set(normalizedPath, {
812
+ health: "destroyed",
813
+ failureKind: "destroyed",
814
+ serverCountAttempted: 0,
815
+ serverCountReady: 0,
816
+ candidateServerIds: getServersForFileWithConfig(filePath).map(
817
+ (s) => s.id,
818
+ ),
819
+ mergedCount: 0,
820
+ dedupDroppedCount: 0,
821
+ checkedAt: new Date().toISOString(),
822
+ });
823
+ return [];
824
+ }
825
+ const startedAt = Date.now();
826
+ const candidateServerIds = getServersForFileWithConfig(filePath).map(
827
+ (s) => s.id,
828
+ );
829
+ const { clients: spawned, serverCountAttempted } =
830
+ await this.getClientsForFile(filePath);
831
+ if (spawned.length === 0) {
832
+ const stale = this.lastKnownDiagnostics.get(normalizedPath);
833
+ const failureKind = stale?.length ? "no_clients_stale" : "no_clients";
834
+ this.lastDiagnosticsHealth.set(normalizedPath, {
835
+ health: failureKind,
836
+ failureKind,
837
+ serverCountAttempted,
838
+ serverCountReady: 0,
839
+ candidateServerIds,
840
+ mergedCount: stale?.length ?? 0,
841
+ dedupDroppedCount: 0,
842
+ checkedAt: new Date().toISOString(),
843
+ });
844
+ logLatency({
845
+ type: "phase",
846
+ phase: "lsp_diagnostics_aggregate",
847
+ filePath: normalizedPath,
848
+ durationMs: Date.now() - startedAt,
849
+ metadata: {
850
+ serverCountAttempted,
851
+ serverCountReady: 0,
852
+ mergedCount: stale?.length ?? 0,
853
+ dedupDroppedCount: 0,
854
+ failureKind,
855
+ health: failureKind,
856
+ servers: [],
857
+ },
858
+ });
859
+ return stale ?? [];
860
+ }
861
+
862
+ // Per-server entries produced by client waits. Each promise resolves
863
+ // with a PerServerEntry; raceToCompletion collects them as they finish.
864
+ type PerServerEntry = {
865
+ serverId: string;
866
+ waitMs: number;
867
+ diagnosticCount: number;
868
+ diagnostics: import("./client.js").LSPDiagnostic[];
869
+ };
870
+
871
+ const clientWaits: Promise<PerServerEntry>[] = spawned.map(
872
+ async (entry) => {
873
+ const waitStart = Date.now();
874
+ const strategy = getStrategy(entry.info.id);
875
+ await entry.client.waitForDiagnostics(
876
+ filePath,
877
+ strategy.aggregateWaitMs,
878
+ );
879
+ let diagnostics = entry.client.getDiagnostics(filePath);
880
+ const firstWaitMs = Date.now() - waitStart;
881
+ if (
882
+ strategy.expectSemanticSecondPush &&
883
+ diagnostics.length === 0 &&
884
+ firstWaitMs < DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS
885
+ ) {
886
+ await entry.client.waitForDiagnostics(
887
+ filePath,
888
+ DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS,
889
+ );
890
+ diagnostics = entry.client.getDiagnostics(filePath);
891
+ }
892
+ return {
893
+ serverId: entry.info.id,
894
+ waitMs: Date.now() - waitStart,
895
+ diagnosticCount: diagnostics.length,
896
+ diagnostics,
897
+ };
898
+ },
899
+ );
900
+
901
+ // Document mode: 0ms grace — return as soon as any client has results.
902
+ // Full mode: 400ms grace — wait a bit for other clients to catch up.
903
+ const graceMs = diagnosticsMode === "document" ? 0 : EARLY_UNBLOCK_GRACE_MS;
904
+
905
+ // Result-aware racing: trigger early-unblock when any client has results,
906
+ // OR when a seedFirstPush server returns (its first push is authoritative
907
+ // even when empty — waiting longer yields nothing more).
908
+ const perServer = await raceToCompletion(
909
+ clientWaits,
910
+ (results) =>
911
+ results.some(
912
+ (r) => r.diagnosticCount > 0 || getStrategy(r.serverId).seedFirstPush,
913
+ ),
914
+ {
915
+ timeoutMs: Math.max(
916
+ ...spawned.map((entry) => getStrategy(entry.info.id).aggregateWaitMs),
917
+ ),
918
+ graceMs,
919
+ },
920
+ );
921
+
922
+ // Fill in any slots that timed out before producing results.
923
+ const earlyUnblockedCount = spawned.length - perServer.length;
924
+ const perServerFull: PerServerEntry[] = spawned.map((entry) => {
925
+ const found = perServer.find((r) => r.serverId === entry.info.id);
926
+ return (
927
+ found ?? {
928
+ serverId: entry.info.id,
929
+ waitMs: getStrategy(entry.info.id).aggregateWaitMs,
930
+ diagnosticCount: 0,
931
+ diagnostics: [],
932
+ }
933
+ );
934
+ });
935
+
936
+ // Deduplicate across servers (same diagnostic reported by multiple tools).
937
+
938
+ const merged: import("./client.js").LSPDiagnostic[] = [];
939
+ const seen = new Set<string>();
940
+ for (const entry of perServerFull) {
941
+ for (const diagnostic of entry.diagnostics) {
942
+ const key = [
943
+ diagnostic.range.start.line,
944
+ diagnostic.range.start.character,
945
+ diagnostic.message,
946
+ ].join(":");
947
+ if (seen.has(key)) continue;
948
+ seen.add(key);
949
+ merged.push(diagnostic);
950
+ }
951
+ }
952
+
953
+ const rawCount = perServerFull.reduce(
954
+ (sum, entry) => sum + entry.diagnosticCount,
955
+ 0,
956
+ );
957
+ const serversWithDiagnostics = perServerFull.filter(
958
+ (entry) => entry.diagnosticCount > 0,
959
+ ).length;
960
+ const failureKind = merged.length === 0 ? "ok_empty" : "success";
961
+
962
+ this.lastDiagnosticsHealth.set(normalizedPath, {
963
+ health: failureKind === "success" ? "ok" : "ok_empty",
964
+ failureKind,
965
+ serverCountAttempted,
966
+ serverCountReady: perServerFull.length,
967
+ candidateServerIds,
968
+ mergedCount: merged.length,
969
+ dedupDroppedCount: rawCount - merged.length,
970
+ checkedAt: new Date().toISOString(),
971
+ });
972
+
973
+ logLatency({
974
+ type: "phase",
975
+ phase: "lsp_diagnostics_aggregate",
976
+ filePath: normalizedPath,
977
+ durationMs: Date.now() - startedAt,
978
+ metadata: {
979
+ serverCountAttempted,
980
+ serverCountReady: perServerFull.length,
981
+ serverCountWithDiagnostics: serversWithDiagnostics,
982
+ mergedCount: merged.length,
983
+ dedupDroppedCount: rawCount - merged.length,
984
+ earlyUnblockedCount,
985
+ diagnosticsMode,
986
+ failureKind,
987
+ health: failureKind === "success" ? "ok" : "ok_empty",
988
+ servers: perServerFull.map((entry) => ({
989
+ id: entry.serverId,
990
+ waitMs: entry.waitMs,
991
+ diagnosticCount: entry.diagnosticCount,
992
+ })),
993
+ },
994
+ });
995
+
996
+ // Keep last known so the widget can show stale diagnostics if LSP dies.
997
+ // Live clients returning [] means genuinely no errors — clear the stale
998
+ // entry so the widget doesn't show resolved issues.
999
+ if (merged.length > 0) {
1000
+ this.lastKnownDiagnostics.set(normalizedPath, merged);
1001
+ } else {
1002
+ this.lastKnownDiagnostics.delete(normalizedPath);
1003
+ }
1004
+
1005
+ return merged;
1006
+ }
1007
+
1008
+ /**
1009
+ * Navigation: go to definition
1010
+ */
1011
+ async definition(filePath: string, line: number, character: number) {
1012
+ const spawned = await this.getClientForFile(
1013
+ filePath,
1014
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1015
+ );
1016
+ if (!spawned) return [];
1017
+ return spawned.client.definition(filePath, line, character);
1018
+ }
1019
+
1020
+ /**
1021
+ * Navigation: find all references
1022
+ */
1023
+ async references(
1024
+ filePath: string,
1025
+ line: number,
1026
+ character: number,
1027
+ includeDeclaration = true,
1028
+ ) {
1029
+ const spawned = await this.getClientForFile(
1030
+ filePath,
1031
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1032
+ );
1033
+ if (!spawned) return [];
1034
+ return spawned.client.references(
1035
+ filePath,
1036
+ line,
1037
+ character,
1038
+ includeDeclaration,
1039
+ );
1040
+ }
1041
+
1042
+ /**
1043
+ * Navigation: hover info
1044
+ */
1045
+ async hover(filePath: string, line: number, character: number) {
1046
+ const spawned = await this.getClientForFile(
1047
+ filePath,
1048
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1049
+ );
1050
+ if (!spawned) return null;
1051
+ return spawned.client.hover(filePath, line, character);
1052
+ }
1053
+
1054
+ /**
1055
+ * Navigation: signature help at cursor position
1056
+ */
1057
+ async signatureHelp(filePath: string, line: number, character: number) {
1058
+ const spawned = await this.getClientForFile(
1059
+ filePath,
1060
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1061
+ );
1062
+ if (!spawned) return null;
1063
+ return spawned.client.signatureHelp(filePath, line, character);
1064
+ }
1065
+
1066
+ /**
1067
+ * Navigation: symbols in document
1068
+ */
1069
+ async documentSymbol(filePath: string) {
1070
+ const spawned = await this.getClientForFile(
1071
+ filePath,
1072
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1073
+ );
1074
+ if (!spawned) return [];
1075
+ return spawned.client.documentSymbol(filePath);
1076
+ }
1077
+
1078
+ /**
1079
+ * Navigation: workspace-wide symbol search
1080
+ */
1081
+ async workspaceSymbol(query: string, filePath?: string) {
1082
+ if (filePath) {
1083
+ const spawned = await this.getClientForFile(
1084
+ filePath,
1085
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1086
+ );
1087
+ if (!spawned) return [];
1088
+ return spawned.client.workspaceSymbol(query);
1089
+ }
1090
+
1091
+ // Use the first active client for workspace-level queries
1092
+ const clients = Array.from(this.state.clients.values());
1093
+ if (clients.length === 0) return [];
1094
+ return clients[0].workspaceSymbol(query);
1095
+ }
1096
+
1097
+ /**
1098
+ * Capability snapshot for LSP operations.
1099
+ * If filePath is provided, probes that server; otherwise uses first active client.
1100
+ */
1101
+ async getOperationSupport(
1102
+ filePath?: string,
1103
+ ): Promise<import("./client.js").LSPOperationSupport | null> {
1104
+ if (filePath) {
1105
+ const spawned = await this.getClientForFile(filePath);
1106
+ if (!spawned) return null;
1107
+ const getter = spawned.client.getOperationSupport;
1108
+ if (typeof getter !== "function") return null;
1109
+ return getter();
1110
+ }
1111
+
1112
+ const first = this.state.clients.values().next().value;
1113
+ if (!first) return null;
1114
+ const getter = first.getOperationSupport;
1115
+ if (typeof getter !== "function") return null;
1116
+ return getter();
1117
+ }
1118
+
1119
+ /**
1120
+ * Capability snapshot for workspace diagnostics support.
1121
+ * If filePath is provided, probes that server; otherwise uses first active client.
1122
+ */
1123
+ async getWorkspaceDiagnosticsSupport(
1124
+ filePath?: string,
1125
+ ): Promise<import("./client.js").LSPWorkspaceDiagnosticsSupport | null> {
1126
+ if (filePath) {
1127
+ const spawned = await this.getClientForFile(filePath);
1128
+ if (!spawned) return null;
1129
+ const getter = spawned.client.getWorkspaceDiagnosticsSupport;
1130
+ if (typeof getter !== "function") return null;
1131
+ return getter();
1132
+ }
1133
+
1134
+ const first = this.state.clients.values().next().value;
1135
+ if (!first) return null;
1136
+ const getter = first.getWorkspaceDiagnosticsSupport;
1137
+ if (typeof getter !== "function") return null;
1138
+ return getter();
1139
+ }
1140
+
1141
+ /**
1142
+ * Navigation: available code actions at position/range
1143
+ */
1144
+ async codeAction(
1145
+ filePath: string,
1146
+ line: number,
1147
+ character: number,
1148
+ endLine: number,
1149
+ endCharacter: number,
1150
+ ) {
1151
+ const spawned = await this.getClientForFile(
1152
+ filePath,
1153
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1154
+ );
1155
+ if (!spawned) return [];
1156
+ return spawned.client.codeAction(
1157
+ filePath,
1158
+ line,
1159
+ character,
1160
+ endLine,
1161
+ endCharacter,
1162
+ );
1163
+ }
1164
+
1165
+ /**
1166
+ * Navigation: rename symbol at position
1167
+ */
1168
+ async rename(
1169
+ filePath: string,
1170
+ line: number,
1171
+ character: number,
1172
+ newName: string,
1173
+ ) {
1174
+ const spawned = await this.getClientForFile(
1175
+ filePath,
1176
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1177
+ );
1178
+ if (!spawned) return null;
1179
+ return spawned.client.rename(filePath, line, character, newName);
1180
+ }
1181
+
1182
+ /**
1183
+ * Navigation: go to implementation
1184
+ */
1185
+ async implementation(filePath: string, line: number, character: number) {
1186
+ const spawned = await this.getClientForFile(
1187
+ filePath,
1188
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1189
+ );
1190
+ if (!spawned) return [];
1191
+ return spawned.client.implementation(filePath, line, character);
1192
+ }
1193
+
1194
+ /**
1195
+ * Navigation: prepare call hierarchy at position
1196
+ */
1197
+ async prepareCallHierarchy(
1198
+ filePath: string,
1199
+ line: number,
1200
+ character: number,
1201
+ ) {
1202
+ const spawned = await this.getClientForFile(
1203
+ filePath,
1204
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1205
+ );
1206
+ if (!spawned) return [];
1207
+ return spawned.client.prepareCallHierarchy(filePath, line, character);
1208
+ }
1209
+
1210
+ /**
1211
+ * Navigation: find incoming calls (callers)
1212
+ */
1213
+ async incomingCalls(item: import("./client.js").LSPCallHierarchyItem) {
1214
+ const spawned = await this.getClientForFile(
1215
+ uriToPath(item.uri),
1216
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1217
+ );
1218
+ if (!spawned) return [];
1219
+ return spawned.client.incomingCalls(item);
1220
+ }
1221
+
1222
+ /**
1223
+ * Navigation: find outgoing calls (callees)
1224
+ */
1225
+ async outgoingCalls(item: import("./client.js").LSPCallHierarchyItem) {
1226
+ const spawned = await this.getClientForFile(
1227
+ uriToPath(item.uri),
1228
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
1229
+ );
1230
+ if (!spawned) return [];
1231
+ return spawned.client.outgoingCalls(item);
1232
+ }
1233
+
1234
+ /**
1235
+ * Get all diagnostics across all tracked files (for cascade checking)
1236
+ */
1237
+ async getAllDiagnostics(): Promise<
1238
+ Map<string, { diags: import("./client.js").LSPDiagnostic[]; ts: number }>
1239
+ > {
1240
+ const all = new Map<
1241
+ string,
1242
+ { diags: import("./client.js").LSPDiagnostic[]; ts: number }
1243
+ >();
1244
+ const now = Date.now();
1245
+ for (const [_key, client] of this.state.clients) {
1246
+ client.pruneDiagnostics(
1247
+ (filePath, ts) =>
1248
+ !nodeFs.existsSync(filePath) || now - ts > CASCADE_DIAGNOSTICS_TTL_MS,
1249
+ );
1250
+ const clientDiags = client.getAllDiagnostics();
1251
+ for (const [filePath, entry] of clientDiags) {
1252
+ const existing = all.get(filePath);
1253
+ if (existing) {
1254
+ existing.diags = mergeLspDiagnostics([
1255
+ ...existing.diags,
1256
+ ...entry.diags,
1257
+ ]);
1258
+ existing.ts = Math.max(existing.ts, entry.ts);
1259
+ } else {
1260
+ all.set(filePath, { diags: [...entry.diags], ts: entry.ts });
1261
+ }
1262
+ }
1263
+ }
1264
+ return all;
1265
+ }
1266
+
1267
+ /**
1268
+ * Check whether a file type/root has any configured LSP support.
1269
+ * Pure capability check — does not spawn or wait for clients.
1270
+ */
1271
+ supportsLSP(filePath: string): boolean {
1272
+ return getServersForFileWithConfig(filePath).length > 0;
1273
+ }
1274
+
1275
+ /**
1276
+ * Check whether an LSP client is already alive for a file.
1277
+ * Lightweight — does not spawn or wait for a client.
1278
+ */
1279
+ async hasWarmLSP(filePath: string): Promise<boolean> {
1280
+ const spawned = await this.getWarmClientForFile(filePath);
1281
+ return Boolean(spawned);
1282
+ }
1283
+
1284
+ /**
1285
+ * Check if LSP is available for a file.
1286
+ * May spawn a client; prefer supportsLSP()/hasWarmLSP() when you only need
1287
+ * a capability or warm-state check.
1288
+ */
1289
+ async hasLSP(filePath: string): Promise<boolean> {
1290
+ const spawned = await this.getClientForFile(filePath);
1291
+ return Boolean(spawned);
1292
+ }
1293
+
1294
+ /**
1295
+ * Shutdown all LSP clients
1296
+ */
1297
+ async shutdown(): Promise<void> {
1298
+ if (this.checkDestroyed()) return;
1299
+ this.isDestroyed = true;
1300
+ // Cancel any in-flight spawns
1301
+ this.state.inFlight.clear();
1302
+
1303
+ for (const [_key, client] of this.state.clients) {
1304
+ try {
1305
+ await client.shutdown();
1306
+ } catch {
1307
+ // pi-lens-ignore: missing-error-propagation — per-client shutdown failure, must not abort remaining shutdowns
1308
+ }
1309
+ }
1310
+ this.state.clients.clear();
1311
+ this.state.broken.clear();
1312
+ this.workspaceProbeLogged.clear();
1313
+ this.warmStartLogged.clear();
1314
+ }
1315
+
1316
+ /**
1317
+ * Get status of all active clients
1318
+ */
1319
+ getStatus(): Array<{ serverId: string; root: string; connected: boolean }> {
1320
+ return Array.from(this.state.clients.entries()).map(([key, client]) => {
1321
+ const [serverId, root] = key.split(":");
1322
+ return { serverId, root, connected: client.isAlive() };
1323
+ });
1324
+ }
1325
+
1326
+ /**
1327
+ * Count clients that are currently alive (connected and initialized).
1328
+ * Lightweight — does not spawn or wait for anything.
1329
+ */
1330
+ getAliveClientCount(): number {
1331
+ let count = 0;
1332
+ for (const client of this.state.clients.values()) {
1333
+ if (client.isAlive()) count++;
1334
+ }
1335
+ return count;
1336
+ }
1337
+ }
1338
+
1339
+ // --- Singleton Instance ---
1340
+
1341
+ let globalLSPService: LSPService | null = null;
1342
+
1343
+ export function getLSPService(): LSPService {
1344
+ if (!globalLSPService) {
1345
+ globalLSPService = new LSPService();
1346
+ }
1347
+ return globalLSPService;
1348
+ }
1349
+
1350
+ export function resetLSPService(): void {
1351
+ if (globalLSPService) {
1352
+ globalLSPService.shutdown().catch(() => {});
1353
+ }
1354
+ globalLSPService = null;
1355
+ }