pi-lsp-lite 0.2.4 → 0.3.2

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.
@@ -0,0 +1,30 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ check:
14
+ name: typecheck + unit tests
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v6
18
+
19
+ - uses: actions/setup-node@v6
20
+ with:
21
+ node-version: 20
22
+ cache: npm
23
+
24
+ - run: npm ci
25
+
26
+ - name: typecheck
27
+ run: npm run check
28
+
29
+ - name: unit tests
30
+ run: npm test
@@ -0,0 +1,79 @@
1
+ name: Integration Tests
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [main]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ gopls:
12
+ name: gopls
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v6
16
+ - uses: actions/setup-node@v6
17
+ with:
18
+ node-version: 20
19
+ cache: npm
20
+ - uses: actions/setup-go@v6
21
+ with:
22
+ go-version: stable
23
+ - run: go install golang.org/x/tools/gopls@latest
24
+ - run: npm ci
25
+ - run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/gopls.test.ts
26
+
27
+ rust-analyzer:
28
+ name: rust-analyzer
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v6
32
+ - uses: actions/setup-node@v6
33
+ with:
34
+ node-version: 20
35
+ cache: npm
36
+ - run: |
37
+ rustup update stable
38
+ rustup component add rust-analyzer
39
+ - run: npm ci
40
+ - run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/rust-analyzer.test.ts
41
+
42
+ typescript:
43
+ name: typescript-language-server
44
+ runs-on: ubuntu-latest
45
+ steps:
46
+ - uses: actions/checkout@v6
47
+ - uses: actions/setup-node@v6
48
+ with:
49
+ node-version: 20
50
+ cache: npm
51
+ - run: npm install -g typescript-language-server typescript
52
+ - run: npm ci
53
+ - run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/typescript.test.ts
54
+
55
+ pylsp:
56
+ name: pylsp
57
+ runs-on: ubuntu-latest
58
+ steps:
59
+ - uses: actions/checkout@v6
60
+ - uses: actions/setup-node@v6
61
+ with:
62
+ node-version: 20
63
+ cache: npm
64
+ - run: pip install 'python-lsp-server[all]'
65
+ - run: npm ci
66
+ - run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/pylsp.test.ts
67
+
68
+ clangd:
69
+ name: clangd
70
+ runs-on: ubuntu-latest
71
+ steps:
72
+ - uses: actions/checkout@v6
73
+ - uses: actions/setup-node@v6
74
+ with:
75
+ node-version: 20
76
+ cache: npm
77
+ - run: sudo apt-get update -qq && sudo apt-get install -y -qq clangd
78
+ - run: npm ci
79
+ - run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/clangd.test.ts
@@ -0,0 +1,34 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+ id-token: write
10
+
11
+ jobs:
12
+ publish:
13
+ name: publish to npm
14
+ runs-on: ubuntu-latest
15
+ environment: public
16
+ steps:
17
+ - uses: actions/checkout@v6
18
+
19
+ - uses: actions/setup-node@v6
20
+ with:
21
+ node-version: 24
22
+ registry-url: https://registry.npmjs.org
23
+ cache: npm
24
+
25
+ - run: npm ci
26
+
27
+ - name: typecheck
28
+ run: npm run check
29
+
30
+ - name: unit tests
31
+ run: npm test
32
+
33
+ - name: publish
34
+ run: npm publish --access public
package/README.md CHANGED
@@ -1,62 +1,111 @@
1
1
  # pi-lsp-lite
2
2
 
