@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/lsp.ts ADDED
@@ -0,0 +1,375 @@
1
+ // LSP Extension for pi — provides Language Server Protocol integration.
2
+ //
3
+ // Gives the agent type-aware hover, go-to-definition,
4
+ // diagnostics, document-symbols, rename, and code-actions via a registered
5
+ // `lsp` tool. It also keeps supported source files warm in their language
6
+ // servers, surfaces inline diagnostics after edits/writes, and injects concise
7
+ // semantic-first guidance into agent turns.
8
+ //
9
+ // Environment variables:
10
+ // PI_LSP_DISABLED=1 — disable all LSP functionality
11
+ // PI_LSP_SERVERS=a,b — restrict to listed servers
12
+ // PI_LSP_SEVERITY=2 — inline severity threshold (1=error, 2=warn, 3=info, 4=hint)
13
+
14
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
+ import { Type } from "@sinclair/typebox";
16
+ import { shouldBlockSemanticBashSearch } from "./bash-guard.ts";
17
+ import { loadConfig } from "./config.ts";
18
+ import {
19
+ extractPromptPathHints,
20
+ filterLspGuidanceMessages,
21
+ lspPromptGuidelines,
22
+ lspPromptSnippet,
23
+ mergeRelevantPaths,
24
+ runtimeGuidanceFingerprint,
25
+ } from "./guidance.ts";
26
+ import { LspManager } from "./manager.ts";
27
+ import { registerLspAwareToolOverrides } from "./overrides.ts";
28
+ import {
29
+ persistRecentPaths,
30
+ restoreRecentPaths,
31
+ updateRecentPathsFromToolEvent,
32
+ } from "./recent-paths.ts";
33
+ import {
34
+ computePendingRuntimeGuidance,
35
+ createRuntimeGuidanceState,
36
+ type LspRuntimeGuidanceState,
37
+ pruneMissingTrackedPaths,
38
+ registerQualifyingSourceInteraction,
39
+ resetRuntimeGuidanceState,
40
+ } from "./runtime-state.ts";
41
+ import { executeAction, type LspAction, lspToolDescription } from "./tool-actions.ts";
42
+ import { type LspInspectorState, toggleLspStatusOverlay, updateLspUi } from "./ui.ts";
43
+
44
+ const LspActionEnum = Type.Union([
45
+ Type.Literal("hover"),
46
+ Type.Literal("definition"),
47
+ Type.Literal("references"),
48
+ Type.Literal("diagnostics"),
49
+ Type.Literal("symbols"),
50
+ Type.Literal("rename"),
51
+ Type.Literal("code_actions"),
52
+ ]);
53
+
54
+ interface LspRuntimeState {
55
+ manager: LspManager | null;
56
+ recentPaths: string[];
57
+ persistedRecentPaths: string[];
58
+ currentPrompt: string;
59
+ currentRelevantPaths: string[];
60
+ currentGuidanceToken: string | null;
61
+ guidanceCounter: number;
62
+ inlineSeverity: number;
63
+ inspector: LspInspectorState;
64
+ runtime: LspRuntimeGuidanceState;
65
+ }
66
+
67
+ export default function lspExtension(pi: ExtensionAPI) {
68
+ if (process.env.PI_LSP_DISABLED === "1") {
69
+ registerDisabledStatusCommand(pi);
70
+ return;
71
+ }
72
+
73
+ const state = createRuntimeState(parseSeverity(process.env.PI_LSP_SEVERITY));
74
+
75
+ registerLspAwareToolOverrides(pi, {
76
+ inlineSeverity: state.inlineSeverity,
77
+ getManager: () => state.manager,
78
+ getRecentPaths: () => state.recentPaths,
79
+ setRecentPaths: (paths) => {
80
+ state.recentPaths = paths;
81
+ refreshRelevantPaths(state);
82
+ },
83
+ onRecentPathsChange: () => refreshRelevantPaths(state),
84
+ });
85
+
86
+ registerSessionLifecycleHandlers(pi, state);
87
+ registerBehaviorHandlers(pi, state);
88
+ registerLspTool(pi, state);
89
+ registerLspStatusCommand(pi, state);
90
+ }
91
+
92
+ function registerDisabledStatusCommand(pi: ExtensionAPI): void {
93
+ pi.registerCommand("lsp-status", {
94
+ description: "Show LSP server status",
95
+ handler: async (_args, ctx) => {
96
+ ctx.ui.notify("LSP is disabled (PI_LSP_DISABLED=1)", "warning");
97
+ },
98
+ });
99
+ }
100
+
101
+ function createRuntimeState(inlineSeverity: number): LspRuntimeState {
102
+ return {
103
+ manager: null,
104
+ recentPaths: [],
105
+ persistedRecentPaths: [],
106
+ currentPrompt: "",
107
+ currentRelevantPaths: [],
108
+ currentGuidanceToken: null,
109
+ guidanceCounter: 0,
110
+ inlineSeverity,
111
+ inspector: {
112
+ handle: null,
113
+ close: null,
114
+ },
115
+ runtime: createRuntimeGuidanceState(),
116
+ };
117
+ }
118
+
119
+ function registerSessionLifecycleHandlers(pi: ExtensionAPI, state: LspRuntimeState): void {
120
+ pi.on("session_start", async (_event, ctx) => {
121
+ if (state.manager) {
122
+ await state.manager.shutdownAll();
123
+ }
124
+
125
+ ensureLspToolActive(pi);
126
+ state.manager = new LspManager(loadConfig(process.cwd()));
127
+ state.recentPaths = restoreRecentPaths(
128
+ ctx.sessionManager.getEntries() as Array<{
129
+ type?: string;
130
+ customType?: string;
131
+ data?: unknown;
132
+ }>,
133
+ );
134
+ state.persistedRecentPaths = [...state.recentPaths];
135
+ state.currentPrompt = "";
136
+ state.currentGuidanceToken = null;
137
+ state.guidanceCounter = 0;
138
+ resetRuntimeGuidanceState(state.runtime);
139
+ refreshRelevantPaths(state);
140
+ updateLspUi(ctx, state.manager, state.inlineSeverity);
141
+ });
142
+
143
+ pi.on("session_shutdown", async () => {
144
+ if (state.manager) {
145
+ await state.manager.shutdownAll();
146
+ state.manager = null;
147
+ }
148
+
149
+ state.inspector.close?.();
150
+ state.recentPaths = [];
151
+ state.persistedRecentPaths = [];
152
+ state.currentPrompt = "";
153
+ state.currentRelevantPaths = [];
154
+ state.currentGuidanceToken = null;
155
+ resetRuntimeGuidanceState(state.runtime);
156
+ });
157
+
158
+ pi.on("turn_end", async () => {
159
+ state.persistedRecentPaths = persistRecentPaths(
160
+ pi,
161
+ state.recentPaths,
162
+ state.persistedRecentPaths,
163
+ );
164
+ });
165
+
166
+ pi.on("agent_end", async (_event, ctx) => {
167
+ state.currentPrompt = "";
168
+ state.currentRelevantPaths = [];
169
+ state.currentGuidanceToken = null;
170
+
171
+ if (state.manager) {
172
+ updateLspUi(ctx, state.manager, state.inlineSeverity);
173
+ }
174
+ });
175
+ }
176
+
177
+ function registerBehaviorHandlers(pi: ExtensionAPI, state: LspRuntimeState): void {
178
+ pi.on("before_agent_start", async (event, ctx) => {
179
+ ensureLspToolActive(pi);
180
+ if (!state.manager) return;
181
+
182
+ state.manager.pruneMissingFiles();
183
+ pruneMissingTrackedPaths(state.runtime);
184
+ state.currentPrompt = event.prompt;
185
+ refreshRelevantPaths(state);
186
+ updateLspUi(ctx, state.manager, state.inlineSeverity);
187
+
188
+ const guidance = computePendingRuntimeGuidance(
189
+ state.runtime,
190
+ state.manager,
191
+ state.inlineSeverity,
192
+ );
193
+ if (!guidance) return;
194
+
195
+ const fingerprint = runtimeGuidanceFingerprint(guidance.input);
196
+
197
+ // Refresh the stored fingerprint even when there is nothing to inject.
198
+ // Without this, a diagnostic summary that disappears and later returns
199
+ // identical would still match the previously injected fingerprint and be
200
+ // silently skipped — the caller would never see the regression resurface.
201
+ if (!guidance.content) {
202
+ state.runtime.lastInjectedFingerprint = fingerprint;
203
+ return;
204
+ }
205
+
206
+ // Pending activation always injects (one-shot ready hint) regardless of
207
+ // fingerprint match; otherwise dedupe against the last injected snapshot.
208
+ if (
209
+ fingerprint === state.runtime.lastInjectedFingerprint &&
210
+ !guidance.input.pendingActivation
211
+ ) {
212
+ return;
213
+ }
214
+
215
+ state.runtime.lastInjectedFingerprint = fingerprint;
216
+ state.runtime.pendingActivation = false;
217
+ state.currentGuidanceToken = `lsp-guidance-${++state.guidanceCounter}`;
218
+
219
+ return {
220
+ message: {
221
+ customType: "lsp-guidance",
222
+ content: guidance.content,
223
+ display: false,
224
+ details: {
225
+ guidanceToken: state.currentGuidanceToken,
226
+ inlineSeverity: state.inlineSeverity,
227
+ },
228
+ },
229
+ };
230
+ });
231
+
232
+ pi.on("context", (event) => {
233
+ const messages = filterLspGuidanceMessages(
234
+ event.messages as Array<{ customType?: string; details?: unknown }>,
235
+ state.currentGuidanceToken,
236
+ ) as typeof event.messages;
237
+
238
+ if (messages.length === event.messages.length) return;
239
+ return { messages };
240
+ });
241
+
242
+ pi.on("tool_call", async (event) => {
243
+ const reason = getSemanticBashBlockReason(event.toolName, event.input, state);
244
+ if (reason) {
245
+ return { block: true, reason };
246
+ }
247
+ });
248
+
249
+ pi.on("tool_result", async (event, ctx) => {
250
+ if (!state.manager) return;
251
+
252
+ if (event.toolName === "lsp") {
253
+ state.recentPaths = updateRecentPathsFromToolEvent(
254
+ event.toolName,
255
+ event.input,
256
+ state.recentPaths,
257
+ );
258
+ refreshRelevantPaths(state);
259
+ }
260
+
261
+ if (isLspAwareTool(event.toolName)) {
262
+ // Only treat successful interactions as qualifying. Failed lsp/read/edit
263
+ // calls (e.g. invalid params, missing files) shouldn't arm runtime
264
+ // guidance — the file may not have been touched at all.
265
+ if (!event.isError) {
266
+ registerQualifyingSourceInteraction(
267
+ state.runtime,
268
+ state.manager,
269
+ event.toolName,
270
+ event.input,
271
+ );
272
+ }
273
+ updateLspUi(ctx, state.manager, state.inlineSeverity);
274
+ }
275
+ });
276
+ }
277
+
278
+ function registerLspTool(pi: ExtensionAPI, state: LspRuntimeState): void {
279
+ pi.registerTool({
280
+ name: "lsp",
281
+ label: "LSP",
282
+ description: lspToolDescription,
283
+ promptSnippet: lspPromptSnippet,
284
+ promptGuidelines: lspPromptGuidelines,
285
+ parameters: Type.Object({
286
+ action: LspActionEnum,
287
+ file: Type.Optional(Type.String({ description: "File path (relative or absolute)" })),
288
+ line: Type.Optional(Type.Number({ description: "1-based line number" })),
289
+ character: Type.Optional(Type.Number({ description: "1-based column number" })),
290
+ newName: Type.Optional(Type.String({ description: "New name (for rename action)" })),
291
+ }),
292
+ // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
293
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
294
+ if (!state.manager) {
295
+ return {
296
+ content: [{ type: "text", text: "LSP not initialized. Start a new session first." }],
297
+ details: {},
298
+ };
299
+ }
300
+
301
+ const text = await executeAction(
302
+ state.manager,
303
+ params as {
304
+ action: LspAction;
305
+ file?: string;
306
+ line?: number;
307
+ character?: number;
308
+ newName?: string;
309
+ },
310
+ );
311
+
312
+ return {
313
+ content: [{ type: "text", text }],
314
+ details: {},
315
+ };
316
+ },
317
+ });
318
+ }
319
+
320
+ function registerLspStatusCommand(pi: ExtensionAPI, state: LspRuntimeState): void {
321
+ pi.registerCommand("lsp-status", {
322
+ description: "Show active LSP servers, open files, and diagnostics",
323
+ handler: async (_args, ctx) => {
324
+ if (!state.manager) {
325
+ ctx.ui.notify("LSP not initialized", "warning");
326
+ return;
327
+ }
328
+
329
+ toggleLspStatusOverlay(ctx, state.manager, state.inlineSeverity, state.inspector);
330
+ },
331
+ });
332
+ }
333
+
334
+ function refreshRelevantPaths(state: LspRuntimeState): void {
335
+ state.currentRelevantPaths = mergeRelevantPaths(
336
+ extractPromptPathHints(state.currentPrompt),
337
+ state.recentPaths,
338
+ );
339
+ }
340
+
341
+ function getSemanticBashBlockReason(
342
+ toolName: string,
343
+ input: Record<string, unknown>,
344
+ state: LspRuntimeState,
345
+ ): string | null {
346
+ if (!state.manager || toolName !== "bash") return null;
347
+ if (typeof input.command !== "string") return null;
348
+
349
+ const hasRelevantCoverage =
350
+ state.currentRelevantPaths.length > 0 &&
351
+ state.manager.getRelevantCoverageSummaryText(state.currentRelevantPaths) !== null;
352
+
353
+ return shouldBlockSemanticBashSearch(
354
+ input.command,
355
+ state.currentPrompt,
356
+ state.currentRelevantPaths,
357
+ hasRelevantCoverage,
358
+ );
359
+ }
360
+
361
+ function isLspAwareTool(toolName: string): boolean {
362
+ return toolName === "lsp" || toolName === "read" || toolName === "write" || toolName === "edit";
363
+ }
364
+
365
+ function ensureLspToolActive(pi: ExtensionAPI): void {
366
+ const activeTools = pi.getActiveTools();
367
+ if (activeTools.includes("lsp")) return;
368
+ pi.setActiveTools([...activeTools, "lsp"]);
369
+ }
370
+
371
+ function parseSeverity(env: string | undefined): number {
372
+ if (!env) return 1;
373
+ const parsed = parseInt(env, 10);
374
+ return parsed >= 1 && parsed <= 4 ? parsed : 1;
375
+ }