@mrclrchtr/supi-lsp 0.1.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/bash-guard.ts +58 -0
- package/capabilities.ts +54 -0
- package/client.ts +397 -0
- package/config.ts +99 -0
- package/defaults.json +40 -0
- package/diagnostic-summary.ts +69 -0
- package/diagnostics.ts +93 -0
- package/format.ts +190 -0
- package/guidance.ts +140 -0
- package/lsp.ts +375 -0
- package/manager.ts +396 -0
- package/overrides.ts +95 -0
- package/package.json +36 -0
- package/recent-paths.ts +126 -0
- package/runtime-state.ts +113 -0
- package/summary.ts +118 -0
- package/tool-actions.ts +211 -0
- package/transport.ts +188 -0
- package/tsconfig.json +5 -0
- package/types.ts +286 -0
- package/ui.ts +303 -0
- package/utils.ts +139 -0
package/manager.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// LSP Manager — server pool with lazy spawning and diagnostic collection.
|
|
2
|
+
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { LspClient } from "./client.ts";
|
|
6
|
+
import { getServerForFile } from "./config.ts";
|
|
7
|
+
import {
|
|
8
|
+
accumulateOutstandingDiagnostics,
|
|
9
|
+
collectDiagnosticSummaryCounts,
|
|
10
|
+
createOutstandingDiagnosticSummary,
|
|
11
|
+
relativeFilePathFromUri,
|
|
12
|
+
} from "./diagnostic-summary.ts";
|
|
13
|
+
import {
|
|
14
|
+
displayRelativeFilePath,
|
|
15
|
+
formatCoverageSummaryText,
|
|
16
|
+
formatOutstandingDiagnosticsSummaryText,
|
|
17
|
+
isPathRelevant,
|
|
18
|
+
normalizeRelevantPaths,
|
|
19
|
+
shouldIgnoreLspPath,
|
|
20
|
+
} from "./summary.ts";
|
|
21
|
+
import type { Diagnostic, LspConfig } from "./types.ts";
|
|
22
|
+
import { commandExists, findProjectRoot } from "./utils.ts";
|
|
23
|
+
|
|
24
|
+
// ── Types ─────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface ServerStatus {
|
|
27
|
+
name: string;
|
|
28
|
+
status: "running" | "error" | "unavailable";
|
|
29
|
+
root: string;
|
|
30
|
+
openFiles: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DiagnosticSummary {
|
|
34
|
+
file: string;
|
|
35
|
+
errors: number;
|
|
36
|
+
warnings: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CoverageSummaryEntry {
|
|
40
|
+
name: string;
|
|
41
|
+
fileTypes: string[];
|
|
42
|
+
active: boolean;
|
|
43
|
+
openFiles: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ActiveCoverageSummaryEntry {
|
|
47
|
+
name: string;
|
|
48
|
+
openFiles: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface OutstandingDiagnosticSummaryEntry {
|
|
52
|
+
file: string;
|
|
53
|
+
total: number;
|
|
54
|
+
errors: number;
|
|
55
|
+
warnings: number;
|
|
56
|
+
information: number;
|
|
57
|
+
hints: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ManagerStatus {
|
|
61
|
+
servers: ServerStatus[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── LspManager ────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export class LspManager {
|
|
67
|
+
/** Active clients keyed by "serverName:root" */
|
|
68
|
+
private clients = new Map<string, LspClient>();
|
|
69
|
+
/** Servers we've already tried and failed to start */
|
|
70
|
+
private unavailable = new Set<string>();
|
|
71
|
+
/** Memoized per-command availability of LSP server binaries on PATH */
|
|
72
|
+
private commandAvailability = new Map<string, boolean>();
|
|
73
|
+
|
|
74
|
+
constructor(private readonly config: LspConfig) {}
|
|
75
|
+
|
|
76
|
+
// ── Public API ────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Synchronously check whether a file path maps to a configured LSP server
|
|
80
|
+
* whose binary is actually installed and has not already failed to start.
|
|
81
|
+
* Used by tool-event activation so we don't advertise LSP readiness for
|
|
82
|
+
* files whose server cannot run in this environment.
|
|
83
|
+
*/
|
|
84
|
+
isSupportedSourceFile(filePath: string): boolean {
|
|
85
|
+
// Dependency directories are intentionally excluded from recent-path
|
|
86
|
+
// tracking and diagnostic summaries (shouldIgnoreLspPath). Keep runtime
|
|
87
|
+
// guidance activation consistent: reading or editing a file under
|
|
88
|
+
// node_modules / .pnpm must not arm LSP guidance for dependency sources.
|
|
89
|
+
if (shouldIgnoreLspPath(filePath)) return false;
|
|
90
|
+
const match = getServerForFile(this.config, filePath);
|
|
91
|
+
if (!match) return false;
|
|
92
|
+
const [serverName, serverConfig] = match;
|
|
93
|
+
// Mirror getClientForFile's root resolution so the unavailable check stays
|
|
94
|
+
// root-specific. A failed startup in one workspace must not suppress
|
|
95
|
+
// activation for unrelated roots served by the same language server.
|
|
96
|
+
const fileDir = path.dirname(path.resolve(filePath));
|
|
97
|
+
const root = findProjectRoot(fileDir, serverConfig.rootMarkers, process.cwd());
|
|
98
|
+
if (this.unavailable.has(`${serverName}:${root}`)) return false;
|
|
99
|
+
return this.isServerCommandAvailable(serverConfig.command);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private isServerCommandAvailable(command: string): boolean {
|
|
103
|
+
// Only memoize positive lookups. A negative result may become stale if the
|
|
104
|
+
// user installs the binary mid-session (e.g. `mise install`), and
|
|
105
|
+
// getClientForFile calls commandExists directly — caching false here would
|
|
106
|
+
// leave runtime guidance permanently dormant while client spawning can
|
|
107
|
+
// still succeed.
|
|
108
|
+
if (this.commandAvailability.get(command) === true) return true;
|
|
109
|
+
const available = commandExists(command);
|
|
110
|
+
if (available) this.commandAvailability.set(command, true);
|
|
111
|
+
return available;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get or create an LSP client for the given file.
|
|
116
|
+
* Returns null if no server is configured or available.
|
|
117
|
+
*/
|
|
118
|
+
async getClientForFile(filePath: string): Promise<LspClient | null> {
|
|
119
|
+
const match = getServerForFile(this.config, filePath);
|
|
120
|
+
if (!match) return null;
|
|
121
|
+
|
|
122
|
+
const [serverName, serverConfig] = match;
|
|
123
|
+
|
|
124
|
+
// Find project root
|
|
125
|
+
const fileDir = path.dirname(path.resolve(filePath));
|
|
126
|
+
const root = findProjectRoot(fileDir, serverConfig.rootMarkers, process.cwd());
|
|
127
|
+
const key = `${serverName}:${root}`;
|
|
128
|
+
|
|
129
|
+
// Check if unavailable
|
|
130
|
+
if (this.unavailable.has(key)) return null;
|
|
131
|
+
|
|
132
|
+
// Return existing client
|
|
133
|
+
const existing = this.clients.get(key);
|
|
134
|
+
if (existing && existing.status === "running") return existing;
|
|
135
|
+
|
|
136
|
+
// If existing client errored, remove it
|
|
137
|
+
if (existing && existing.status === "error") {
|
|
138
|
+
this.clients.delete(key);
|
|
139
|
+
this.unavailable.add(key);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Validate command exists
|
|
144
|
+
if (!commandExists(serverConfig.command)) {
|
|
145
|
+
this.unavailable.add(key);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Spawn new client
|
|
150
|
+
const client = new LspClient(serverName, serverConfig, root);
|
|
151
|
+
this.clients.set(key, client);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await client.start();
|
|
155
|
+
return client;
|
|
156
|
+
} catch (_err) {
|
|
157
|
+
this.unavailable.add(key);
|
|
158
|
+
this.clients.delete(key);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Sync a file with its LSP server and wait for diagnostics.
|
|
165
|
+
* Returns diagnostics filtered to the given severity threshold.
|
|
166
|
+
*/
|
|
167
|
+
async syncFileAndGetDiagnostics(
|
|
168
|
+
filePath: string,
|
|
169
|
+
maxSeverity: number = 1,
|
|
170
|
+
): Promise<Diagnostic[]> {
|
|
171
|
+
const client = await this.getClientForFile(filePath);
|
|
172
|
+
if (!client) return [];
|
|
173
|
+
|
|
174
|
+
const resolvedPath = path.resolve(filePath);
|
|
175
|
+
let content: string;
|
|
176
|
+
try {
|
|
177
|
+
content = fs.readFileSync(resolvedPath, "utf-8");
|
|
178
|
+
} catch {
|
|
179
|
+
this.closeFile(resolvedPath);
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const diagnostics = await client.syncAndWaitForDiagnostics(resolvedPath, content);
|
|
184
|
+
return diagnostics.filter((d) => d.severity !== undefined && d.severity <= maxSeverity);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Close a file across any active LSP clients and clear its cached diagnostics. */
|
|
188
|
+
closeFile(filePath: string): void {
|
|
189
|
+
const resolvedPath = path.resolve(filePath);
|
|
190
|
+
for (const client of this.clients.values()) {
|
|
191
|
+
client.didClose(resolvedPath);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Remove any missing files from open-document and diagnostic state. */
|
|
196
|
+
pruneMissingFiles(): string[] {
|
|
197
|
+
const removed: string[] = [];
|
|
198
|
+
for (const client of this.clients.values()) {
|
|
199
|
+
const prune = (client as unknown as { pruneMissingFiles?: () => string[] }).pruneMissingFiles;
|
|
200
|
+
if (typeof prune === "function") {
|
|
201
|
+
removed.push(...prune.call(client));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return removed;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Shut down all running LSP servers. */
|
|
208
|
+
async shutdownAll(): Promise<void> {
|
|
209
|
+
const shutdowns = Array.from(this.clients.values()).map((c) => c.shutdown().catch(() => {}));
|
|
210
|
+
await Promise.all(shutdowns);
|
|
211
|
+
this.clients.clear();
|
|
212
|
+
this.unavailable.clear();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Get status of all servers. */
|
|
216
|
+
getStatus(): ManagerStatus {
|
|
217
|
+
this.pruneMissingFiles();
|
|
218
|
+
const servers: ServerStatus[] = [];
|
|
219
|
+
for (const [_key, client] of this.clients) {
|
|
220
|
+
servers.push({
|
|
221
|
+
name: client.name,
|
|
222
|
+
status: client.status === "running" ? "running" : "error",
|
|
223
|
+
root: client.root,
|
|
224
|
+
openFiles: client.openFiles,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return { servers };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Get configured and active LSP coverage for the current project. */
|
|
231
|
+
getCoverageSummary(): CoverageSummaryEntry[] {
|
|
232
|
+
this.pruneMissingFiles();
|
|
233
|
+
const activeServers = new Map<string, { active: boolean; openFiles: number }>();
|
|
234
|
+
|
|
235
|
+
for (const server of this.getStatus().servers) {
|
|
236
|
+
const current = activeServers.get(server.name) ?? { active: false, openFiles: 0 };
|
|
237
|
+
current.active = current.active || server.status === "running";
|
|
238
|
+
current.openFiles += server.openFiles.length;
|
|
239
|
+
activeServers.set(server.name, current);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return Object.entries(this.config.servers)
|
|
243
|
+
.map(([name, server]) => {
|
|
244
|
+
const activity = activeServers.get(name);
|
|
245
|
+
return {
|
|
246
|
+
name,
|
|
247
|
+
fileTypes: server.fileTypes,
|
|
248
|
+
active: activity?.active ?? false,
|
|
249
|
+
openFiles: activity?.openFiles ?? 0,
|
|
250
|
+
} satisfies CoverageSummaryEntry;
|
|
251
|
+
})
|
|
252
|
+
.sort(
|
|
253
|
+
(a, b) =>
|
|
254
|
+
Number(b.active) - Number(a.active) ||
|
|
255
|
+
b.openFiles - a.openFiles ||
|
|
256
|
+
a.name.localeCompare(b.name),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Get active LSP coverage summarized by running servers with open files. */
|
|
261
|
+
getActiveCoverageSummary(): ActiveCoverageSummaryEntry[] {
|
|
262
|
+
this.pruneMissingFiles();
|
|
263
|
+
const activeServers = new Map<string, Set<string>>();
|
|
264
|
+
|
|
265
|
+
for (const server of this.getStatus().servers) {
|
|
266
|
+
if (server.status !== "running" || server.openFiles.length === 0) continue;
|
|
267
|
+
|
|
268
|
+
const openFiles = activeServers.get(server.name) ?? new Set<string>();
|
|
269
|
+
for (const file of server.openFiles) {
|
|
270
|
+
const relativeFile = displayRelativeFilePath(file);
|
|
271
|
+
if (shouldIgnoreLspPath(relativeFile)) continue;
|
|
272
|
+
openFiles.add(relativeFile);
|
|
273
|
+
}
|
|
274
|
+
activeServers.set(server.name, openFiles);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return Array.from(activeServers.entries())
|
|
278
|
+
.map(([name, openFiles]) => ({
|
|
279
|
+
name,
|
|
280
|
+
openFiles: Array.from(openFiles).sort(),
|
|
281
|
+
}))
|
|
282
|
+
.sort((a, b) => b.openFiles.length - a.openFiles.length || a.name.localeCompare(b.name));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Get active coverage as compact text suitable for pre-turn context. */
|
|
286
|
+
getCoverageSummaryText(maxServers: number = 2, maxFiles: number = 2): string | null {
|
|
287
|
+
return formatCoverageSummaryText(this.getActiveCoverageSummary(), maxServers, maxFiles);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Get active coverage filtered to files or directories relevant to the current turn. */
|
|
291
|
+
getRelevantCoverageSummaryText(
|
|
292
|
+
relevantPaths: string[],
|
|
293
|
+
maxServers: number = 2,
|
|
294
|
+
maxFiles: number = 2,
|
|
295
|
+
): string | null {
|
|
296
|
+
const normalizedPaths = normalizeRelevantPaths(relevantPaths);
|
|
297
|
+
if (normalizedPaths.length === 0) return null;
|
|
298
|
+
|
|
299
|
+
const relevantEntries = this.getActiveCoverageSummary()
|
|
300
|
+
.map((entry) => ({
|
|
301
|
+
...entry,
|
|
302
|
+
openFiles: entry.openFiles.filter((file) => isPathRelevant(file, normalizedPaths)),
|
|
303
|
+
}))
|
|
304
|
+
.filter((entry) => entry.openFiles.length > 0);
|
|
305
|
+
|
|
306
|
+
return formatCoverageSummaryText(relevantEntries, maxServers, maxFiles);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Get a diagnostic summary across all servers and files. */
|
|
310
|
+
getDiagnosticSummary(): DiagnosticSummary[] {
|
|
311
|
+
this.pruneMissingFiles();
|
|
312
|
+
const fileDiags = new Map<string, { errors: number; warnings: number }>();
|
|
313
|
+
|
|
314
|
+
for (const client of this.clients.values()) {
|
|
315
|
+
for (const entry of client.getAllDiagnostics()) {
|
|
316
|
+
collectDiagnosticSummaryCounts(fileDiags, entry);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return Array.from(fileDiags.entries()).map(([file, counts]) => ({ file, ...counts }));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Get outstanding diagnostics at or above the configured inline threshold. */
|
|
324
|
+
getOutstandingDiagnosticSummary(maxSeverity: number = 1): OutstandingDiagnosticSummaryEntry[] {
|
|
325
|
+
this.pruneMissingFiles();
|
|
326
|
+
const fileDiags = new Map<string, OutstandingDiagnosticSummaryEntry>();
|
|
327
|
+
|
|
328
|
+
for (const client of this.clients.values()) {
|
|
329
|
+
for (const entry of client.getAllDiagnostics()) {
|
|
330
|
+
const file = relativeFilePathFromUri(entry.uri);
|
|
331
|
+
if (shouldIgnoreLspPath(file)) continue;
|
|
332
|
+
const current = fileDiags.get(file) ?? createOutstandingDiagnosticSummary(file);
|
|
333
|
+
const next = accumulateOutstandingDiagnostics(current, entry.diagnostics, maxSeverity);
|
|
334
|
+
|
|
335
|
+
if (next.total > 0) {
|
|
336
|
+
fileDiags.set(file, next);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return Array.from(fileDiags.values()).sort(
|
|
342
|
+
(a, b) =>
|
|
343
|
+
b.errors - a.errors ||
|
|
344
|
+
b.warnings - a.warnings ||
|
|
345
|
+
b.information - a.information ||
|
|
346
|
+
b.hints - a.hints ||
|
|
347
|
+
a.file.localeCompare(b.file),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Get outstanding diagnostics as compact text suitable for pre-turn context. */
|
|
352
|
+
getOutstandingDiagnosticsSummaryText(
|
|
353
|
+
maxSeverity: number = 1,
|
|
354
|
+
maxFiles: number = 3,
|
|
355
|
+
): string | null {
|
|
356
|
+
return formatOutstandingDiagnosticsSummaryText(
|
|
357
|
+
this.getOutstandingDiagnosticSummary(maxSeverity),
|
|
358
|
+
maxFiles,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Get outstanding diagnostics filtered to files or directories relevant to the current turn. */
|
|
363
|
+
getRelevantOutstandingDiagnosticsSummaryText(
|
|
364
|
+
relevantPaths: string[],
|
|
365
|
+
maxSeverity: number = 1,
|
|
366
|
+
maxFiles: number = 3,
|
|
367
|
+
): string | null {
|
|
368
|
+
const normalizedPaths = normalizeRelevantPaths(relevantPaths);
|
|
369
|
+
if (normalizedPaths.length === 0) return null;
|
|
370
|
+
|
|
371
|
+
const relevantEntries = this.getOutstandingDiagnosticSummary(maxSeverity).filter((entry) =>
|
|
372
|
+
isPathRelevant(entry.file, normalizedPaths),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
return formatOutstandingDiagnosticsSummaryText(relevantEntries, maxFiles);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Ensure a file is open in its LSP server.
|
|
380
|
+
* Used when the agent needs to read a file for the first time.
|
|
381
|
+
*/
|
|
382
|
+
async ensureFileOpen(filePath: string): Promise<LspClient | null> {
|
|
383
|
+
const client = await this.getClientForFile(filePath);
|
|
384
|
+
if (!client) return null;
|
|
385
|
+
|
|
386
|
+
const resolvedPath = path.resolve(filePath);
|
|
387
|
+
try {
|
|
388
|
+
const content = fs.readFileSync(resolvedPath, "utf-8");
|
|
389
|
+
client.didOpen(resolvedPath, content);
|
|
390
|
+
return client;
|
|
391
|
+
} catch {
|
|
392
|
+
this.closeFile(resolvedPath);
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
package/overrides.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { formatDiagnostics } from "./diagnostics.ts";
|
|
4
|
+
import type { LspManager } from "./manager.ts";
|
|
5
|
+
import { trackRecentPath } from "./recent-paths.ts";
|
|
6
|
+
|
|
7
|
+
interface LspOverrideState {
|
|
8
|
+
inlineSeverity: number;
|
|
9
|
+
getManager(): LspManager | null;
|
|
10
|
+
getRecentPaths(): string[];
|
|
11
|
+
setRecentPaths(paths: string[]): void;
|
|
12
|
+
onRecentPathsChange?(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerLspAwareToolOverrides(pi: ExtensionAPI, state: LspOverrideState): void {
|
|
16
|
+
const cwd = process.cwd();
|
|
17
|
+
const originalRead = createReadTool(cwd);
|
|
18
|
+
const originalWrite = createWriteTool(cwd);
|
|
19
|
+
const originalEdit = createEditTool(cwd);
|
|
20
|
+
|
|
21
|
+
pi.registerTool({
|
|
22
|
+
...originalRead,
|
|
23
|
+
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
24
|
+
async execute(toolCallId, params, signal, onUpdate, _ctx) {
|
|
25
|
+
const result = await originalRead.execute(toolCallId, params, signal, onUpdate);
|
|
26
|
+
recordRecentPath(state, params.path);
|
|
27
|
+
await ensureFileOpen(state.getManager(), params.path);
|
|
28
|
+
return result;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
pi.registerTool({
|
|
33
|
+
...originalWrite,
|
|
34
|
+
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
35
|
+
async execute(toolCallId, params, signal, onUpdate, _ctx) {
|
|
36
|
+
const result = await originalWrite.execute(toolCallId, params, signal, onUpdate);
|
|
37
|
+
|
|
38
|
+
recordRecentPath(state, params.path);
|
|
39
|
+
return appendInlineDiagnostics(state.getManager(), params.path, state.inlineSeverity, result);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
pi.registerTool({
|
|
44
|
+
...originalEdit,
|
|
45
|
+
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
46
|
+
async execute(toolCallId, params, signal, onUpdate, _ctx) {
|
|
47
|
+
const result = await originalEdit.execute(toolCallId, params, signal, onUpdate);
|
|
48
|
+
|
|
49
|
+
recordRecentPath(state, params.path);
|
|
50
|
+
return appendInlineDiagnostics(state.getManager(), params.path, state.inlineSeverity, result);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function appendInlineDiagnostics<T extends { content: unknown[]; details: unknown }>(
|
|
56
|
+
manager: LspManager | null,
|
|
57
|
+
filePath: string,
|
|
58
|
+
inlineSeverity: number,
|
|
59
|
+
result: T,
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
if (!manager) return result;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const diags = await manager.syncFileAndGetDiagnostics(filePath, inlineSeverity);
|
|
65
|
+
if (diags.length === 0) return result;
|
|
66
|
+
|
|
67
|
+
const diagText = formatDiagnostics(filePath, diags);
|
|
68
|
+
const diagnosticContent = {
|
|
69
|
+
type: "text" as const,
|
|
70
|
+
text: `\n\n⚠️ LSP Diagnostics:\n${diagText}`,
|
|
71
|
+
} as T["content"][number];
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
...result,
|
|
75
|
+
content: [...result.content, diagnosticContent],
|
|
76
|
+
} as T;
|
|
77
|
+
} catch {
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function ensureFileOpen(manager: LspManager | null, filePath: string): Promise<void> {
|
|
83
|
+
if (!manager) return;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await manager.ensureFileOpen(filePath);
|
|
87
|
+
} catch {
|
|
88
|
+
// Never block the agent on LSP errors
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function recordRecentPath(state: LspOverrideState, filePath: string): void {
|
|
93
|
+
state.setRecentPaths(trackRecentPath(state.getRecentPaths(), filePath));
|
|
94
|
+
state.onRecentPathsChange?.();
|
|
95
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mrclrchtr/supi-lsp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SuPi LSP extension — Language Server Protocol integration for pi",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/mrclrchtr/supi.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi",
|
|
16
|
+
"pi-coding-agent"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
"*.ts",
|
|
20
|
+
"*.json",
|
|
21
|
+
"!__tests__"
|
|
22
|
+
],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@mariozechner/pi-coding-agent": "~0.66.0",
|
|
25
|
+
"@mariozechner/pi-tui": "~0.66.0",
|
|
26
|
+
"@sinclair/typebox": ">=0.34.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"vitest": "^4.1.4"
|
|
30
|
+
},
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./lsp.ts"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
}
|
package/recent-paths.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { shouldIgnoreLspPath } from "./summary.ts";
|
|
4
|
+
|
|
5
|
+
const LSP_STATE_ENTRY_TYPE = "lsp-state";
|
|
6
|
+
|
|
7
|
+
export function updateRecentPathsFromToolEvent(
|
|
8
|
+
toolName: string,
|
|
9
|
+
input: Record<string, unknown>,
|
|
10
|
+
recentPaths: string[],
|
|
11
|
+
): string[] {
|
|
12
|
+
const filePath = getFilePathFromToolEvent(toolName, input);
|
|
13
|
+
return filePath ? trackRecentPath(recentPaths, filePath) : recentPaths;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getFilePathFromToolEvent(
|
|
17
|
+
toolName: string,
|
|
18
|
+
input: Record<string, unknown>,
|
|
19
|
+
): string | null {
|
|
20
|
+
const raw = getRawFilePathFromToolEvent(toolName, input);
|
|
21
|
+
return raw === null ? null : normalizeTrackedPath(raw);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Like getFilePathFromToolEvent but returns the unfiltered string path from
|
|
26
|
+
* the tool input. Used by runtime-guidance gating, which must accept absolute
|
|
27
|
+
* paths to files outside cwd (sibling worktrees, monorepo packages) since
|
|
28
|
+
* read/edit/lsp all support them and they get real LSP coverage.
|
|
29
|
+
*/
|
|
30
|
+
export function getRawFilePathFromToolEvent(
|
|
31
|
+
toolName: string,
|
|
32
|
+
input: Record<string, unknown>,
|
|
33
|
+
): string | null {
|
|
34
|
+
if (
|
|
35
|
+
(toolName === "read" || toolName === "write" || toolName === "edit") &&
|
|
36
|
+
typeof input.path === "string"
|
|
37
|
+
) {
|
|
38
|
+
return input.path;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (toolName === "lsp" && typeof input.file === "string") {
|
|
42
|
+
return input.file;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function trackRecentPath(
|
|
49
|
+
recentPaths: string[],
|
|
50
|
+
filePath: string,
|
|
51
|
+
maxEntries: number = 6,
|
|
52
|
+
): string[] {
|
|
53
|
+
const normalized = normalizeTrackedPath(filePath);
|
|
54
|
+
if (!normalized) return recentPaths;
|
|
55
|
+
|
|
56
|
+
const next = [normalized, ...recentPaths.filter((entry) => entry !== normalized)];
|
|
57
|
+
return next.slice(0, maxEntries);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert any tool-supplied path into a stable form for tracking. In-tree
|
|
62
|
+
* paths collapse to their project-relative form; out-of-tree paths preserve
|
|
63
|
+
* the absolute form so sibling worktrees / monorepo packages stay in the
|
|
64
|
+
* relevance set that powers `getSemanticBashBlockReason`. This mirrors
|
|
65
|
+
* `displayRelativeFilePath`, so recent-paths, tracked runtime paths, and
|
|
66
|
+
* diagnostic keys all share one representation. Files in dependency
|
|
67
|
+
* directories (node_modules, .pnpm) are dropped so they don't crowd out real
|
|
68
|
+
* source files.
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeTrackedPath(filePath: string): string | null {
|
|
71
|
+
const resolved = path.resolve(filePath);
|
|
72
|
+
if (shouldIgnoreLspPath(resolved)) return null;
|
|
73
|
+
const relative = path.relative(process.cwd(), resolved);
|
|
74
|
+
if (relative === "") return path.basename(resolved);
|
|
75
|
+
if (relative.startsWith(`..${path.sep}`) || relative === "..") {
|
|
76
|
+
return resolved;
|
|
77
|
+
}
|
|
78
|
+
return relative.replaceAll(path.sep, "/");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Recover the recent-paths list from previously persisted session entries.
|
|
83
|
+
* Each turn that saw new paths appended a fresh `lsp-state` entry, so the
|
|
84
|
+
* latest one (`.pop()`) is the authoritative snapshot — earlier entries are
|
|
85
|
+
* stale superseded states, not history we want to merge.
|
|
86
|
+
*/
|
|
87
|
+
export function restoreRecentPaths(
|
|
88
|
+
entries: Array<{ type?: string; customType?: string; data?: unknown }>,
|
|
89
|
+
): string[] {
|
|
90
|
+
const entry = entries
|
|
91
|
+
.filter(
|
|
92
|
+
(candidate) => candidate.type === "custom" && candidate.customType === LSP_STATE_ENTRY_TYPE,
|
|
93
|
+
)
|
|
94
|
+
.pop() as { data?: { recentPaths?: unknown } } | undefined;
|
|
95
|
+
|
|
96
|
+
return sanitizeRecentPaths(entry?.data?.recentPaths);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function persistRecentPaths(
|
|
100
|
+
pi: ExtensionAPI,
|
|
101
|
+
recentPaths: string[],
|
|
102
|
+
persistedRecentPaths: string[],
|
|
103
|
+
): string[] {
|
|
104
|
+
const sanitized = sanitizeRecentPaths(recentPaths);
|
|
105
|
+
if (samePaths(sanitized, persistedRecentPaths)) return persistedRecentPaths;
|
|
106
|
+
|
|
107
|
+
pi.appendEntry(LSP_STATE_ENTRY_TYPE, { recentPaths: sanitized });
|
|
108
|
+
return sanitized;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sanitizeRecentPaths(paths: unknown, maxEntries: number = 6): string[] {
|
|
112
|
+
if (!Array.isArray(paths)) return [];
|
|
113
|
+
|
|
114
|
+
return Array.from(
|
|
115
|
+
new Set(
|
|
116
|
+
paths
|
|
117
|
+
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
118
|
+
.map(normalizeTrackedPath)
|
|
119
|
+
.filter((value): value is string => value !== null),
|
|
120
|
+
),
|
|
121
|
+
).slice(0, maxEntries);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function samePaths(a: string[], b: string[]): boolean {
|
|
125
|
+
return a.length === b.length && a.every((entry, index) => entry === b[index]);
|
|
126
|
+
}
|