3
- [pi](https://github.com/mariozechner/pi) extension that feeds LSP diagnostics back to the agent after every `write` and `edit`. Go, Rust, and TypeScript via `gopls`, `rust-analyzer`, and `typescript-language-server`.
3
+ [![CI](https://img.shields.io/github/actions/workflow/status/mcphailtom/pi-lsp-lite/ci.yml?branch=main&label=CI)](https://github.com/mcphailtom/pi-lsp-lite/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/pi-lsp-lite)](https://www.npmjs.com/package/pi-lsp-lite)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
6
 
5
- The agent sees errors and warnings inline on the same turn as the edit that caused them.
7
+ Your agent can't see compiler errors. Now it can.
8
+
9
+ [pi](https://github.com/mariozechner/pi) extension that runs language servers in the background and feeds diagnostics back inline after every edit. Errors appear on the same turn — no context switch, no separate command.
10
+
11
+ **Go · Rust · TypeScript · Python · C/C++**
6
12
 
7
13
  ## Install
8
14
 
9
15
  ```bash
10
- pi install git:github.com/mcphailtom/pi-lsp-lite
16
+ pi install npm:pi-lsp-lite
11
17
  ```
12
18
 
13
- Or from npm:
19
+ That's it. If you have `gopls`, `rust-analyzer`, `typescript-language-server`, `pylsp`, or `clangd` on PATH, diagnostics start flowing automatically.
14
20
 
15
- ```bash
16
- pi install npm:pi-lsp-lite
21
+ ## What you see
22
+
23
+ ```
24
+ edit ─ src/main.go
25
+ ✓ Edited src/main.go (replaced 2 lines)
26
+
27
+ ⚠ LSP diagnostics for src/main.go (2 errors):
28
+ error 12:5 [compiler] undefined: foo
29
+ error 18:2 [compiler] too many arguments in call to bar
30
+ + 1 diagnostic in 1 other file
17
31
  ```
18
32
 
19
- ## Prerequisites
33
+ The agent sees these too — they're appended to the tool result, so it can self-correct on the same turn.
34
+
35
+ ## Commands
20
36
 
21
- Language servers must be on `PATH`. If missing, that language is silently disabled.
37
+ | Command | What it does |
38
+ |---------|-------------|
39
+ | `/lsp-status` | Show running servers, PIDs, workspace roots, uptime |
40
+ | `/lsp-diag` | Show all current diagnostics (or `/lsp-diag path/to/file` for one file) |
41
+ | `/lsp-add` | Interactively add a new language server |
42
+ | `/lsp-remove` | Disable a configured server |
43
+ | `/lsp-toggle` | Flip a server on/off without removing config |
44
+ | `/lsp-install` | Install a missing server binary |
45
+
46
+ ## Supported servers
22
47
 
23
48
  | Server | Language | Install |
24
49
  |--------|----------|---------|
25
50
  | `gopls` | Go | `go install golang.org/x/tools/gopls@latest` |
26
51
  | `rust-analyzer` | Rust | `rustup component add rust-analyzer` |
27
- | `typescript-language-server` | TypeScript/JavaScript | `npm install -g typescript-language-server typescript` |
52
+ | `typescript-language-server` | TypeScript/JS | `npm install -g typescript-language-server typescript` |
53
+ | `pylsp` | Python | `pip install python-lsp-server` |
54
+ | `clangd` | C/C++ | Xcode CLI tools / `apt install clangd` |
55
+
56
+ Missing a server? `/lsp-add` lets you configure any LSP server that speaks stdio. Or add it to `.pi-lsp-lite.json`:
57
+
58
+ ```json
59
+ {
60
+ "servers": {
61
+ "haskell": {
62
+ "extensions": [".hs"],
63
+ "command": "haskell-language-server-wrapper",
64
+ "args": ["--lsp"],
65
+ "rootPatterns": ["cabal.project", "stack.yaml"]
66
+ }
67
+ }
68
+ }
69
+ ```
28
70
 
29
- ## Usage
71
+ ## Configuration
30
72
 
31
- No configuration needed. Once installed, diagnostics appear automatically after every `write` or `edit` to a supported file:
73
+ Works without config. For customisation, create `.pi-lsp-lite.json` (project) or `~/.pi-lsp-lite.json` (global):
32
74
 
33
- ```
34
- ⚠ LSP diagnostics for main.go (2 errors):
35
- error 12:5 [compiler] undefined: foo
36
- error 18:2 [compiler] too many arguments in call to bar
37
- + 1 diagnostic in 1 other file
38
- ```
75
+ | Field | Description | Default |
76
+ |-------|-------------|---------|
77
+ | `servers.<id>.diagnosticTimeout` | Per-attempt timeout (ms) | per-language |
78
+ | `servers.<id>.maxRetries` | Retry attempts on timeout (0-10) | `3` |
79
+ | `servers.<id>.disabled` | Disable this server | `false` |
80
+ | `diagnosticTimeout` | Global default timeout (ms) | `5000` |
81
+ | `documentIdleTimeout` | Close idle documents after (ms) | `120000` |
39
82
 
40
- Use `/lsp-status` to see running servers.
83
+ Project config merges over global. Partial overrides work — only specify what you want to change.
41
84
 
42
85
  ## How it works
43
86
 
44
- Edits trigger `textDocument/didOpen` or `textDocument/didChange` against a long-lived language server. Diagnostics are collected within a 3-second window and appended to the tool result. Workspace roots are detected automatically (`go.mod`, `Cargo.toml`, `tsconfig.json`, `package.json`).
87
+ 1. Agent writes/edits a file
88
+ 2. Extension detects the language, finds the workspace root
89
+ 3. Spawns (or reuses) an LSP server for that language + root
90
+ 4. Sends `didChange`, waits for `publishDiagnostics`
91
+ 5. If timeout: retries with exponential backoff + jitter (up to `maxRetries` times)
92
+ 6. Filters to errors + warnings, formats, appends to tool result + shows in TUI
93
+
94
+ Cross-file impact is detected via snapshot-diff: if editing `lib.ts` breaks `caller.ts`, you see "+ N diagnostics in M other files".
45
95
 
46
- See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for internals.
96
+ Servers are lazy (spawn on first edit), idle-shutdown after 240s, and clean up on session end.
47
97
 
48
98
  ## Development
49
99
 
50
100
  ```bash
51
101
  git clone https://github.com/mcphailtom/pi-lsp-lite
52
- cd pi-lsp-lite
53
- npm install
54
- npm run check # typecheck
55
- npm test # unit tests
56
- npm run test:integration # requires servers on PATH
102
+ cd pi-lsp-lite && npm install
103
+ npm run check # typecheck
104
+ npm test # unit tests (106, no servers needed)
105
+ npm run test:integration # real server tests (needs servers on PATH)
57
106
  ```
58
107
 
59
- See [CONTRIBUTING.md](docs/CONTRIBUTING.md) for details.
108
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
60
109
 
61
110
  ## License
62
111
 
package/index.ts CHANGED
@@ -1,16 +1,37 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { createServerManager } from "./src/server-manager.js";
3
- import { languageForFile, checkExtensionOverlaps } from "./src/languages.js";
3
+ import { languageForFile, checkExtensionOverlaps, builtinLanguages, type LanguageServerConfig } from "./src/languages.js";
4
4
  import { formatDiagnostics } from "./src/format.js";
5
+ import { DiagnosticSeverity } from "vscode-languageserver-protocol";
6
+ import { loadConfig, writeGlobalConfig, readGlobalConfig } from "./src/config.js";
7
+ import { fileUri, which } from "./src/util.js";
8
+ import { installRegistry } from "./src/install-registry.js";
5
9
  import { resolve, relative, isAbsolute } from "node:path";
10
+ import { fileURLToPath } from "node:url";
6
11
 
7
12
  export default function (pi: ExtensionAPI) {
8
- const manager = createServerManager();
13
+ let servers: LanguageServerConfig[] = [];
14
+ let manager = createServerManager({});
9
15
 
10
- for (const warning of checkExtensionOverlaps()) {
11
- console.error(`[pi-lsp-lite] ${warning}`);
16
+ async function initConfig(cwd: string) {
17
+ await manager.shutdownAll();
18
+ const resolved = await loadConfig(cwd);
19
+ servers = resolved.servers;
20
+ manager = createServerManager({
21
+ diagnosticTimeout: resolved.diagnosticTimeout,
22
+ documentIdleTimeout: resolved.documentIdleTimeout,
23
+ perServerTimeout: resolved.perServerTimeout,
24
+ });
25
+
26
+ for (const warning of checkExtensionOverlaps(servers)) {
27
+ console.error(`[pi-lsp-lite] ${warning}`);
28
+ }
12
29
  }
13
30
 
31
+ pi.on("session_start", async (_event, ctx) => {
32
+ await initConfig(ctx.cwd);
33
+ });
34
+
14
35
  pi.on("tool_result", async (event, ctx) => {
15
36
  if (event.toolName !== "write" && event.toolName !== "edit") return;
16
37
 
@@ -21,11 +42,11 @@ export default function (pi: ExtensionAPI) {
21
42
  const absolutePath = resolve(ctx.cwd, filePath);
22
43
  const rel = relative(ctx.cwd, absolutePath);
23
44
  if (!rel || rel.startsWith("..") || isAbsolute(rel)) return;
24
- const config = languageForFile(absolutePath);
25
- if (!config) return;
45
+ const langConfig = languageForFile(absolutePath, servers);
46
+ if (!langConfig) return;
26
47
 
27
48
  try {
28
- const result = await manager.handleEdit(absolutePath, config, ctx.cwd);
49
+ const result = await manager.handleEdit(absolutePath, langConfig, ctx.cwd);
29
50
  const formatted = formatDiagnostics(filePath, result);
30
51
  if (!formatted) return;
31
52
 
@@ -46,12 +67,12 @@ export default function (pi: ExtensionAPI) {
46
67
  pi.registerCommand("lsp-status", {
47
68
  description: "Show running LSP servers and recent diagnostic counts",
48
69
  handler: async (_args, ctx) => {
49
- const servers = manager.status();
50
- if (servers.length === 0) {
70
+ const running = manager.status();
71
+ if (running.length === 0) {
51
72
  ctx.ui.notify("pi-lsp-lite: no servers running", "info");
52
73
  return;
53
74
  }
54
- const lines = servers.map((s) => {
75
+ const lines = running.map((s) => {
55
76
  const idle = Math.round((Date.now() - s.lastActivity) / 1000);
56
77
  const up = Math.round(s.uptime / 1000);
57
78
  return `${s.id} (pid ${s.pid}) root=${s.root} — ${s.openDocuments} open files, up ${up}s, idle ${idle}s`;
@@ -59,4 +80,211 @@ export default function (pi: ExtensionAPI) {
59
80
  ctx.ui.notify(lines.join("\n"), "info");
60
81
  },
61
82
  });
83
+
84
+ pi.registerCommand("lsp-diag", {
85
+ description: "Show current LSP diagnostics for all tracked files (or a specific file)",
86
+ handler: async (args, ctx) => {
87
+ const allDiags = manager.getAllDiagnostics();
88
+
89
+ if (allDiags.size === 0) {
90
+ ctx.ui.notify("pi-lsp-lite: no diagnostics", "info");
91
+ return;
92
+ }
93
+
94
+ const filterPath = args?.trim();
95
+ let filterUri: string | undefined;
96
+ if (filterPath) {
97
+ const abs = resolve(ctx.cwd, filterPath);
98
+ filterUri = fileUri(abs);
99
+ }
100
+
101
+ const lines: string[] = [];
102
+ for (const [uri, diags] of allDiags) {
103
+ if (filterUri && uri !== filterUri) continue;
104
+ const filePath = fileURLToPath(new URL(uri));
105
+ const relevant = diags.filter((d) => d.severity === DiagnosticSeverity.Error || d.severity === DiagnosticSeverity.Warning);
106
+ if (relevant.length === 0) continue;
107
+ lines.push(`${filePath} (${relevant.length} diagnostic${relevant.length !== 1 ? "s" : ""})`);
108
+ for (const d of relevant) {
109
+ const severity = d.severity === DiagnosticSeverity.Error ? "error" : "warning";
110
+ const line = d.range.start.line + 1;
111
+ const col = d.range.start.character + 1;
112
+ const source = d.source ? `[${d.source}] ` : "";
113
+ lines.push(` ${severity} ${line}:${col} ${source}${d.message}`);
114
+ }
115
+ }
116
+
117
+ if (lines.length === 0) {
118
+ ctx.ui.notify(filterPath ? `pi-lsp-lite: no diagnostics for ${filterPath}` : "pi-lsp-lite: no diagnostics", "info");
119
+ return;
120
+ }
121
+
122
+ ctx.ui.notify(lines.join("\n"), "warning");
123
+ },
124
+ });
125
+
126
+ pi.registerCommand("lsp-add", {
127
+ description: "Add a new language server to global config",
128
+ handler: async (_args, ctx) => {
129
+ if (!ctx.hasUI) {
130
+ ctx.ui.notify("pi-lsp-lite: /lsp-add requires interactive mode", "error");
131
+ return;
132
+ }
133
+
134
+ const rawId = await ctx.ui.input("Server ID (e.g. haskell):");
135
+ if (!rawId) return;
136
+ const id = rawId.trim().toLowerCase();
137
+ if (!/^[a-z0-9_-]+$/.test(id)) {
138
+ ctx.ui.notify("pi-lsp-lite: server ID must be lowercase alphanumeric, hyphens, or underscores", "error");
139
+ return;
140
+ }
141
+ const RESERVED_IDS = new Set(["__proto__", "constructor", "prototype"]);
142
+ if (RESERVED_IDS.has(id)) {
143
+ ctx.ui.notify("pi-lsp-lite: reserved ID, choose a different name", "error");
144
+ return;
145
+ }
146
+
147
+ const rawCommand = await ctx.ui.input("Binary command (e.g. haskell-language-server-wrapper):");
148
+ const command = rawCommand?.trim();
149
+ if (!command) return;
150
+
151
+ const argsRaw = await ctx.ui.input("CLI args (comma-separated, or empty):");
152
+ const args = argsRaw ? argsRaw.split(",").map((a) => a.trim()).filter(Boolean) : [];
153
+
154
+ const extRaw = await ctx.ui.input("File extensions (comma-separated, e.g. .hs,.lhs):");
155
+ if (!extRaw) return;
156
+ const extensions = extRaw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
157
+ if (extensions.length === 0) {
158
+ ctx.ui.notify("pi-lsp-lite: at least one extension is required", "error");
159
+ return;
160
+ }
161
+
162
+ const rootRaw = await ctx.ui.input("Root pattern files (comma-separated, or empty):");
163
+ const rootPatterns = rootRaw ? rootRaw.split(",").map((r) => r.trim()).filter(Boolean) : [];
164
+
165
+ const resolved = await which(command);
166
+ if (!resolved) {
167
+ ctx.ui.notify(`pi-lsp-lite: "${command}" not found on PATH — server added but won't start until installed`, "warning");
168
+ }
169
+
170
+ await writeGlobalConfig({ servers: { [id]: { command, args, extensions, rootPatterns } } });
171
+ await initConfig(ctx.cwd);
172
+ ctx.ui.notify(`pi-lsp-lite: added server "${id}"`, "info");
173
+ },
174
+ });
175
+
176
+ pi.registerCommand("lsp-remove", {
177
+ description: "Remove or disable a language server",
178
+ handler: async (_args, ctx) => {
179
+ if (!ctx.hasUI) {
180
+ ctx.ui.notify("pi-lsp-lite: /lsp-remove requires interactive mode", "error");
181
+ return;
182
+ }
183
+
184
+ if (servers.length === 0) {
185
+ ctx.ui.notify("pi-lsp-lite: no servers configured", "info");
186
+ return;
187
+ }
188
+
189
+ const ids = servers.map((s) => s.id);
190
+ const selected = await ctx.ui.select("Remove which server?", ids);
191
+ if (!selected) return;
192
+
193
+ const confirmed = await ctx.ui.confirm("Confirm removal", `Disable server "${selected}"?`);
194
+ if (!confirmed) return;
195
+
196
+ await writeGlobalConfig({ servers: { [selected]: { disabled: true } } });
197
+ await initConfig(ctx.cwd);
198
+ ctx.ui.notify(`pi-lsp-lite: disabled server "${selected}"`, "info");
199
+ },
200
+ });
201
+
202
+ pi.registerCommand("lsp-toggle", {
203
+ description: "Enable or disable a language server",
204
+ handler: async (_args, ctx) => {
205
+ if (!ctx.hasUI) {
206
+ ctx.ui.notify("pi-lsp-lite: /lsp-toggle requires interactive mode", "error");
207
+ return;
208
+ }
209
+
210
+ const builtinIds = new Set(builtinLanguages.map((l) => l.id));
211
+ const activeIds = new Set(servers.map((s) => s.id));
212
+
213
+ // include disabled user-added servers from global config so they can be re-enabled
214
+ const globalConfig = await readGlobalConfig();
215
+ const RESERVED = new Set(["__proto__", "constructor", "prototype"]);
216
+ const globalServerIds = (globalConfig?.servers && typeof globalConfig.servers === "object" && !Array.isArray(globalConfig.servers))
217
+ ? Object.keys(globalConfig.servers).filter((k) => !RESERVED.has(k))
218
+ : [];
219
+ const allIds = new Set<string>([...builtinIds, ...activeIds, ...globalServerIds]);
220
+
221
+ if (allIds.size === 0) {
222
+ ctx.ui.notify("pi-lsp-lite: no servers configured", "info");
223
+ return;
224
+ }
225
+
226
+ const entries = [...allIds];
227
+ const options = entries.map((id) => `${id} ${activeIds.has(id) ? "[enabled]" : "[disabled]"}`);
228
+ const choice = await ctx.ui.select("Toggle which server?", options);
229
+ if (!choice) return;
230
+
231
+ const idx = options.indexOf(choice);
232
+ const id = entries[idx];
233
+ const isCurrentlyEnabled = activeIds.has(id);
234
+
235
+ if (isCurrentlyEnabled) {
236
+ await writeGlobalConfig({ servers: { [id]: { disabled: true } } });
237
+ } else {
238
+ // re-enable: works for both built-ins and user-added servers in global config
239
+ await writeGlobalConfig({ servers: { [id]: { disabled: false } } });
240
+ }
241
+
242
+ await initConfig(ctx.cwd);
243
+ ctx.ui.notify(`pi-lsp-lite: ${isCurrentlyEnabled ? "disabled" : "enabled"} server "${id}"`, "info");
244
+ },
245
+ });
246
+
247
+ pi.registerCommand("lsp-install", {
248
+ description: "Install a missing language server binary",
249
+ handler: async (_args, ctx) => {
250
+ if (!ctx.hasUI) {
251
+ ctx.ui.notify("pi-lsp-lite: /lsp-install requires interactive mode", "error");
252
+ return;
253
+ }
254
+
255
+ const checks = await Promise.all(
256
+ [...installRegistry].map(async ([id, entry]) => {
257
+ const lang = builtinLanguages.find((l) => l.id === id);
258
+ const binary = lang?.command ?? id;
259
+ const found = await which(binary);
260
+ return found ? null : { id, command: binary, installCmd: entry.command, description: entry.description };
261
+ }),
262
+ );
263
+ const missing = checks.filter((c): c is NonNullable<typeof c> => c !== null);
264
+
265
+ if (missing.length === 0) {
266
+ ctx.ui.notify("pi-lsp-lite: all known servers are available", "info");
267
+ return;
268
+ }
269
+
270
+ const options = missing.map((m) => `${m.id} — ${m.description} (${m.command})`);
271
+ const choice = await ctx.ui.select("Install which server?", options);
272
+ if (!choice) return;
273
+
274
+ const idx = options.indexOf(choice);
275
+ const selected = missing[idx];
276
+
277
+ const confirmed = await ctx.ui.confirm("Confirm install", `Run: ${selected.installCmd}`);
278
+ if (!confirmed) return;
279
+
280
+ const result = await pi.exec("sh", ["-c", selected.installCmd]);
281
+ if (result.code !== 0) {
282
+ ctx.ui.notify(`pi-lsp-lite: install failed (exit ${result.code})\n${result.stderr}`, "error");
283
+ return;
284
+ }
285
+
286
+ await initConfig(ctx.cwd);
287
+ ctx.ui.notify(`pi-lsp-lite: installed ${selected.id}`, "info");
288
+ },
289
+ });
62
290
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-lsp-lite",
3
- "version": "0.2.4",
4
- "description": "pi extension: feeds LSP diagnostics back to the agent after every write/edit. Go, Rust, and TypeScript.",
3
+ "version": "0.3.2",
4
+ "description": "LSP diagnostics for pi errors and warnings on every edit, same turn. Go, Rust, TypeScript, Python, C/C++.",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "license": "MIT",
@@ -17,7 +17,11 @@
17
17
  "lsp",
