ultimate-pi 0.18.0 → 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.
- package/.agents/skills/harness-debate-plan/SKILL.md +1 -1
- package/.agents/skills/harness-decisions/SKILL.md +2 -3
- package/.agents/skills/harness-governor/SKILL.md +6 -5
- package/.agents/skills/harness-orchestration/SKILL.md +4 -4
- package/.agents/skills/harness-review/SKILL.md +7 -7
- package/.agents/skills/harness-sentrux-setup/SKILL.md +4 -3
- package/.agents/skills/harness-steer/SKILL.md +1 -1
- package/.agents/skills/sentrux/SKILL.md +9 -9
- package/.pi/PACKAGING.md +4 -4
- package/.pi/SYSTEM.md +54 -120
- package/.pi/agents/harness/incident-recorder.md +0 -1
- package/.pi/agents/harness/planning/decompose.md +1 -3
- package/.pi/agents/harness/planning/execution-plan-author.md +0 -2
- package/.pi/agents/harness/planning/hypothesis-validator.md +0 -2
- package/.pi/agents/harness/planning/hypothesis.md +0 -2
- package/.pi/agents/harness/planning/implementation-researcher.md +0 -2
- package/.pi/agents/harness/planning/plan-adversary.md +0 -2
- package/.pi/agents/harness/planning/plan-evaluator.md +1 -3
- package/.pi/agents/harness/planning/planning-context.md +0 -2
- package/.pi/agents/harness/planning/review-integrator.md +0 -2
- package/.pi/agents/harness/planning/sprint-contract-auditor.md +0 -2
- package/.pi/agents/harness/planning/stack-researcher.md +0 -2
- package/.pi/agents/harness/{adversary.md → reviewing/adversary.md} +0 -2
- package/.pi/agents/harness/{evaluator.md → reviewing/evaluator.md} +0 -2
- package/.pi/agents/harness/{tie-breaker.md → reviewing/tie-breaker.md} +0 -2
- package/.pi/agents/harness/{executor.md → running/executor.md} +0 -2
- package/.pi/agents/harness/sentrux-bootstrap.md +0 -1
- package/.pi/agents/harness/sentrux-steward.md +0 -2
- package/.pi/agents/harness/trace-librarian.md +0 -1
- package/.pi/extensions/00-harness-project-control.ts +133 -0
- package/.pi/extensions/00-posthog-network-bootstrap.ts +1 -1
- package/.pi/extensions/agt-kill-switch.ts +57 -0
- package/.pi/extensions/agt-prompt-guard.ts +32 -0
- package/.pi/extensions/budget-guard.ts +2 -0
- package/.pi/extensions/custom-footer.ts +46 -145
- package/.pi/extensions/custom-header.ts +1 -1
- package/.pi/extensions/custom-system-prompt.ts +1 -1
- package/.pi/extensions/debate-orchestrator.ts +7 -5
- package/.pi/extensions/harness-ask-user.ts +8 -8
- package/.pi/extensions/harness-debate-tools.ts +27 -43
- package/.pi/extensions/harness-lens.ts +94 -0
- package/.pi/extensions/harness-live-widget.ts +33 -2
- package/.pi/extensions/harness-plan-approval.ts +12 -12
- package/.pi/extensions/harness-run-context.ts +1214 -852
- package/.pi/extensions/harness-subagent-governance.ts +8 -0
- package/.pi/extensions/harness-subagent-submit.ts +36 -164
- package/.pi/extensions/harness-subagents.ts +4 -4
- package/.pi/extensions/harness-telemetry.ts +3 -1
- package/.pi/extensions/harness-web-tools.ts +3 -3
- package/.pi/extensions/observation-bus.ts +2 -0
- package/.pi/extensions/policy-gate.ts +27 -5
- package/.pi/extensions/review-integrity.ts +91 -10
- package/.pi/extensions/sentrux-rules-sync.ts +3 -1
- package/.pi/extensions/subagent-governance.ts +92 -0
- package/.pi/extensions/test-diff-integrity.ts +1 -0
- package/.pi/extensions/trace-recorder.ts +3 -1
- package/.pi/extensions/{ultimate-pi-vcc.ts → vcc-compaction.ts} +1 -1
- package/.pi/harness/README.md +6 -2
- package/.pi/harness/agents.manifest.json +38 -49
- package/.pi/harness/agents.policy.yaml +275 -0
- package/.pi/harness/corpus/graphify-kb-updater.config.json +55 -0
- package/.pi/harness/docs/adrs/0006-sentrux-dual-layer.md +2 -1
- package/.pi/harness/docs/adrs/0030-inhouse-vcc-compaction.md +1 -1
- package/.pi/harness/docs/adrs/0035-plan-phase-review-gate.md +1 -1
- package/.pi/harness/docs/adrs/0044-harness-steer-loop.md +3 -2
- package/.pi/harness/docs/adrs/0045-harness-lens-minimal-contract.md +49 -0
- package/.pi/harness/docs/adrs/0045-phase-scoped-agent-directories.md +33 -0
- package/.pi/harness/docs/adrs/0046-agt-policy-engine.md +51 -0
- package/.pi/harness/docs/adrs/0047-agt-layered-security.md +39 -0
- package/.pi/harness/docs/adrs/0048-tool-call-hook-order.md +25 -0
- package/.pi/harness/docs/adrs/0049-agents-policy-manifest.md +36 -0
- package/.pi/harness/docs/adrs/README.md +6 -0
- package/.pi/harness/docs/graphify-kb-updater-runbook.md +11 -5
- package/.pi/harness/docs/practice-map.md +2 -2
- package/.pi/harness/evolution/README.md +1 -2
- package/.pi/harness/examples/agents.policy.project.yaml +19 -0
- package/.pi/harness/examples/policies/custom-deny-bash.yaml +9 -0
- package/.pi/harness/policies/bash-denylists.yaml +5 -0
- package/.pi/harness/policies/defaults.yaml +51 -0
- package/.pi/harness/policies/orchestrator.yaml +18 -0
- package/.pi/harness/policies/phases.yaml +10 -0
- package/.pi/harness/policies/roles.yaml +5 -0
- package/.pi/harness/policies/web-guard.yaml +5 -0
- package/.pi/harness/policies/workflow-sequences.yaml +9 -0
- package/.pi/harness/sentrux/architecture.manifest.json +26 -4
- package/.pi/harness/specs/harness-spawn-context.schema.json +1 -1
- package/.pi/harness/specs/observation.schema.json +2 -1
- package/.pi/lib/agents-policy.d.mts +70 -0
- package/.pi/lib/agents-policy.mjs +325 -0
- package/.pi/lib/agents-policy.ts +19 -0
- package/.pi/lib/agt/audit-run-sink.ts +52 -0
- package/.pi/lib/agt/build-evaluation-context.ts +285 -0
- package/.pi/lib/agt/config.ts +28 -0
- package/.pi/lib/agt/delegation.ts +69 -0
- package/.pi/lib/agt/evaluate-policy.ts +56 -0
- package/.pi/lib/agt/identity-registry.ts +41 -0
- package/.pi/lib/agt/index.ts +55 -0
- package/.pi/lib/agt/kill-switch-state.ts +11 -0
- package/.pi/lib/agt/legacy-evaluate.ts +101 -0
- package/.pi/lib/agt/policy-engine.ts +154 -0
- package/.pi/lib/agt/rings.ts +21 -0
- package/.pi/lib/agt/sre-hooks.ts +45 -0
- package/.pi/lib/agt/trust-run-store.ts +26 -0
- package/.pi/lib/agt/workflow-history.ts +29 -0
- package/.pi/lib/agt-governance-active.ts +14 -0
- package/.pi/lib/agt-tool-guard.ts +78 -0
- package/.pi/lib/ask-user/dialog.ts +314 -0
- package/.pi/{extensions/lib → lib}/debate-bus-core.ts +10 -10
- package/.pi/{extensions/lib → lib}/debate-bus-state.ts +1 -1
- package/.pi/{extensions/lib → lib}/extension-load-guard.ts +21 -0
- package/.pi/lib/harness-agt-tool-guard.ts +5 -0
- package/.pi/{extensions/lib → lib}/harness-artifact-gate.ts +6 -16
- package/.pi/lib/harness-debate-core-deps.ts +14 -0
- package/.pi/lib/harness-debate-workflow-deps.ts +43 -0
- package/.pi/lib/harness-lens/.gitattributes +1 -0
- package/.pi/lib/harness-lens/clients/edit-autopatch.ts +88 -0
- package/.pi/lib/harness-lens/clients/file-kinds.ts +380 -0
- package/.pi/lib/harness-lens/clients/file-time.ts +215 -0
- package/.pi/lib/harness-lens/clients/file-utils.ts +484 -0
- package/.pi/lib/harness-lens/clients/format-service.ts +276 -0
- package/.pi/lib/harness-lens/clients/formatters.ts +1000 -0
- package/.pi/lib/harness-lens/clients/git-guard.ts +31 -0
- package/.pi/lib/harness-lens/clients/indent-retarget.ts +90 -0
- package/.pi/lib/harness-lens/clients/installer/index.ts +2368 -0
- package/.pi/lib/harness-lens/clients/latency-logger.ts +80 -0
- package/.pi/lib/harness-lens/clients/lens-config.ts +43 -0
- package/.pi/lib/harness-lens/clients/lens-events.ts +164 -0
- package/.pi/lib/harness-lens/clients/lsp/aggregation.ts +91 -0
- package/.pi/lib/harness-lens/clients/lsp/client.ts +1466 -0
- package/.pi/lib/harness-lens/clients/lsp/config.ts +216 -0
- package/.pi/lib/harness-lens/clients/lsp/edits.ts +297 -0
- package/.pi/lib/harness-lens/clients/lsp/index.ts +1355 -0
- package/.pi/lib/harness-lens/clients/lsp/interactive-install.ts +424 -0
- package/.pi/lib/harness-lens/clients/lsp/language.ts +223 -0
- package/.pi/lib/harness-lens/clients/lsp/launch.ts +939 -0
- package/.pi/lib/harness-lens/clients/lsp/lsp-index.ts +11 -0
- package/.pi/lib/harness-lens/clients/lsp/path-utils.ts +12 -0
- package/.pi/lib/harness-lens/clients/lsp/server-strategies.ts +81 -0
- package/.pi/lib/harness-lens/clients/lsp/server.ts +1971 -0
- package/.pi/lib/harness-lens/clients/path-utils.ts +182 -0
- package/.pi/lib/harness-lens/clients/pipeline.ts +360 -0
- package/.pi/lib/harness-lens/clients/project-profile.ts +117 -0
- package/.pi/lib/harness-lens/clients/runtime-agent-end.ts +112 -0
- package/.pi/lib/harness-lens/clients/runtime-config.ts +33 -0
- package/.pi/lib/harness-lens/clients/runtime-coordinator.ts +186 -0
- package/.pi/lib/harness-lens/clients/runtime-tool-result.ts +171 -0
- package/.pi/lib/harness-lens/clients/safe-spawn.ts +339 -0
- package/.pi/lib/harness-lens/clients/secrets-scanner.ts +214 -0
- package/.pi/lib/harness-lens/clients/tool-policy.ts +2072 -0
- package/.pi/lib/harness-lens/clients/types.ts +59 -0
- package/.pi/lib/harness-lens/clients/widget-state.ts +283 -0
- package/.pi/lib/harness-lens/index.ts +532 -0
- package/.pi/lib/harness-lens/tools/lsp-diagnostics.ts +706 -0
- package/.pi/lib/harness-lens/tools/lsp-navigation.ts +1246 -0
- package/.pi/{extensions/lib → lib}/harness-posthog.ts +3 -0
- package/.pi/lib/harness-project-config.ts +91 -0
- package/.pi/lib/harness-run-context-responses.ts +9 -0
- package/.pi/lib/harness-run-context.ts +1 -3
- package/.pi/{extensions/lib/spawn-policy.ts → lib/harness-spawn-policy.ts} +4 -3
- package/.pi/{extensions/lib → lib}/harness-spawn-topology.ts +5 -28
- package/.pi/lib/harness-subagent-auth.ts +51 -0
- package/.pi/{extensions/lib → lib}/harness-subagent-precheck.ts +13 -10
- package/.pi/{extensions/lib → lib}/harness-subagent-submit-pipeline.ts +3 -3
- package/.pi/lib/harness-subagent-submit-register.ts +163 -0
- package/.pi/{extensions/lib → lib}/harness-subagent-submit-registry.ts +1 -55
- package/.pi/{extensions/lib → lib}/harness-subagents-bridge.ts +53 -14
- package/.pi/{extensions/lib → lib}/harness-subprocess-bootstrap.ts +1 -1
- package/.pi/lib/harness-ui-state.ts +27 -12
- package/.pi/{extensions/lib → lib}/plan-approval/create-plan.ts +2 -2
- package/.pi/{extensions/lib → lib}/plan-approval/format-plan.ts +2 -2
- package/.pi/{extensions/lib → lib}/plan-approval/plan-review.ts +162 -201
- package/.pi/{extensions/lib → lib}/plan-approval/render.ts +1 -1
- package/.pi/{extensions/lib → lib}/plan-approval/resolve-disk.ts +2 -2
- package/.pi/{extensions/lib → lib}/plan-approval/types.ts +1 -1
- package/.pi/{extensions/lib → lib}/plan-approval/validate.ts +3 -3
- package/.pi/{extensions/lib → lib}/plan-approval-readiness.ts +3 -52
- package/.pi/{extensions/lib → lib}/plan-debate-envelope.ts +1 -1
- package/.pi/{extensions/lib → lib}/plan-debate-gate.ts +1 -1
- package/.pi/{extensions/lib → lib}/plan-debate-lane.ts +1 -4
- package/.pi/{extensions/lib → lib}/plan-messenger.ts +1 -1
- package/.pi/prompts/harness-auto.md +2 -2
- package/.pi/prompts/harness-plan.md +4 -6
- package/.pi/prompts/harness-review.md +9 -9
- package/.pi/prompts/harness-run.md +7 -7
- package/.pi/prompts/harness-setup.md +42 -68
- package/.pi/prompts/harness-steer.md +2 -2
- package/.pi/scripts/README.md +3 -5
- package/.pi/scripts/generate-agents-policy-yaml.mjs +148 -0
- package/.pi/scripts/graphify-kb-updater.mjs +48 -8
- package/.pi/scripts/harness-agents-manifest.mjs +61 -4
- package/.pi/scripts/harness-agt-doctor.ts +36 -0
- package/.pi/scripts/harness-cli-verify.sh +9 -2
- package/.pi/scripts/harness-project-toggle.mjs +129 -0
- package/.pi/scripts/harness-sentrux-cli.mjs +142 -0
- package/.pi/scripts/harness-verify.mjs +113 -39
- package/.pi/scripts/harness-web-policy-guard.mjs +2 -2
- package/.pi/scripts/validate-plan-dag.mjs +65 -74
- package/.pi/scripts/vendor-pi-vcc-settings.stub.ts +2 -2
- package/.pi/scripts/vendor-sync-pi-vcc.sh +1 -1
- package/.pi/skills/architecture/broker-domain/SKILL.md +65 -0
- package/.pi/skills/architecture/cqrs/SKILL.md +63 -0
- package/.pi/skills/architecture/event-driven/SKILL.md +60 -0
- package/.pi/skills/architecture/hexagonal-ports-adapters/SKILL.md +66 -0
- package/.pi/skills/architecture/layered/SKILL.md +68 -0
- package/.pi/skills/architecture/microkernel/SKILL.md +62 -0
- package/.pi/skills/architecture/microservices/SKILL.md +64 -0
- package/.pi/skills/architecture/modular-monolith/SKILL.md +65 -0
- package/.pi/skills/architecture/orchestration-driven-soa/SKILL.md +61 -0
- package/.pi/skills/architecture/pipeline/SKILL.md +63 -0
- package/.pi/skills/architecture/service-based/SKILL.md +64 -0
- package/.pi/skills/architecture/service-mesh/SKILL.md +60 -0
- package/.pi/skills/architecture/space-based/SKILL.md +60 -0
- package/.pi/skills/ast-grep/SKILL.md +40 -321
- package/.pi/skills/delivery/debugging-discipline/SKILL.md +36 -0
- package/.pi/skills/delivery/documentation-update/SKILL.md +33 -0
- package/.pi/skills/delivery/requirements-to-implementation/SKILL.md +34 -0
- package/.pi/skills/delivery/risk-based-verification/SKILL.md +43 -0
- package/.pi/skills/delivery/tradeoff-analysis/SKILL.md +34 -0
- package/.pi/skills/engineering/api-contract-design/SKILL.md +38 -0
- package/.pi/skills/engineering/cohesion-coupling/SKILL.md +43 -0
- package/.pi/skills/engineering/complexity-control/SKILL.md +31 -0
- package/.pi/skills/engineering/defensive-programming/SKILL.md +38 -0
- package/.pi/skills/engineering/dependency-management/SKILL.md +29 -0
- package/.pi/skills/engineering/domain-modeling/SKILL.md +32 -0
- package/.pi/skills/engineering/error-handling/SKILL.md +37 -0
- package/.pi/skills/engineering/legacy-code-seams/SKILL.md +35 -0
- package/.pi/skills/engineering/naming-and-intent/SKILL.md +29 -0
- package/.pi/skills/engineering/refactoring-safe-evolution/SKILL.md +35 -0
- package/.pi/skills/engineering/routine-function-design/SKILL.md +34 -0
- package/.pi/skills/engineering/small-change-discipline/SKILL.md +35 -0
- package/.pi/skills/lsp-navigation/SKILL.md +89 -0
- package/.pi/skills/quality/code-review-self-check/SKILL.md +35 -0
- package/.pi/skills/quality/privacy-data-handling/SKILL.md +26 -0
- package/.pi/skills/quality/security-review/SKILL.md +34 -0
- package/.pi/skills/quality/test-strategy/SKILL.md +33 -0
- package/.pi/skills/quality/testability-design/SKILL.md +33 -0
- package/.pi/skills/systems/concurrency-safety/SKILL.md +32 -0
- package/.pi/skills/systems/data-modeling-migrations/SKILL.md +31 -0
- package/.pi/skills/systems/observability-instrumentation/SKILL.md +32 -0
- package/.pi/skills/systems/performance-measurement/SKILL.md +35 -0
- package/.pi/skills/systems/reliability-design/SKILL.md +32 -0
- package/.sentrux/rules.toml +20 -4
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +26 -0
- package/README.md +85 -58
- package/THIRD_PARTY_NOTICES.md +12 -21
- package/package.json +15 -7
- package/vendor/pi-subagents/src/agents.ts +45 -1
- package/vendor/pi-subagents/src/subagents.ts +866 -811
- package/vendor/pi-vcc/src/core/brief.ts +68 -99
- package/vendor/pi-vcc/src/core/settings.ts +2 -2
- package/.agents/skills/caveman/SKILL.md +0 -67
- package/.pi/agents/harness/meta-optimizer.md +0 -36
- package/.pi/agents/harness/planning/scout-graphify.md +0 -39
- package/.pi/agents/harness/planning/scout-semantic.md +0 -41
- package/.pi/agents/harness/planning/scout-structure.md +0 -37
- package/.pi/extensions/lib/ask-user/dialog.ts +0 -260
- package/.pi/extensions/lib/harness-subagent-auth.ts +0 -209
- package/.pi/extensions/lib/harness-subagent-policy.ts +0 -236
- package/.pi/extensions/pi-model-router-harness.ts +0 -42
- package/.pi/harness/evolution/meta-optimizer.mjs +0 -99
- package/.pi/harness/specs/router-tuning-proposal.schema.json +0 -114
- package/.pi/model-router.example.json +0 -36
- package/.pi/prompts/harness-critic.md +0 -10
- package/.pi/prompts/harness-eval.md +0 -10
- package/.pi/prompts/harness-router-tune.md +0 -52
- package/.pi/scripts/harness-generate-model-router.mjs +0 -327
- package/.pi/scripts/harness-model-router-routing.test.mjs +0 -97
- package/.pi/scripts/harness-sync-model-router.mjs +0 -97
- package/.pi/scripts/vendor-sync-pi-model-router.sh +0 -47
- package/vendor/pi-model-router/.prettierignore +0 -4
- package/vendor/pi-model-router/.prettierrc +0 -5
- package/vendor/pi-model-router/AGENTS.md +0 -39
- package/vendor/pi-model-router/LICENSE +0 -21
- package/vendor/pi-model-router/README.md +0 -99
- package/vendor/pi-model-router/UPSTREAM_PIN.md +0 -10
- package/vendor/pi-model-router/docs/ARCHITECTURE.md +0 -54
- package/vendor/pi-model-router/extensions/commands.ts +0 -720
- package/vendor/pi-model-router/extensions/config.ts +0 -348
- package/vendor/pi-model-router/extensions/constants.ts +0 -1
- package/vendor/pi-model-router/extensions/index.ts +0 -478
- package/vendor/pi-model-router/extensions/provider.ts +0 -580
- package/vendor/pi-model-router/extensions/routing.ts +0 -564
- package/vendor/pi-model-router/extensions/state.ts +0 -52
- package/vendor/pi-model-router/extensions/types.ts +0 -95
- package/vendor/pi-model-router/extensions/ui.ts +0 -144
- package/vendor/pi-model-router/model-router.example.json +0 -48
- package/vendor/pi-model-router/package.json +0 -48
- package/vendor/pi-model-router/tsconfig.json +0 -16
- /package/.pi/{prompts → harness/docs}/planning-rubrics.md +0 -0
- /package/.pi/{extensions/lib → lib}/ask-user/fallback.ts +0 -0
- /package/.pi/{extensions/lib → lib}/ask-user/render.ts +0 -0
- /package/.pi/{extensions/lib → lib}/ask-user/schema.ts +0 -0
- /package/.pi/{extensions/lib → lib}/ask-user/types.ts +0 -0
- /package/.pi/{extensions/lib → lib}/ask-user/validate-core.mjs +0 -0
- /package/.pi/{extensions/lib → lib}/ask-user/validate.ts +0 -0
- /package/.pi/{extensions/lib → lib}/harness-cocoindex-refresh.ts +0 -0
- /package/.pi/{extensions/lib → lib}/harness-paths.ts +0 -0
- /package/.pi/{extensions/lib → lib}/harness-spawn-budget.ts +0 -0
- /package/.pi/{extensions/lib → lib}/harness-vcc-settings.ts +0 -0
- /package/.pi/{extensions/lib → lib}/harness-web/run-cli.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-approval/dialog.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-approval/schema.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-debate-eligibility.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-debate-focus.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-debate-id.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-debate-lanes.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-debate-round-status.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-debate-write-guard.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-review-gate.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-review-integrator-rules.ts +0 -0
- /package/.pi/{extensions/lib → lib}/plan-scope-guard.ts +0 -0
- /package/.pi/{extensions/lib → lib}/posthog-client.ts +0 -0
- /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
|
+
}
|