decorated-pi 0.3.0 → 0.4.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.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * LSP Server Manager — lifecycle, per-language instances, project-local binaries.
3
+ */
4
+ import { readFile } from "node:fs/promises";
5
+ import { resolve as resolvePath } from "node:path";
6
+ import { LspClient, LspClientStartError, filePathToUri } from "./client.js";
7
+ import { createChildProcessEnv } from "./env.js";
8
+ import {
9
+ detectLanguage,
10
+ findWorkspaceRoot,
11
+ getServerConfig,
12
+ languageIdForFile,
13
+ } from "./servers.js";
14
+
15
+ interface FileState {
16
+ client: LspClient;
17
+ language: string;
18
+ workspaceRoot: string;
19
+ rootUri: string;
20
+ command: string;
21
+ installHint: string;
22
+ }
23
+
24
+ interface ResolvedState {
25
+ abs: string;
26
+ uri: string;
27
+ state: FileState;
28
+ }
29
+
30
+ interface Startup {
31
+ cancelled: boolean;
32
+ promise: Promise<FileState | undefined>;
33
+ }
34
+
35
+ async function raceWithTimeout<T>(promise: Promise<T>, timeoutMs: number, file: string): Promise<T> {
36
+ return Promise.race([
37
+ promise,
38
+ new Promise<T>((_, reject) => setTimeout(() => reject(new LspToolError({ kind: "tool_timeout", file, message: `LSP request timed out after ${timeoutMs}ms` })), timeoutMs)),
39
+ ]);
40
+ }
41
+
42
+ export interface LspManagerOptions {
43
+ cwd?: () => string;
44
+ }
45
+
46
+ export interface ResolveFileStateOptions {
47
+ timeoutMs?: number;
48
+ }
49
+
50
+ /**
51
+ * Manages LSP server instances per (language, workspace_root).
52
+ *
53
+ * - Deduplicates: one client per language+workspace
54
+ * - Deduplicates startup: concurrent requests share the same startup promise
55
+ * - Caches failures to avoid repeated start attempts
56
+ */
57
+ export class LspServerManager {
58
+ cwd: string;
59
+ #clients = new Map<string, FileState>();
60
+ #failures = new Map<string, any>();
61
+ #starting = new Map<string, Startup>();
62
+
63
+ constructor(options: LspManagerOptions = {}) {
64
+ this.cwd = options.cwd?.() ?? process.cwd();
65
+ }
66
+
67
+ resolveAbs(file: string): string {
68
+ return file.startsWith("/") ? file : resolvePath(this.cwd, file);
69
+ }
70
+
71
+ async clearLanguageState(language?: string): Promise<void> {
72
+ const toStop = language
73
+ ? [...this.#clients.values()].filter((s) => s.language === language)
74
+ : [...this.#clients.values()];
75
+
76
+ for (const [, startup] of this.#starting) {
77
+ startup.cancelled = true;
78
+ }
79
+ this.#starting.clear();
80
+
81
+ await Promise.allSettled(toStop.map((s) => s.client.stop()));
82
+ for (const [key, state] of this.#clients) {
83
+ if (!language || state.language === language) this.#clients.delete(key);
84
+ }
85
+ if (!language) {
86
+ this.#failures.clear();
87
+ } else {
88
+ for (const [key, f] of this.#failures) {
89
+ if (f.language === language) this.#failures.delete(key);
90
+ }
91
+ }
92
+ }
93
+
94
+ async resolveFileState(
95
+ file: string,
96
+ options: ResolveFileStateOptions = {},
97
+ ): Promise<{ ok: true; result: ResolvedState } | { ok: false; error: any }> {
98
+ const abs = this.resolveAbs(file);
99
+ try {
100
+ const work = async () => {
101
+ const state = await this.#getFileState(abs, options);
102
+ if (!state) {
103
+ return { ok: false, error: { kind: "unsupported_language", file: abs, message: `No language server configured for ${abs}` } } as const;
104
+ }
105
+ const uri = await this.#openFile(state, abs);
106
+ return { ok: true, result: { abs, uri, state } } as const;
107
+ };
108
+ return options.timeoutMs != null
109
+ ? await raceWithTimeout(work(), options.timeoutMs, abs)
110
+ : await work();
111
+ } catch (error) {
112
+ if (error instanceof LspToolError) return { ok: false, error: error.details };
113
+ return {
114
+ ok: false,
115
+ error: { kind: "tool_execution_failed", file: abs, message: error instanceof Error ? error.message : String(error) },
116
+ };
117
+ }
118
+ }
119
+
120
+ async #getFileState(file: string, options: ResolveFileStateOptions = {}): Promise<FileState | undefined> {
121
+ const language = detectLanguage(file);
122
+ if (!language) return undefined;
123
+
124
+ const workspaceRoot = findWorkspaceRoot(file, this.cwd);
125
+ const key = `${language}\0${workspaceRoot}`;
126
+
127
+ const existing = this.#clients.get(key);
128
+ if (existing) return existing;
129
+
130
+ if (this.#failures.has(key)) throw new LspToolError(this.#failures.get(key));
131
+
132
+ const inFlight = this.#starting.get(key);
133
+ if (inFlight) return inFlight.promise;
134
+
135
+ const config = getServerConfig(language, workspaceRoot);
136
+ if (!config) return undefined;
137
+
138
+ const rootUri = filePathToUri(workspaceRoot);
139
+ const startup: Startup = { cancelled: false, promise: Promise.resolve(undefined) };
140
+
141
+ const startPromise = (async (): Promise<FileState | undefined> => {
142
+ const client = new LspClient({
143
+ command: config.command,
144
+ args: config.args,
145
+ root_uri: rootUri,
146
+ language_id_for_uri: languageIdForFile,
147
+ env: createChildProcessEnv(),
148
+ });
149
+
150
+ try {
151
+ await client.start(options.timeoutMs);
152
+ } catch (error) {
153
+ if (startup.cancelled) throw new LspStartupCancelledError(language, workspaceRoot);
154
+ const failure = toLspToolError(file, language, workspaceRoot, config.command, config.install_hint, error);
155
+ this.#failures.set(key, failure);
156
+ throw new LspToolError(failure);
157
+ }
158
+
159
+ if (startup.cancelled) {
160
+ await client.stop();
161
+ throw new LspStartupCancelledError(language, workspaceRoot);
162
+ }
163
+
164
+ const state: FileState = {
165
+ client, language, workspaceRoot, rootUri,
166
+ command: config.command, installHint: config.install_hint,
167
+ };
168
+ this.#clients.set(key, state);
169
+ this.#failures.delete(key);
170
+ return state;
171
+ })();
172
+
173
+ startup.promise = startPromise;
174
+ this.#starting.set(key, startup);
175
+
176
+ try {
177
+ return await startPromise;
178
+ } finally {
179
+ if (this.#starting.get(key) === startup) this.#starting.delete(key);
180
+ }
181
+ }
182
+
183
+ async #openFile(state: FileState, absPath: string): Promise<string> {
184
+ const text = await readFile(absPath, "utf-8");
185
+ const uri = filePathToUri(absPath);
186
+ await state.client.ensureDocumentOpen(uri, text);
187
+ return uri;
188
+ }
189
+ }
190
+
191
+ // ─── Error types ──────────────────────────────────────────────────────────
192
+
193
+ class LspStartupCancelledError extends Error {
194
+ constructor(language: string, workspaceRoot: string) {
195
+ super(`Startup cancelled for ${language} LSP in ${workspaceRoot}`);
196
+ this.name = "LspStartupCancelledError";
197
+ }
198
+ }
199
+
200
+ export class LspToolError extends Error {
201
+ constructor(public readonly details: LspToolErrorDetail) {
202
+ super(details.message);
203
+ this.name = "LspToolError";
204
+ }
205
+ }
206
+
207
+ export interface LspToolErrorDetail {
208
+ kind: string;
209
+ file?: string;
210
+ language?: string;
211
+ workspace_root?: string;
212
+ command?: string;
213
+ install_hint?: string;
214
+ message: string;
215
+ code?: string;
216
+ }
217
+
218
+ export function toLspToolError(
219
+ file: string, language: string, workspaceRoot: string | undefined,
220
+ command: string, installHint: string | undefined, error: unknown,
221
+ ): LspToolErrorDetail {
222
+ if (error instanceof LspToolError) return error.details;
223
+ if (error instanceof LspClientStartError) {
224
+ return {
225
+ kind: "server_start_failed", file, language, workspace_root: workspaceRoot,
226
+ command, install_hint: installHint, code: error.code,
227
+ message: error.code === "ENOENT" ? `command "${command}" not found` : error.message,
228
+ };
229
+ }
230
+ const err = error as Record<string, unknown> | undefined;
231
+ return {
232
+ kind: "tool_execution_failed", file, language, workspace_root: workspaceRoot,
233
+ command, install_hint: installHint,
234
+ message: error instanceof Error ? error.message : String(error),
235
+ code: err?.code as string | undefined,
236
+ };
237
+ }
238
+
239
+ export function formatToolError(details: LspToolErrorDetail): string {
240
+ if (details.kind === "unsupported_language" || details.kind === "tool_timeout") return details.message;
241
+ const lines = [
242
+ details.language ? `${details.language} LSP unavailable for ${details.file}` : `LSP request failed for ${details.file}`,
243
+ `Reason: ${details.message}`,
244
+ ];
245
+ if (details.command) lines.push(`Command: ${details.command}`);
246
+ if (details.workspace_root) lines.push(`Workspace: ${details.workspace_root}`);
247
+ if (details.install_hint) lines.push(`Hint: ${details.install_hint}`);
248
+ return lines.join("\n");
249
+ }
@@ -1,45 +1,6 @@
1
1
  /**
2
- * LSP System Prompt — injects LSP guidance into agent system prompt
2
+ * LSP System Prompt — LSP guidance via promptGuidelines (no injection).
3
3
  *
4
- * Based on @spences10/pi-lsp by Scott Spence
5
- * https://github.com/spences10/my-pi/tree/main/packages/pi-lsp (MIT License)
4
+ * System-level guidance is unnecessary; promptSnippet + promptGuidelines
5
+ * on each tool are sufficient for model tool selection and usage.
6
6
  */
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
- import { list_supported_languages } from "./servers.js";
9
-
10
- export const LSP_TOOL_NAMES = new Set([
11
- "lsp_diagnostics",
12
- "lsp_find_symbol",
13
- "lsp_hover",
14
- "lsp_definition",
15
- "lsp_references",
16
- "lsp_document_symbols",
17
- "lsp_rename",
18
- ]);
19
-
20
- const LSP_GUIDANCE_MARKER = "### LSP Guidance";
21
-
22
- export function setup_lsp_prompt(pi: ExtensionAPI) {
23
- pi.on("before_agent_start", async (event) => {
24
- if (!should_inject_lsp_prompt(event.systemPromptOptions)) return;
25
- if (event.systemPrompt.includes(LSP_GUIDANCE_MARKER)) return;
26
-
27
- const languages = list_supported_languages().join(", ");
28
- const guidance = [
29
- LSP_GUIDANCE_MARKER,
30
- "",
31
- `Consider using LSP tools when reading or editing source files. Supported languages: ${languages}. Use them when debugging language-server-supported errors, checking types, symbol definitions or API documentation from code, finding references more precisely than text search, and renaming symbols across a project.`,
32
- ].join("\n");
33
-
34
- return {
35
- systemPrompt: `${event.systemPrompt}\n\n${guidance}`,
36
- };
37
- });
38
- }
39
-
40
- export function should_inject_lsp_prompt(options?: {
41
- selectedTools?: string[];
42
- }): boolean {
43
- const tools = options?.selectedTools;
44
- return !tools || tools.some((tool) => LSP_TOOL_NAMES.has(tool));
45
- }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * JSON-RPC over stdio — minimal LSP transport layer.
3
+ *
4
+ * Handles message framing (Content-Length headers), request/response
5
+ * correlation with timeouts, and server-to-client notifications.
6
+ */
7
+ import { spawn, ChildProcess } from "node:child_process";
8
+ import { EventEmitter } from "node:events";
9
+
10
+ export interface JsonRpcRequest {
11
+ jsonrpc: "2.0";
12
+ id: number | string;
13
+ method: string;
14
+ params?: unknown;
15
+ }
16
+
17
+ export interface JsonRpcResponse {
18
+ jsonrpc: "2.0";
19
+ id: number | string;
20
+ result?: unknown;
21
+ error?: { code: number; message: string; data?: unknown };
22
+ }
23
+
24
+ export interface JsonRpcNotification {
25
+ jsonrpc: "2.0";
26
+ method: string;
27
+ params?: unknown;
28
+ }
29
+
30
+ export class LspProtocolError extends Error {
31
+ constructor(public readonly code: number, message: string) {
32
+ super(`LSP error ${code}: ${message}`);
33
+ this.name = "LspProtocolError";
34
+ }
35
+ }
36
+
37
+ interface PendingRequest {
38
+ resolve: (value: unknown) => void;
39
+ reject: (error: Error) => void;
40
+ timer: NodeJS.Timeout;
41
+ }
42
+
43
+ export class LspProtocol extends EventEmitter {
44
+ #proc: ChildProcess | null = null;
45
+ #buffer = Buffer.alloc(0);
46
+ #nextId = 1;
47
+ #pending = new Map<number, PendingRequest>();
48
+ #stopped = false;
49
+
50
+ get process(): ChildProcess | null {
51
+ return this.#proc;
52
+ }
53
+
54
+ /** Spawn the LSP server process. */
55
+ spawn(command: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> {
56
+ return new Promise((resolve, reject) => {
57
+ this.#stopped = false;
58
+ this.#proc = spawn(command, args, {
59
+ stdio: ["pipe", "pipe", "pipe"],
60
+ env,
61
+ });
62
+ const proc = this.#proc;
63
+ let settled = false;
64
+
65
+ proc.once("spawn", () => {
66
+ if (settled) return;
67
+ settled = true;
68
+ resolve();
69
+ });
70
+
71
+ proc.once("error", (err) => {
72
+ if (!settled) {
73
+ settled = true;
74
+ reject(Object.assign(new Error(`Failed to spawn ${command}: ${err.message}`), { code: (err as NodeJS.ErrnoException).code }));
75
+ }
76
+ });
77
+
78
+ proc.on("close", (code) => {
79
+ if (!this.#stopped) {
80
+ for (const p of this.#pending.values()) {
81
+ clearTimeout(p.timer);
82
+ p.reject(new Error(`LSP server exited (code ${code})`));
83
+ }
84
+ this.#pending.clear();
85
+ }
86
+ });
87
+
88
+ proc.stderr?.on("data", () => {}); // discard
89
+
90
+ proc.stdout?.on("data", (chunk: Buffer) => {
91
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
92
+ this.#drain();
93
+ });
94
+ });
95
+ }
96
+
97
+ /** Send a request and wait for response. */
98
+ request(method: string, params: unknown, timeoutMs = 30_000): Promise<unknown> {
99
+ return new Promise((resolve, reject) => {
100
+ const id = this.#nextId++;
101
+ const timer = setTimeout(() => {
102
+ this.#pending.delete(id);
103
+ reject(new Error(`LSP request "${method}" timed out after ${timeoutMs}ms`));
104
+ }, timeoutMs);
105
+
106
+ this.#pending.set(id, { resolve, reject, timer });
107
+ try {
108
+ this.#send({ jsonrpc: "2.0", id, method, params });
109
+ } catch (err) {
110
+ clearTimeout(timer);
111
+ this.#pending.delete(id);
112
+ reject(err);
113
+ }
114
+ });
115
+ }
116
+
117
+ /** Send a fire-and-forget notification. */
118
+ notify(method: string, params: unknown): void {
119
+ this.#send({ jsonrpc: "2.0", method, params });
120
+ }
121
+
122
+ /** Gracefully shut down the server. */
123
+ async shutdown(timeoutMs = 1000): Promise<void> {
124
+ this.#stopped = true;
125
+ try {
126
+ await this.request("shutdown", null, timeoutMs);
127
+ } catch {
128
+ // server already dead
129
+ }
130
+ this.notify("exit", null);
131
+ this.#proc?.kill();
132
+ this.#proc = null;
133
+ }
134
+
135
+ /** Force kill. */
136
+ kill(): void {
137
+ this.#stopped = true;
138
+ this.#proc?.kill();
139
+ this.#proc = null;
140
+ for (const p of this.#pending.values()) {
141
+ clearTimeout(p.timer);
142
+ p.reject(new Error("LSP protocol stopped"));
143
+ }
144
+ this.#pending.clear();
145
+ }
146
+
147
+ #send(message: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): void {
148
+ if (!this.#proc?.stdin?.writable) {
149
+ throw new Error("LSP server not connected");
150
+ }
151
+ const body = Buffer.from(JSON.stringify(message), "utf8");
152
+ const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii");
153
+ this.#proc.stdin.write(Buffer.concat([header, body]));
154
+ }
155
+
156
+ #drain(): void {
157
+ while (true) {
158
+ const headerEnd = this.#buffer.indexOf("\r\n\r\n");
159
+ if (headerEnd === -1) return;
160
+
161
+ const header = this.#buffer.subarray(0, headerEnd).toString("ascii");
162
+ const match = header.match(/Content-Length:\s*(\d+)/i);
163
+ if (!match) {
164
+ this.#buffer = this.#buffer.subarray(headerEnd + 4);
165
+ continue;
166
+ }
167
+
168
+ const length = Number(match[1]);
169
+ const bodyStart = headerEnd + 4;
170
+ if (this.#buffer.length < bodyStart + length) return;
171
+
172
+ const body = this.#buffer.subarray(bodyStart, bodyStart + length);
173
+ this.#buffer = this.#buffer.subarray(bodyStart + length);
174
+
175
+ try {
176
+ this.#handle(JSON.parse(body.toString("utf8")));
177
+ } catch (err) {
178
+ // ignore malformed messages
179
+ }
180
+ }
181
+ }
182
+
183
+ #handle(msg: Record<string, unknown>): void {
184
+ // Response to our request
185
+ if (msg.id != null) {
186
+ const numId = typeof msg.id === "number"
187
+ ? msg.id
188
+ : typeof msg.id === "string" && /^-?\d+$/.test(msg.id)
189
+ ? Number(msg.id)
190
+ : null;
191
+
192
+ if (numId != null && this.#pending.has(numId)) {
193
+ const p = this.#pending.get(numId)!;
194
+ this.#pending.delete(numId);
195
+ clearTimeout(p.timer);
196
+
197
+ if (msg.error) {
198
+ const e = msg.error as { code: number; message: string };
199
+ p.reject(new LspProtocolError(e.code, e.message));
200
+ } else {
201
+ p.resolve(msg.result);
202
+ }
203
+ return;
204
+ }
205
+
206
+ // Server-to-client request — respond with null
207
+ if (msg.method != null && msg.id != null) {
208
+ const resp: JsonRpcResponse = { jsonrpc: "2.0", id: msg.id as number | string, result: null };
209
+ this.#send(resp);
210
+ }
211
+ return;
212
+ }
213
+
214
+ // Notification from server
215
+ if (msg.method === "textDocument/publishDiagnostics" && msg.params) {
216
+ this.emit("diagnostics", msg.params);
217
+ }
218
+ }
219
+ }