18
18
  "gopls",
19
19
  "rust-analyzer",
20
- "typescript-language-server"
20
+ "typescript-language-server",
21
+ "pylsp",
22
+ "clangd",
23
+ "python",
24
+ "cpp"
21
25
  ],
22
26
  "pi": {
23
27
  "extensions": [
package/src/client.ts CHANGED
@@ -27,6 +27,7 @@ export interface DiagnosticResult {
27
27
  status: "ok" | "timeout" | "unavailable";
28
28
  diagnostics: Diagnostic[];
29
29
  otherFiles: OtherFileDiagnostics[];
30
+ retryAttempts: number;
30
31
  }
31
32
 
32
33
  export interface LspClient {
@@ -35,10 +36,12 @@ export interface LspClient {
35
36
  didChange(uri: string, content: string): void;
36
37
  didClose(uri: string): void;
37
38
  waitForDiagnostics(uri: string, timeoutMs: number): Promise<DiagnosticResult>;
39
+ getAllDiagnostics(): Map<string, Diagnostic[]>;
38
40
  shutdown(): Promise<void>;
39
41
  }
40
42
 
41
43
  const SHUTDOWN_TIMEOUT_MS = 5_000;
44
+ const QUIESCENCE_MS = 200;
42
45
 
43
46
  function countDiagnostics(diags: Diagnostic[]): { errors: number; warnings: number } {
44
47
  let errors = 0;
@@ -70,21 +73,21 @@ export function createLspClient(child: ChildProcess): LspClient {
70
73
  const diagnosticsMap = new Map<string, DiagnosticEntry>();
71
74
  const documentVersion = new Map<string, number>();
72
75
  const uriGeneration = new Map<string, number>();
76
+ let crossFileCallback: ((changedUri: string) => void) | null = null;
73
77
 
74
78
  connection.onNotification(PublishDiagnosticsNotification.type, (params) => {
75
79
  const entry = diagnosticsMap.get(params.uri);
76
80
  if (entry) {
77
- // only accept diagnostics for the current generation of this URI
78
81
  const currentGen = uriGeneration.get(params.uri) ?? 0;
79
82
  if (entry.generation !== currentGen) return;
80
83
  entry.diagnostics = params.diagnostics;
81
84
  entry.received = true;
82
85
  entry.resolve?.();
83
86
  } else {
84
- // cross-file diagnostics for URIs we haven't opened — accept them
85
87
  const gen = uriGeneration.get(params.uri) ?? 0;
86
88
  diagnosticsMap.set(params.uri, { diagnostics: params.diagnostics, generation: gen, received: true });
87
89
  }
90
+ if (crossFileCallback) crossFileCallback(params.uri);
88
91
  });
89
92
 
90
93
  connection.listen();
@@ -137,7 +140,6 @@ export function createLspClient(child: ChildProcess): LspClient {
137
140
  },
138
141
 
139
142
  didClose(uri: string) {
140
- // bump generation so any in-flight diagnostics for the old open are rejected
141
143
  const gen = (uriGeneration.get(uri) ?? 0) + 1;
142
144
  uriGeneration.set(uri, gen);
143
145
  connection.sendNotification(DidCloseTextDocumentNotification.type, {
@@ -150,7 +152,6 @@ export function createLspClient(child: ChildProcess): LspClient {
150
152
  async waitForDiagnostics(uri: string, timeoutMs: number): Promise<DiagnosticResult> {
151
153
  const targetGen = uriGeneration.get(uri) ?? 0;
152
154
 
153
- // snapshot diagnostic counts for all other tracked URIs before the edit settles
154
155
  const preSnapshot = new Map<string, { errors: number; warnings: number }>();
155
156
  for (const [trackedUri, entry] of diagnosticsMap) {
156
157
  if (trackedUri !== uri) {
@@ -174,19 +175,26 @@ export function createLspClient(child: ChildProcess): LspClient {
174
175
  };
175
176
 
176
177
  return new Promise<DiagnosticResult>((resolve) => {
177
- const SETTLE_MS = 50;
178
178
  let settled = false;
179
+ let quiescenceTimer: ReturnType<typeof setTimeout> | null = null;
179
180
 
180
181
  const settle = (status: "ok" | "timeout") => {
181
182
  if (settled) return;
182
183
  settled = true;
183
- setTimeout(() => {
184
- resolve({
185
- status,
186
- diagnostics: diagnosticsMap.get(uri)?.diagnostics ?? [],
187
- otherFiles: collectOtherFiles(),
188
- });
189
- }, SETTLE_MS);
184
+ crossFileCallback = null;
185
+ if (quiescenceTimer) clearTimeout(quiescenceTimer);
186
+ resolve({
187
+ status,
188
+ diagnostics: diagnosticsMap.get(uri)?.diagnostics ?? [],
189
+ otherFiles: collectOtherFiles(),
190
+ retryAttempts: 0,
191
+ });
192
+ };
193
+
194
+ const resetQuiescence = () => {
195
+ if (settled) return;
196
+ if (quiescenceTimer) clearTimeout(quiescenceTimer);
197
+ quiescenceTimer = setTimeout(() => settle("ok"), QUIESCENCE_MS);
190
198
  };
191
199
 
192
200
  const timeout = setTimeout(() => {
@@ -194,19 +202,43 @@ export function createLspClient(child: ChildProcess): LspClient {
194
202
  }, timeoutMs);
195
203
 
196
204
  const entry = diagnosticsMap.get(uri) ?? { diagnostics: [], generation: targetGen, received: false };
197
- if (entry.received) {
205
+
206
+ entry.resolve = () => {
198
207
  clearTimeout(timeout);
199
- settle("ok");
200
- } else {
201
- entry.resolve = () => {
208
+ resetQuiescence();
209
+ };
210
+ diagnosticsMap.set(uri, entry);
211
+
212
+ // when a non-target URI publishes diagnostics that differ from the
213
+ // pre-snapshot, start quiescence — this catches the case where the
214
+ // edited file is valid but dependents break
215
+ crossFileCallback = (changedUri: string) => {
216
+ if (settled || changedUri === uri) return;
217
+ const pre = preSnapshot.get(changedUri) ?? { errors: 0, warnings: 0 };
218
+ const post = countDiagnostics(diagnosticsMap.get(changedUri)?.diagnostics ?? []);
219
+ if (post.errors !== pre.errors || post.warnings !== pre.warnings) {
202
220
  clearTimeout(timeout);
203
- settle("ok");
204
- };
205
- diagnosticsMap.set(uri, entry);
221
+ resetQuiescence();
222
+ }
223
+ };
224
+
225
+ if (entry.received) {
226
+ clearTimeout(timeout);
227
+ resetQuiescence();
206
228
  }
207
229
  });
208
230
  },
209
231
 
232
+ getAllDiagnostics(): Map<string, Diagnostic[]> {
233
+ const result = new Map<string, Diagnostic[]>();
234
+ for (const [uri, entry] of diagnosticsMap) {
235
+ if (entry.diagnostics.length > 0) {
236
+ result.set(uri, [...entry.diagnostics]);
237
+ }
238
+ }
239
+ return result;
240
+ },
241
+
210
242
  async shutdown() {
211
243
  let timer: ReturnType<typeof setTimeout> | undefined;
212
244
  try {
package/src/config.ts ADDED
@@ -0,0 +1,295 @@
1
+ import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ import { type LanguageServerConfig, builtinLanguages } from "./languages.js";
6
+
7
+ export interface ServerConfigOverride {
8
+ extensions?: string[];
9
+ command?: string;
10
+ args?: string[];
11
+ rootPatterns?: string[];
12
+ diagnosticTimeout?: number;
13
+ maxRetries?: number;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ export interface UserConfig {
18
+ servers?: Record<string, ServerConfigOverride>;
19
+ diagnosticTimeout?: number;
20
+ documentIdleTimeout?: number;
21
+ }
22
+
23
+ export interface ResolvedConfig {
24
+ servers: LanguageServerConfig[];
25
+ diagnosticTimeout: number;
26
+ documentIdleTimeout: number;
27
+ perServerTimeout: Map<string, number>;
28
+ }
29
+
30
+ export const DEFAULT_DIAGNOSTIC_TIMEOUT = 5_000;
31
+ export const DEFAULT_DOCUMENT_IDLE_TIMEOUT = 120_000;
32
+ export const DEFAULT_MAX_RETRIES = 3;
33
+
34
+ const MIN_DIAGNOSTIC_TIMEOUT = 1_000;
35
+ const MAX_DIAGNOSTIC_TIMEOUT = 60_000;
36
+ const MIN_DOCUMENT_IDLE_TIMEOUT = 10_000;
37
+ const MAX_DOCUMENT_IDLE_TIMEOUT = 600_000;
38
+ const MIN_MAX_RETRIES = 0;
39
+ const MAX_MAX_RETRIES = 10;
40
+
41
+ function clamp(value: unknown, min: number, max: number, fallback: number): number {
42
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
43
+ return Math.max(min, Math.min(max, value));
44
+ }
45
+
46
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
47
+ return typeof v === "object" && v !== null && !Array.isArray(v);
48
+ }
49
+
50
+ function isStringArray(v: unknown): v is string[] {
51
+ return Array.isArray(v) && v.every((item) => typeof item === "string");
52
+ }
53
+
54
+ function validateOverride(id: string, raw: unknown): ServerConfigOverride | null {
55
+ if (!isPlainObject(raw)) return null;
56
+
57
+ const override: ServerConfigOverride = {};
58
+
59
+ if (raw.disabled === true) {
60
+ override.disabled = true;
61
+ return override;
62
+ }
63
+
64
+ if (raw.extensions !== undefined) {
65
+ if (!isStringArray(raw.extensions) || raw.extensions.length === 0) {
66
+ console.error(`[pi-lsp-lite] config "${id}": extensions must be a non-empty string array, skipping`);
67
+ return null;
68
+ }
69
+ override.extensions = (raw.extensions as string[]).map((e) => e.toLowerCase());
70
+ }
71
+
72
+ if (raw.command !== undefined) {
73
+ if (typeof raw.command !== "string" || raw.command.length === 0) {
74
+ console.error(`[pi-lsp-lite] config "${id}": command must be a non-empty string, skipping`);
75
+ return null;
76
+ }
77
+ override.command = raw.command as string;
78
+ }
79
+
80
+ if (raw.args !== undefined) {
81
+ if (!isStringArray(raw.args)) {
82
+ console.error(`[pi-lsp-lite] config "${id}": args must be a string array, skipping`);
83
+ return null;
84
+ }
85
+ override.args = raw.args as string[];
86
+ }
87
+
88
+ if (raw.rootPatterns !== undefined) {
89
+ if (!isStringArray(raw.rootPatterns)) {
90
+ console.error(`[pi-lsp-lite] config "${id}": rootPatterns must be a string array, skipping`);
91
+ return null;
92
+ }
93
+ override.rootPatterns = raw.rootPatterns as string[];
94
+ }
95
+
96
+ if (raw.diagnosticTimeout !== undefined) {
97
+ override.diagnosticTimeout = clamp(
98
+ raw.diagnosticTimeout,
99
+ MIN_DIAGNOSTIC_TIMEOUT,
100
+ MAX_DIAGNOSTIC_TIMEOUT,
101
+ DEFAULT_DIAGNOSTIC_TIMEOUT,
102
+ );
103
+ }
104
+
105
+ if (raw.maxRetries !== undefined) {
106
+ override.maxRetries = clamp(
107
+ raw.maxRetries,
108
+ MIN_MAX_RETRIES,
109
+ MAX_MAX_RETRIES,
110
+ DEFAULT_MAX_RETRIES,
111
+ );
112
+ }
113
+
114
+ return override;
115
+ }
116
+
117
+ async function readConfigFile(path: string): Promise<UserConfig | null> {
118
+ let content: string;
119
+ try {
120
+ content = await readFile(path, "utf-8");
121
+ } catch (err: unknown) {
122
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return null;
123
+ console.error(`[pi-lsp-lite] failed to read config ${path}:`, err);
124
+ return null;
125
+ }
126
+
127
+ try {
128
+ const parsed: unknown = JSON.parse(content);
129
+ if (!isPlainObject(parsed)) {
130
+ console.error(`[pi-lsp-lite] config ${path}: expected a JSON object, skipping`);
131
+ return null;
132
+ }
133
+ return parsed as UserConfig;
134
+ } catch (err) {
135
+ console.error(`[pi-lsp-lite] config ${path}: invalid JSON, skipping:`, err);
136
+ return null;
137
+ }
138
+ }
139
+
140
+ async function findProjectConfig(cwd: string): Promise<UserConfig | null> {
141
+ for (const candidate of [
142
+ join(cwd, ".pi-lsp-lite.json"),
143
+ join(cwd, ".pi", "lsp-lite.json"),
144
+ ]) {
145
+ const config = await readConfigFile(candidate);
146
+ if (config) return config;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ type ConfigSource = "global" | "project";
152
+
153
+ function mergeConfigs(
154
+ base: LanguageServerConfig[],
155
+ overrides: Record<string, ServerConfigOverride>,
156
+ source: ConfigSource,
157
+ ): LanguageServerConfig[] {
158
+ const result = new Map<string, LanguageServerConfig>();
159
+
160
+ for (const server of base) {
161
+ result.set(server.id, { ...server });
162
+ }
163
+
164
+ for (const [id, rawOverride] of Object.entries(overrides)) {
165
+ const override = validateOverride(id, rawOverride);
166
+ if (!override) continue;
167
+
168
+ if (override.disabled) {
169
+ result.delete(id);
170
+ continue;
171
+ }
172
+
173
+ const existing = result.get(id);
174
+ if (existing) {
175
+ const { disabled: _, diagnosticTimeout: __, ...lspFields } = override;
176
+ const defined = Object.fromEntries(
177
+ Object.entries(lspFields).filter(([, v]) => v !== undefined),
178
+ );
179
+ result.set(id, { ...existing, ...defined });
180
+ } else {
181
+ if (source === "project") {
182
+ console.error(`[pi-lsp-lite] project config cannot define new server "${id}" — only global config (~/.pi-lsp-lite.json) can add servers`);
183
+ continue;
184
+ }
185
+ if (!override.command || !override.extensions) {
186
+ console.error(`[pi-lsp-lite] config "${id}" must have at least "command" and "extensions" to define a new server, skipping`);
187
+ continue;
188
+ }
189
+ result.set(id, {
190
+ id,
191
+ extensions: override.extensions,
192
+ command: override.command,
193
+ args: override.args ?? [],
194
+ rootPatterns: override.rootPatterns ?? [],
195
+ ...(override.maxRetries !== undefined && { maxRetries: override.maxRetries }),
196
+ });
197
+ }
198
+ }
199
+
200
+ return Array.from(result.values());
201
+ }
202
+
203
+ export function globalConfigFilePath(globalConfigPath?: string): string {
204
+ return globalConfigPath ?? join(homedir(), ".pi-lsp-lite.json");
205
+ }
206
+
207
+ export async function readGlobalConfig(globalConfigPath?: string): Promise<UserConfig | null> {
208
+ return readConfigFile(globalConfigFilePath(globalConfigPath));
209
+ }
210
+
211
+ let writeLock = Promise.resolve();
212
+
213
+ export function writeGlobalConfig(config: UserConfig, globalConfigPath?: string): Promise<void> {
214
+ const op = writeLock.then(() => writeGlobalConfigInner(config, globalConfigPath));
215
+ writeLock = op.catch(() => {});
216
+ return op;
217
+ }
218
+
219
+ async function writeGlobalConfigInner(config: UserConfig, globalConfigPath?: string): Promise<void> {
220
+ const filePath = globalConfigFilePath(globalConfigPath);
221
+ const existing = await readConfigFile(filePath);
222
+ const merged = deepMerge(
223
+ (existing ?? {}) as Record<string, unknown>,
224
+ config as Record<string, unknown>,
225
+ ) as UserConfig;
226
+ const dir = dirname(filePath);
227
+ await mkdir(dir, { recursive: true });
228
+ const tmpPath = join(dir, `.tmp-${randomUUID()}`);
229
+ try {
230
+ await writeFile(tmpPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
231
+ await rename(tmpPath, filePath);
232
+ } catch (err) {
233
+ await unlink(tmpPath).catch(() => {});
234
+ throw err;
235
+ }
236
+ }
237
+
238
+ const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
239
+
240
+ function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
241
+ const result: Record<string, unknown> = Object.create(null);
242
+ for (const key of Object.keys(target)) {
243
+ if (!RESERVED_KEYS.has(key)) result[key] = target[key];
244
+ }
245
+ for (const key of Object.keys(source)) {
246
+ if (RESERVED_KEYS.has(key)) continue;
247
+ const sv = source[key];
248
+ const tv = target[key];
249
+ if (sv === undefined) continue;
250
+ if (sv === null) {
251
+ delete result[key];
252
+ } else if (isPlainObject(sv) && isPlainObject(tv)) {
253
+ result[key] = deepMerge(tv, sv);
254
+ } else {
255
+ result[key] = sv;
256
+ }
257
+ }
258
+ return result;
259
+ }
260
+
261
+ export async function loadConfig(cwd: string, globalConfigPath?: string): Promise<ResolvedConfig> {
262
+ const globalConfig = await readConfigFile(globalConfigFilePath(globalConfigPath));
263
+ const projectConfig = await findProjectConfig(cwd);
264
+
265
+ let servers = [...builtinLanguages];
266
+ const perServerTimeout = new Map<string, number>();
267
+ let diagnosticTimeout = DEFAULT_DIAGNOSTIC_TIMEOUT;
268
+ let documentIdleTimeout = DEFAULT_DOCUMENT_IDLE_TIMEOUT;
269
+
270
+ const layers: [UserConfig | null, ConfigSource][] = [
271
+ [globalConfig, "global"],
272
+ [projectConfig, "project"],
273
+ ];
274
+
275
+ for (const [layer, source] of layers) {
276
+ if (!layer) continue;
277
+ if (layer.servers && isPlainObject(layer.servers)) {
278
+ servers = mergeConfigs(servers, layer.servers as Record<string, ServerConfigOverride>, source);
279
+ for (const [id, rawOverride] of Object.entries(layer.servers)) {
280
+ const override = validateOverride(id, rawOverride);
281
+ if (override?.diagnosticTimeout !== undefined) {
282
+ perServerTimeout.set(id, override.diagnosticTimeout);
283
+ }
284
+ }
285
+ }
286
+ if (layer.diagnosticTimeout !== undefined) {
287
+ diagnosticTimeout = clamp(layer.diagnosticTimeout, MIN_DIAGNOSTIC_TIMEOUT, MAX_DIAGNOSTIC_TIMEOUT, diagnosticTimeout);
288
+ }
289
+ if (layer.documentIdleTimeout !== undefined) {
290
+ documentIdleTimeout = clamp(layer.documentIdleTimeout, MIN_DOCUMENT_IDLE_TIMEOUT, MAX_DOCUMENT_IDLE_TIMEOUT, documentIdleTimeout);
291
+ }
292
+ }
293
+
294
+ return { servers, diagnosticTimeout, documentIdleTimeout, perServerTimeout };
295
+ }
package/src/format.ts CHANGED
@@ -9,6 +9,10 @@ export function formatDiagnostics(filePath: string, result: DiagnosticResult): s
9
9
  if (relevant.length === 0 && result.status === "ok" && result.otherFiles.length === 0) return "";
10
10
  if (result.status === "unavailable") return "";
11
11
 
12
+ const retryNote = result.status === "timeout" && result.retryAttempts > 0
13
+ ? ` after ${result.retryAttempts} ${result.retryAttempts === 1 ? "retry" : "retries"}`
14
+ : "";
15
+
12
16
  if (relevant.length === 0 && result.status === "ok" && result.otherFiles.length > 0) {
13
17
  return `\n⚠ LSP diagnostics for ${filePath}: no issues${otherFilesFooter(result)}`;
14
18
  }
@@ -27,7 +31,7 @@ export function formatDiagnostics(filePath: string, result: DiagnosticResult): s
27
31
  const summary = [
28
32
  errorCount > 0 ? `${errorCount} error${errorCount > 1 ? "s" : ""}` : "",
29
33
  warnCount > 0 ? `${warnCount} warning${warnCount > 1 ? "s" : ""}` : "",
30
- result.status === "timeout" ? "timed out, may be incomplete" : "",
34
+ result.status === "timeout" ? `timed out${retryNote}, may be incomplete` : "",
31
35
  ]
32
36
  .filter(Boolean)
33
37
  .join(", ");
@@ -0,0 +1,27 @@
1
+ export interface InstallEntry {
2
+ command: string;
3
+ description: string;
4
+ }
5
+
6
+ export const installRegistry = new Map<string, InstallEntry>([
7
+ ["go", {
8
+ command: "go install golang.org/x/tools/gopls@latest",
9
+ description: "Go language server",
10
+ }],
11
+ ["rust", {
12
+ command: "rustup component add rust-analyzer",
13
+ description: "Rust language server",
14
+ }],
15
+ ["typescript", {
16
+ command: "npm install -g typescript-language-server typescript",
17
+ description: "TypeScript/JavaScript language server",
18
+ }],
19
+ ["python", {
20
+ command: "pip install python-lsp-server",
21
+ description: "Python language server",
22
+ }],
23
+ ["cpp", {
24
+ command: "sudo apt-get install -y clangd || brew install llvm",
25
+ description: "C/C++ language server",
26
+ }],
27
+ ]);
package/src/languages.ts CHANGED
@@ -4,15 +4,18 @@ export interface LanguageServerConfig {
4
4
  command: string;
5
5
  args: string[];
6
6
  rootPatterns: string[];
7
+ diagnosticTimeout?: number;
8
+ maxRetries?: number;
7
9
  }
8
10
 
9
- export const languages: LanguageServerConfig[] = [
11
+ export const builtinLanguages: LanguageServerConfig[] = [
10
12
  {
11
13
  id: "go",
12
14
  extensions: [".go"],
13
15
  command: "gopls",
14
16
  args: ["serve"],
15
17
  rootPatterns: ["go.mod"],
18
+ diagnosticTimeout: 5_000,
16
19
  },
17
20
  {
18
21
  id: "rust",
@@ -20,6 +23,7 @@ export const languages: LanguageServerConfig[] = [
20
23
  command: "rust-analyzer",
21
24
  args: [],
22
25
  rootPatterns: ["Cargo.toml"],
26
+ diagnosticTimeout: 30_000,
23
27
  },
24
28
  {
25
29
  id: "typescript",
@@ -27,18 +31,35 @@ export const languages: LanguageServerConfig[] = [
27
31
  command: "typescript-language-server",
28
32
  args: ["--stdio"],
29
33
  rootPatterns: ["tsconfig.json", "package.json"],
34
+ diagnosticTimeout: 30_000,
35
+ },
36
+ {
37
+ id: "python",
38
+ extensions: [".py"],
39
+ command: "pylsp",
40
+ args: [],
41
+ rootPatterns: ["pyproject.toml", "setup.py", "requirements.txt"],
42
+ diagnosticTimeout: 15_000,
43
+ },
44
+ {
45
+ id: "cpp",
46
+ extensions: [".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx"],
47
+ command: "clangd",
48
+ args: [],
49
+ rootPatterns: ["compile_commands.json", "CMakeLists.txt", ".clangd"],
50
+ diagnosticTimeout: 15_000,
30
51
  },
31
52
  ];
32
53
 
33
- export function languageForFile(path: string): LanguageServerConfig | undefined {
54
+ export function languageForFile(path: string, configs: LanguageServerConfig[]): LanguageServerConfig | undefined {
34
55
  const lower = path.toLowerCase();
35
- return languages.find((lang) => lang.extensions.some((ext) => lower.endsWith(ext)));
56
+ return configs.find((lang) => lang.extensions.some((ext) => lower.endsWith(ext)));
36
57
  }
37
58
 
38
- export function checkExtensionOverlaps(): string[] {
59
+ export function checkExtensionOverlaps(configs: LanguageServerConfig[]): string[] {
39
60
  const warnings: string[] = [];
40
61
  const seen = new Map<string, string>();
41
- for (const lang of languages) {
62
+ for (const lang of configs) {
42
63
  for (const ext of lang.extensions) {
43
64
  const existing = seen.get(ext);
44
65
  if (existing) {
@@ -2,6 +2,8 @@ import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { which, fileUri, findWorkspaceRoot } from "./util.js";
3
3
  import { createLspClient, type LspClient, type DiagnosticResult } from "./client.js";
4
4
  import type { LanguageServerConfig } from "./languages.js";
5
+ import type { Diagnostic } from "vscode-languageserver-protocol";
6
+ import { DEFAULT_DIAGNOSTIC_TIMEOUT, DEFAULT_DOCUMENT_IDLE_TIMEOUT, DEFAULT_MAX_RETRIES } from "./config.js";
5
7
  import { readFile } from "node:fs/promises";
6
8
 
7
9
  interface ManagedServer {
@@ -20,6 +22,7 @@ interface ManagedServer {
20
22
  export interface ServerManager {
21
23
  handleEdit(filePath: string, config: LanguageServerConfig, cwd: string): Promise<DiagnosticResult>;
22
24
  status(): ServerStatus[];
25
+ getAllDiagnostics(): Map<string, Diagnostic[]>;
23
26
  shutdownAll(): Promise<void>;
24
27
  }
25
28
 
@@ -33,12 +36,24 @@ export interface ServerStatus {
33
36
  }
34
37
 
35
38
  const IDLE_TIMEOUT_MS = 240_000;
36
- const DIAGNOSTIC_TIMEOUT_MS = 5_000;
37
39
  const INIT_TIMEOUT_MS = 10_000;
38
- const DOCUMENT_IDLE_MS = 120_000;
39
40
  const SWEEP_INTERVAL_MS = 60_000;
40
41
 
41
- export function createServerManager(): ServerManager {
42
+ export interface ServerManagerOptions {
43
+ diagnosticTimeout?: number;
44
+ documentIdleTimeout?: number;
45
+ perServerTimeout?: Map<string, number>;
46
+ maxRetries?: number;
47
+ }
48
+
49
+ const RETRY_BASE_DELAY_MS = 500;
50
+ const MAX_RETRY_DELAY_MS = 30_000;
51
+
52
+ export function createServerManager(options: ServerManagerOptions = {}): ServerManager {
53
+ const diagnosticTimeout = options.diagnosticTimeout ?? DEFAULT_DIAGNOSTIC_TIMEOUT;
54
+ const documentIdleTimeout = options.documentIdleTimeout ?? DEFAULT_DOCUMENT_IDLE_TIMEOUT;
55
+ const perServerTimeout = options.perServerTimeout ?? new Map();
56
+ const defaultMaxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
42
57
  const servers = new Map<string, ManagedServer>();
43
58
  const pending = new Map<string, Promise<ManagedServer | null>>();
44
59
  const disabledBinaries = new Set<string>();
@@ -51,7 +66,7 @@ export function createServerManager(): ServerManager {
51
66
  const now = Date.now();
52
67
  for (const server of servers.values()) {
53
68
  const stale = [...server.openDocuments.entries()]
54
- .filter(([, lastActive]) => now - lastActive > DOCUMENT_IDLE_MS);
69
+ .filter(([, lastActive]) => now - lastActive > documentIdleTimeout);
55
70
  for (const [docUri] of stale) {
56
71
  server.client.didClose(docUri);
57
72
  server.openDocuments.delete(docUri);
@@ -156,7 +171,7 @@ export function createServerManager(): ServerManager {
156
171
  idleTimer: null,
157
172
  startTime: now,
158
173
  lastActivity: now,
159
- editQueue: Promise.resolve({ status: "ok", diagnostics: [], otherFiles: [] }),
174
+ editQueue: Promise.resolve({ status: "ok", diagnostics: [], otherFiles: [], retryAttempts: 0 }),
160
175
  };
161
176
 
162
177
  child.on("exit", () => {
@@ -189,11 +204,19 @@ export function createServerManager(): ServerManager {
189
204
  return promise;
190
205
  }
191
206
 
207
+ function getMaxRetries(config: LanguageServerConfig): number {
208
+ const raw = config.maxRetries ?? defaultMaxRetries;
209
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return defaultMaxRetries;
210
+ return Math.max(0, Math.min(10, Math.floor(raw)));
211
+ }
212
+
192
213
  async function doEdit(server: ManagedServer, filePath: string): Promise<DiagnosticResult> {
193
214
  resetIdleTimer(server);
194
215
 
195
216
  const uri = fileUri(filePath);
196
217
  const content = await readFile(filePath, "utf-8");
218
+ const timeout = perServerTimeout.get(server.config.id) ?? server.config.diagnosticTimeout ?? diagnosticTimeout;
219
+ const retries = getMaxRetries(server.config);
197
220
 
198
221
  if (server.openDocuments.has(uri)) {
199
222
  server.client.didChange(uri, content);
@@ -202,14 +225,33 @@ export function createServerManager(): ServerManager {
202
225
  }
203
226
  server.openDocuments.set(uri, Date.now());
204
227
 
205
- return server.client.waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS);
228
+ let lastResult = await server.client.waitForDiagnostics(uri, timeout);
229
+
230
+ for (let attempt = 0; attempt < retries && lastResult.status === "timeout"; attempt++) {
231
+ resetIdleTimer(server);
232
+ const baseDelay = Math.min(RETRY_BASE_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
233
+ const jitter = baseDelay * Math.random() * 0.5;
234
+ await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
235
+
236
+ server.client.didChange(uri, content);
237
+ server.openDocuments.set(uri, Date.now());
238
+ const result = await server.client.waitForDiagnostics(uri, timeout);
239
+ result.retryAttempts = attempt + 1;
240
+
241
+ if (result.status === "ok") {
242
+ return result;
243
+ }
244
+ lastResult = result;
245
+ }
246
+
247
+ return lastResult;
206
248
  }
207
249
 
208
250
  return {
209
251
  async handleEdit(filePath: string, config: LanguageServerConfig, cwd: string): Promise<DiagnosticResult> {
210
252
  const root = await findWorkspaceRoot(filePath, config.rootPatterns, cwd);
211
253
  const server = await ensureServer(config, root);
212
- if (!server) return { status: "unavailable" as const, diagnostics: [], otherFiles: [] };
254
+ if (!server) return { status: "unavailable" as const, diagnostics: [], otherFiles: [], retryAttempts: 0 };
213
255
 
214
256
  // serialize edits per server to avoid concurrent waitForDiagnostics races
215
257
  const result = server.editQueue.then(
@@ -231,6 +273,16 @@ export function createServerManager(): ServerManager {
231
273
  }));
232
274
  },
233
275
 
276
+ getAllDiagnostics(): Map<string, Diagnostic[]> {
277
+ const result = new Map<string, Diagnostic[]>();
278
+ for (const server of servers.values()) {
279
+ for (const [uri, diags] of server.client.getAllDiagnostics()) {
280
+ result.set(uri, diags);
281
+ }
282
+ }
283
+ return result;
284
+ },
285
+
234
286
  async shutdownAll() {
235
287
  stopSweepTimer();
236
288
  const shutdowns = Array.from(servers.values()).map((s) => shutdownServer(s));