@workweave/router 0.2.10 → 0.2.12

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.
Files changed (47) hide show
  1. package/README.md +40 -14
  2. package/cc-statusline.sh +274 -23
  3. package/codex-skills/fm/SKILL.md +15 -0
  4. package/codex-skills/fm/scripts/emit.sh +8 -0
  5. package/codex-skills/force-model/SKILL.md +15 -0
  6. package/codex-skills/force-model/scripts/emit.sh +9 -0
  7. package/codex-skills/rf/SKILL.md +15 -0
  8. package/codex-skills/rf/scripts/emit.sh +7 -0
  9. package/codex-skills/router-feedback/SKILL.md +15 -0
  10. package/codex-skills/router-feedback/scripts/emit.sh +9 -0
  11. package/codex-skills/router-models/SKILL.md +51 -0
  12. package/codex-skills/router-off/SKILL.md +22 -0
  13. package/codex-skills/router-on/SKILL.md +22 -0
  14. package/codex-skills/router-status/SKILL.md +19 -0
  15. package/codex-skills/ufm/SKILL.md +14 -0
  16. package/codex-skills/ufm/scripts/emit.sh +3 -0
  17. package/codex-skills/unforce-model/SKILL.md +14 -0
  18. package/codex-skills/unforce-model/scripts/emit.sh +4 -0
  19. package/codex-status.sh +312 -0
  20. package/commands/beta.md +5 -0
  21. package/commands/models.md +46 -0
  22. package/commands/router-models.md +46 -0
  23. package/directives.tsv +13 -0
  24. package/install.sh +1849 -263
  25. package/package.json +7 -1
  26. package/pi-router/README.md +39 -7
  27. package/pi-router/skills/install-lsps/SKILL.md +75 -0
  28. package/pi-router/skills/lsp-guide/SKILL.md +63 -0
  29. package/pi-router/src/beta.ts +21 -0
  30. package/pi-router/src/compaction.ts +46 -8
  31. package/pi-router/src/config.ts +34 -5
  32. package/pi-router/src/context-window.ts +12 -0
  33. package/pi-router/src/dispatch.ts +35 -2
  34. package/pi-router/src/index.ts +12 -1
  35. package/pi-router/src/lsp-broker.ts +255 -0
  36. package/pi-router/src/lsp-client.ts +435 -0
  37. package/pi-router/src/lsp-format.ts +230 -0
  38. package/pi-router/src/lsp-install.ts +215 -0
  39. package/pi-router/src/lsp-protocol.ts +128 -0
  40. package/pi-router/src/lsp-servers.ts +361 -0
  41. package/pi-router/src/lsp.ts +529 -0
  42. package/pi-router/src/pricing.generated.ts +77 -70
  43. package/pi-router/src/provider.ts +15 -2
  44. package/pi-router/src/routed-model.ts +17 -0
  45. package/pi-router/src/savings.ts +1 -1
  46. package/registry.sh +102 -0
  47. package/uninstall.sh +210 -31
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Subagent access to the parent's language servers.
3
+ *
4
+ * Dispatch children are the heaviest potential LSP users and the shortest-lived
5
+ * processes, so letting each spawn its own gopls would pay the indexing cost N
6
+ * times over and then throw the warm index away. Instead the parent exposes its
7
+ * pool over a local socket and children forward queries to it. The socket
8
+ * carries the same Content-Length framing as LSP stdio.
9
+ *
10
+ * The broker takes the orchestration core as a function, so it never reaches
11
+ * into the pool itself and is testable against a fake runner.
12
+ */
13
+
14
+ import { randomBytes, timingSafeEqual } from "node:crypto";
15
+ import * as fs from "node:fs";
16
+ import * as net from "node:net";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { createFrameParser, encodeFrame, type JsonRpcMessage, type LspOperationParams } from "./lsp-protocol.js";
20
+
21
+ const HELLO = "broker/hello";
22
+ const EXECUTE = "lsp/execute";
23
+ const CANCEL = "$/cancel";
24
+ const CONNECTION_LOST = "LSP broker connection lost";
25
+
26
+ export type LspRunner = (params: LspOperationParams, cwd: string, signal?: AbortSignal) => Promise<string>;
27
+
28
+ export interface LspBrokerHandle {
29
+ socketPath: string;
30
+ token: string;
31
+ close(): Promise<void>;
32
+ /** Synchronous socket removal for `process.on("exit")`, which cannot await `close()`. */
33
+ removeSocket(): void;
34
+ }
35
+
36
+ export interface BrokerDeps {
37
+ makeSocketPath?(): { socketPath: string; cleanup(): void };
38
+ makeToken?(): string;
39
+ }
40
+
41
+ function tokensMatch(expected: string, received: unknown): boolean {
42
+ if (typeof received !== "string" || received.length !== expected.length) return false;
43
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
44
+ }
45
+
46
+ function defaultSocketPath(): { socketPath: string; cleanup(): void } {
47
+ if (process.platform === "win32") {
48
+ // Named pipes live in the kernel namespace: nothing to create or unlink.
49
+ return { socketPath: `\\\\.\\pipe\\weave-pi-lsp-${process.pid}-${randomBytes(6).toString("hex")}`, cleanup: () => undefined };
50
+ }
51
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "weave-pi-lsp-"));
52
+ fs.chmodSync(dir, 0o700);
53
+ const socketPath = path.join(dir, "lsp.sock");
54
+ return {
55
+ socketPath,
56
+ cleanup: () => {
57
+ try {
58
+ fs.rmSync(dir, { recursive: true, force: true });
59
+ } catch {
60
+ /* best effort */
61
+ }
62
+ },
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Listen for child connections. Started on demand (right before a fan-out), so
68
+ * a session that never dispatches never opens a socket.
69
+ */
70
+ export async function startLspBroker(runner: LspRunner, deps: BrokerDeps = {}): Promise<LspBrokerHandle> {
71
+ const token = (deps.makeToken ?? (() => randomBytes(32).toString("hex")))();
72
+ const { socketPath, cleanup } = (deps.makeSocketPath ?? defaultSocketPath)();
73
+ const connections = new Set<net.Socket>();
74
+
75
+ const server = net.createServer((socket) => {
76
+ connections.add(socket);
77
+ socket.on("close", () => connections.delete(socket));
78
+ socket.on("error", () => socket.destroy());
79
+
80
+ let authenticated = false;
81
+ const inflight = new Map<number, AbortController>();
82
+ const reply = (message: JsonRpcMessage): void => {
83
+ if (!socket.destroyed) socket.write(encodeFrame(message));
84
+ };
85
+
86
+ const parser = createFrameParser(
87
+ (message) => {
88
+ if (!authenticated) {
89
+ // Children share the user's privileges, so the token is not a
90
+ // privilege boundary — it keeps unrelated local processes out.
91
+ if (message.method !== HELLO || !tokensMatch(token, (message.params as { token?: unknown } | undefined)?.token)) {
92
+ socket.destroy();
93
+ return;
94
+ }
95
+ authenticated = true;
96
+ return;
97
+ }
98
+
99
+ if (message.method === CANCEL) {
100
+ const id = (message.params as { id?: unknown } | undefined)?.id;
101
+ if (typeof id === "number") inflight.get(id)?.abort();
102
+ return;
103
+ }
104
+ if (message.method !== EXECUTE || typeof message.id !== "number") return;
105
+
106
+ const id = message.id;
107
+ const payload = message.params as { params?: LspOperationParams; cwd?: string } | undefined;
108
+ if (!payload?.params) {
109
+ reply({ id, error: { code: -32602, message: "missing lsp params" } });
110
+ return;
111
+ }
112
+ const controller = new AbortController();
113
+ inflight.set(id, controller);
114
+ runner(payload.params, payload.cwd || process.cwd(), controller.signal)
115
+ .then((text) => reply({ id, result: { text } }))
116
+ .catch((error: Error) => reply({ id, error: { code: -32000, message: error.message } }))
117
+ .finally(() => inflight.delete(id));
118
+ },
119
+ () => socket.destroy(),
120
+ );
121
+
122
+ socket.on("data", (chunk: Buffer) => parser.push(chunk));
123
+ });
124
+
125
+ await new Promise<void>((resolve, reject) => {
126
+ const onError = (error: Error): void => reject(error);
127
+ server.once("error", onError);
128
+ server.listen(socketPath, () => {
129
+ server.removeListener("error", onError);
130
+ resolve();
131
+ });
132
+ });
133
+ // The listener must never be the reason the parent process stays alive.
134
+ server.unref();
135
+
136
+ return {
137
+ socketPath,
138
+ token,
139
+ removeSocket: cleanup,
140
+ close(): Promise<void> {
141
+ for (const socket of connections) socket.destroy();
142
+ connections.clear();
143
+ return new Promise<void>((resolve) => {
144
+ server.close(() => {
145
+ cleanup();
146
+ resolve();
147
+ });
148
+ });
149
+ },
150
+ };
151
+ }
152
+
153
+ type ConnectFn = (socketPath: string) => net.Socket;
154
+
155
+ interface BrokerPending {
156
+ resolve(text: string): void;
157
+ reject(error: Error): void;
158
+ }
159
+
160
+ /** The child half: one lazy connection, shared by every `lsp` call in that process. */
161
+ export class LspBrokerClient {
162
+ private nextId = 1;
163
+ private readonly pending = new Map<number, BrokerPending>();
164
+ private connection?: Promise<net.Socket>;
165
+
166
+ constructor(
167
+ private readonly socketPath: string,
168
+ private readonly token: string,
169
+ private readonly timeoutMs: number,
170
+ private readonly connectFn: ConnectFn = (target) => net.connect(target),
171
+ ) {}
172
+
173
+ async execute(params: LspOperationParams, cwd: string, signal?: AbortSignal): Promise<string> {
174
+ const socket = await this.connect();
175
+ const id = this.nextId++;
176
+
177
+ return new Promise<string>((resolve, reject) => {
178
+ let settled = false;
179
+ const cleanup = (): void => {
180
+ settled = true;
181
+ clearTimeout(timer);
182
+ signal?.removeEventListener("abort", onAbort);
183
+ this.pending.delete(id);
184
+ };
185
+ const onAbort = (): void => {
186
+ if (settled) return;
187
+ cleanup();
188
+ if (!socket.destroyed) socket.write(encodeFrame({ method: CANCEL, params: { id } }));
189
+ reject(new Error("aborted"));
190
+ };
191
+ const timer = setTimeout(() => {
192
+ if (settled) return;
193
+ cleanup();
194
+ if (!socket.destroyed) socket.write(encodeFrame({ method: CANCEL, params: { id } }));
195
+ reject(new Error("timed out waiting for the LSP broker"));
196
+ }, this.timeoutMs);
197
+
198
+ this.pending.set(id, {
199
+ resolve: (text) => {
200
+ if (settled) return;
201
+ cleanup();
202
+ resolve(text);
203
+ },
204
+ reject: (error) => {
205
+ if (settled) return;
206
+ cleanup();
207
+ reject(error);
208
+ },
209
+ });
210
+ signal?.addEventListener("abort", onAbort, { once: true });
211
+ socket.write(encodeFrame({ id, method: EXECUTE, params: { params, cwd } }));
212
+ });
213
+ }
214
+
215
+ close(): void {
216
+ const pending = this.connection;
217
+ this.connection = undefined;
218
+ void pending?.then((socket) => socket.destroy()).catch(() => undefined);
219
+ }
220
+
221
+ private connect(): Promise<net.Socket> {
222
+ if (this.connection) return this.connection;
223
+ this.connection = new Promise<net.Socket>((resolve, reject) => {
224
+ const socket = this.connectFn(this.socketPath);
225
+ const parser = createFrameParser(
226
+ (message) => {
227
+ if (typeof message.id !== "number") return;
228
+ const entry = this.pending.get(message.id);
229
+ if (!entry) return;
230
+ if (message.error) entry.reject(new Error(message.error.message));
231
+ else entry.resolve(((message.result as { text?: string } | undefined)?.text) ?? "");
232
+ },
233
+ () => socket.destroy(),
234
+ );
235
+
236
+ const fail = (error: Error): void => {
237
+ // Drop the cached connection so a later call can retry a live parent.
238
+ this.connection = undefined;
239
+ for (const entry of [...this.pending.values()]) entry.reject(error);
240
+ this.pending.clear();
241
+ reject(error);
242
+ };
243
+
244
+ socket.on("data", (chunk: Buffer) => parser.push(chunk));
245
+ socket.on("error", (error: Error) => fail(error));
246
+ socket.on("close", () => fail(new Error(CONNECTION_LOST)));
247
+ socket.on("connect", () => {
248
+ socket.write(encodeFrame({ method: HELLO, params: { token: this.token } }));
249
+ resolve(socket);
250
+ });
251
+ socket.unref?.();
252
+ });
253
+ return this.connection;
254
+ }
255
+ }
@@ -0,0 +1,435 @@
1
+ /**
2
+ * One language-server connection.
3
+ *
4
+ * The server is reached through an `LspTransport` rather than a ChildProcess so
5
+ * the protocol logic — handshake, document versioning, request correlation,
6
+ * diagnostics bookkeeping — is exercised in tests without spawning anything.
7
+ */
8
+
9
+ import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process";
10
+ import * as path from "node:path";
11
+ import { createFrameParser, encodeFrame, pathToUri, type JsonRpcMessage } from "./lsp-protocol.js";
12
+ import type { LspDiagnostic } from "./lsp-format.js";
13
+
14
+ const STDERR_RING_BYTES = 4096;
15
+ const SHUTDOWN_BUDGET_MS = 1000;
16
+ const SIGKILL_GRACE_MS = 5000;
17
+ const METHOD_NOT_FOUND = -32601;
18
+
19
+ export interface TransportClose {
20
+ code: number | null;
21
+ stderr: string;
22
+ }
23
+
24
+ export interface LspTransport {
25
+ send(data: Buffer): void;
26
+ onMessage(handler: (message: JsonRpcMessage) => void): void;
27
+ onClose(handler: (info: TransportClose) => void): void;
28
+ /** Graceful stop: SIGTERM, then SIGKILL if the peer has not exited. */
29
+ kill(): void;
30
+ /** Synchronous SIGKILL for process-exit paths that cannot await anything. */
31
+ killNow(): void;
32
+ }
33
+
34
+ export type SpawnFn = (command: string, args: string[], options: SpawnOptions) => ChildProcess;
35
+
36
+ export function spawnTransport(command: string, args: string[], cwd: string, spawnFn: SpawnFn = nodeSpawn): LspTransport {
37
+ const child = spawnFn(command, args, { cwd, shell: false, detached: false, stdio: ["pipe", "pipe", "pipe"] });
38
+ const messageHandlers: Array<(message: JsonRpcMessage) => void> = [];
39
+ const closeHandlers: Array<(info: TransportClose) => void> = [];
40
+ let stderrTail = "";
41
+ let settled = false;
42
+
43
+ const appendStderr = (text: string): void => {
44
+ stderrTail = (stderrTail + text).slice(-STDERR_RING_BYTES);
45
+ };
46
+ const emitClose = (code: number | null): void => {
47
+ if (settled) return;
48
+ settled = true;
49
+ for (const handler of closeHandlers) handler({ code, stderr: stderrTail });
50
+ };
51
+
52
+ const parser = createFrameParser(
53
+ (message) => {
54
+ for (const handler of messageHandlers) handler(message);
55
+ },
56
+ (error) => {
57
+ appendStderr(`\n${error.message}`);
58
+ child.kill("SIGKILL");
59
+ },
60
+ );
61
+
62
+ child.stdout?.on("data", (chunk: Buffer) => parser.push(chunk));
63
+ // Server stderr is diagnostic noise (gopls indexing chatter); it feeds error
64
+ // messages only and is never streamed to the model.
65
+ child.stderr?.on("data", (chunk: Buffer) => appendStderr(chunk.toString("utf8")));
66
+ child.on("close", (code) => emitClose(code));
67
+ child.on("error", (error: Error) => {
68
+ appendStderr(error.message);
69
+ emitClose(null);
70
+ });
71
+
72
+ return {
73
+ send(data) {
74
+ try {
75
+ child.stdin?.write(data);
76
+ } catch {
77
+ /* peer gone — the close handler rejects everything pending */
78
+ }
79
+ },
80
+ onMessage(handler) {
81
+ messageHandlers.push(handler);
82
+ },
83
+ onClose(handler) {
84
+ closeHandlers.push(handler);
85
+ },
86
+ kill() {
87
+ child.kill("SIGTERM");
88
+ // `child.killed` flips the instant SIGTERM is *sent*, so escalate on
89
+ // whether the process actually exited.
90
+ const timer = setTimeout(() => {
91
+ if (!settled) child.kill("SIGKILL");
92
+ }, SIGKILL_GRACE_MS);
93
+ timer.unref?.();
94
+ },
95
+ killNow() {
96
+ try {
97
+ child.kill("SIGKILL");
98
+ } catch {
99
+ /* already gone */
100
+ }
101
+ },
102
+ };
103
+ }
104
+
105
+ export interface DocumentState {
106
+ version: number;
107
+ text: string;
108
+ }
109
+
110
+ export type DocumentSyncPlan = { action: "none" } | { action: "open" | "change"; version: number };
111
+
112
+ /** didOpen at v1, didChange at v+1, nothing when the buffer is unchanged. */
113
+ export function planDocumentSync(state: DocumentState | undefined, text: string): DocumentSyncPlan {
114
+ if (!state) return { action: "open", version: 1 };
115
+ if (state.text === text) return { action: "none" };
116
+ return { action: "change", version: state.version + 1 };
117
+ }
118
+
119
+ /** Reject when `signal` aborts, leaving `promise` running (a shared warmup must survive one caller leaving). */
120
+ export function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
121
+ if (!signal) return promise;
122
+ if (signal.aborted) return Promise.reject(new Error("aborted"));
123
+ return new Promise<T>((resolve, reject) => {
124
+ const onAbort = (): void => reject(new Error("aborted"));
125
+ signal.addEventListener("abort", onAbort, { once: true });
126
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
127
+ });
128
+ }
129
+
130
+ function lastLine(text: string): string {
131
+ const lines = text
132
+ .split("\n")
133
+ .map((line) => line.trim())
134
+ .filter(Boolean);
135
+ return lines.length > 0 ? lines[lines.length - 1] : "";
136
+ }
137
+
138
+ interface Pending {
139
+ resolve(value: unknown): void;
140
+ reject(error: Error): void;
141
+ settle(): void;
142
+ }
143
+
144
+ interface DiagnosticsEntry {
145
+ generation: number;
146
+ items: LspDiagnostic[];
147
+ }
148
+
149
+ interface DiagnosticsWaiter {
150
+ uri: string;
151
+ minGeneration: number;
152
+ deliver(items: LspDiagnostic[] | undefined): void;
153
+ }
154
+
155
+ export interface LspClientOptions {
156
+ requestTimeoutMs: number;
157
+ warmupTimeoutMs: number;
158
+ }
159
+
160
+ export class LspClient {
161
+ private nextId = 1;
162
+ private readonly pending = new Map<number, Pending>();
163
+ private readonly documents = new Map<string, DocumentState>();
164
+ private readonly diagnostics = new Map<string, DiagnosticsEntry>();
165
+ private diagnosticsWaiters: DiagnosticsWaiter[] = [];
166
+ private syncChain: Promise<void> = Promise.resolve();
167
+ private initializePromise?: Promise<void>;
168
+ private generation = 0;
169
+ private warmedUp = false;
170
+ private disposed = false;
171
+ private closeInfo?: TransportClose;
172
+
173
+ constructor(
174
+ readonly root: string,
175
+ private readonly transport: LspTransport,
176
+ private readonly options: LspClientOptions,
177
+ ) {
178
+ transport.onMessage((message) => this.handleMessage(message));
179
+ transport.onClose((info) => this.handleClose(info));
180
+ }
181
+
182
+ get dead(): boolean {
183
+ return this.closeInfo !== undefined || this.disposed;
184
+ }
185
+
186
+ /** True while any request or diagnostics wait is in flight — the pool must not idle-dispose a busy client. */
187
+ get busy(): boolean {
188
+ return this.pending.size > 0 || this.diagnosticsWaiters.length > 0;
189
+ }
190
+
191
+ /** Shared across callers and never cancelled by one of them leaving: a rejected handshake poisons the pool entry. */
192
+ initialize(): Promise<void> {
193
+ if (!this.initializePromise) this.initializePromise = this.performInitialize();
194
+ return this.initializePromise;
195
+ }
196
+
197
+ private async performInitialize(): Promise<void> {
198
+ await this.request(
199
+ "initialize",
200
+ {
201
+ processId: process.pid,
202
+ rootUri: pathToUri(this.root),
203
+ rootPath: this.root,
204
+ workspaceFolders: [{ uri: pathToUri(this.root), name: path.basename(this.root) }],
205
+ capabilities: {
206
+ textDocument: {
207
+ synchronization: { didSave: false, dynamicRegistration: false },
208
+ definition: { linkSupport: true },
209
+ references: {},
210
+ hover: { contentFormat: ["markdown", "plaintext"] },
211
+ documentSymbol: { hierarchicalDocumentSymbolSupport: true },
212
+ publishDiagnostics: { relatedInformation: false },
213
+ },
214
+ workspace: { workspaceFolders: true },
215
+ // Declaring workDoneProgress routes indexing chatter into $/progress
216
+ // notifications we drop, instead of showMessage traffic. We
217
+ // deliberately do NOT declare workspace.configuration — but we still
218
+ // answer it if the server asks anyway (see handleServerRequest).
219
+ window: { workDoneProgress: true },
220
+ },
221
+ },
222
+ { timeoutMs: this.options.warmupTimeoutMs },
223
+ );
224
+ this.notify("initialized", {});
225
+ }
226
+
227
+ /** Returns the sync action taken, so callers can tell a real didOpen/didChange from a no-op. */
228
+ async ensureDocument(uri: string, text: string, languageId: string): Promise<DocumentSyncPlan["action"]> {
229
+ // Tool executionMode is parallel and broker requests interleave with the
230
+ // main loop's, so version numbering is serialized on one chain.
231
+ const run = this.syncChain.then(() => {
232
+ const plan = planDocumentSync(this.documents.get(uri), text);
233
+ if (plan.action === "none") return plan.action;
234
+ if (plan.action === "open") {
235
+ this.notify("textDocument/didOpen", { textDocument: { uri, languageId, version: plan.version, text } });
236
+ } else {
237
+ this.notify("textDocument/didChange", {
238
+ textDocument: { uri, version: plan.version },
239
+ contentChanges: [{ text }],
240
+ });
241
+ }
242
+ this.documents.set(uri, { version: plan.version, text });
243
+ return plan.action;
244
+ });
245
+ this.syncChain = run.then(
246
+ () => undefined,
247
+ () => undefined,
248
+ );
249
+ return run;
250
+ }
251
+
252
+ diagnosticsGeneration(): number {
253
+ return this.generation;
254
+ }
255
+
256
+ async waitForDiagnostics(
257
+ uri: string,
258
+ sinceGeneration: number,
259
+ timeoutMs: number,
260
+ signal?: AbortSignal,
261
+ ): Promise<{ items: LspDiagnostic[]; fresh: boolean }> {
262
+ const current = this.diagnostics.get(uri);
263
+ if (current && current.generation > sinceGeneration) return { items: current.items, fresh: true };
264
+
265
+ const published = await new Promise<LspDiagnostic[] | undefined>((resolve) => {
266
+ let settled = false;
267
+ const waiter: DiagnosticsWaiter = {
268
+ uri,
269
+ minGeneration: sinceGeneration,
270
+ deliver: (items) => {
271
+ if (settled) return;
272
+ settled = true;
273
+ clearTimeout(timer);
274
+ signal?.removeEventListener("abort", onAbort);
275
+ this.diagnosticsWaiters = this.diagnosticsWaiters.filter((entry) => entry !== waiter);
276
+ resolve(items);
277
+ },
278
+ };
279
+ const onAbort = (): void => waiter.deliver(undefined);
280
+ const timer = setTimeout(() => waiter.deliver(undefined), timeoutMs);
281
+ signal?.addEventListener("abort", onAbort, { once: true });
282
+ this.diagnosticsWaiters.push(waiter);
283
+ });
284
+
285
+ if (published) return { items: published, fresh: true };
286
+ return { items: this.diagnostics.get(uri)?.items ?? [], fresh: false };
287
+ }
288
+
289
+ request(method: string, params: unknown, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<unknown> {
290
+ if (this.closeInfo) return Promise.reject(new Error(this.closeMessage()));
291
+ const id = this.nextId++;
292
+ // The first real request lands while the server is still indexing, so it
293
+ // inherits the warmup budget rather than the steady-state one.
294
+ const timeoutMs = options.timeoutMs ?? (this.warmedUp ? this.options.requestTimeoutMs : this.options.warmupTimeoutMs);
295
+
296
+ return new Promise<unknown>((resolve, reject) => {
297
+ let settled = false;
298
+ const cleanup = (): void => {
299
+ settled = true;
300
+ clearTimeout(timer);
301
+ options.signal?.removeEventListener("abort", onAbort);
302
+ this.pending.delete(id);
303
+ };
304
+ const entry: Pending = {
305
+ resolve: (value) => {
306
+ if (settled) return;
307
+ cleanup();
308
+ this.warmedUp = true;
309
+ resolve(value);
310
+ },
311
+ reject: (error) => {
312
+ if (settled) return;
313
+ cleanup();
314
+ reject(error);
315
+ },
316
+ settle: cleanup,
317
+ };
318
+ const onAbort = (): void => {
319
+ if (settled) return;
320
+ cleanup();
321
+ this.notify("$/cancelRequest", { id });
322
+ reject(new Error("aborted"));
323
+ };
324
+ const timer = setTimeout(() => {
325
+ if (settled) return;
326
+ cleanup();
327
+ // Keep the server alive: it is probably still indexing, and the next
328
+ // request against a warm index usually succeeds.
329
+ this.notify("$/cancelRequest", { id });
330
+ reject(new Error(`${method} timed out after ${Math.round(timeoutMs / 1000)}s (the language server may still be indexing — retry shortly)`));
331
+ }, timeoutMs);
332
+
333
+ options.signal?.addEventListener("abort", onAbort, { once: true });
334
+ this.pending.set(id, entry);
335
+ this.transport.send(encodeFrame({ jsonrpc: "2.0", id, method, params }));
336
+ });
337
+ }
338
+
339
+ notify(method: string, params: unknown): void {
340
+ if (this.closeInfo) return;
341
+ this.transport.send(encodeFrame({ jsonrpc: "2.0", method, params }));
342
+ }
343
+
344
+ async dispose(): Promise<void> {
345
+ if (this.disposed) return;
346
+ this.disposed = true;
347
+ try {
348
+ await this.request("shutdown", null, { timeoutMs: SHUTDOWN_BUDGET_MS });
349
+ this.notify("exit", undefined);
350
+ } catch {
351
+ /* an unresponsive server just gets killed */
352
+ }
353
+ this.transport.kill();
354
+ }
355
+
356
+ killNow(): void {
357
+ this.disposed = true;
358
+ this.transport.killNow();
359
+ }
360
+
361
+ private closeMessage(): string {
362
+ const detail = lastLine(this.closeInfo?.stderr ?? "");
363
+ const code = this.closeInfo?.code;
364
+ return `language server exited${code === null || code === undefined ? "" : ` (code ${code})`}${detail ? `: ${detail}` : ""}`;
365
+ }
366
+
367
+ private handleClose(info: TransportClose): void {
368
+ this.closeInfo = info;
369
+ const error = new Error(this.closeMessage());
370
+ for (const entry of [...this.pending.values()]) entry.reject(error);
371
+ this.pending.clear();
372
+ for (const waiter of [...this.diagnosticsWaiters]) waiter.deliver(undefined);
373
+ }
374
+
375
+ private handleMessage(message: JsonRpcMessage): void {
376
+ if (message.method !== undefined) {
377
+ if (message.id === undefined || message.id === null) this.handleNotification(message);
378
+ else this.handleServerRequest(message);
379
+ return;
380
+ }
381
+ if (typeof message.id !== "number") return;
382
+ const entry = this.pending.get(message.id);
383
+ if (!entry) return;
384
+ if (message.error) entry.reject(new Error(message.error.message || "language server error"));
385
+ else entry.resolve(message.result);
386
+ }
387
+
388
+ private handleNotification(message: JsonRpcMessage): void {
389
+ if (message.method !== "textDocument/publishDiagnostics") return;
390
+ const params = message.params as { uri?: string; diagnostics?: LspDiagnostic[] } | undefined;
391
+ if (!params?.uri) return;
392
+ this.generation += 1;
393
+ const entry: DiagnosticsEntry = { generation: this.generation, items: params.diagnostics ?? [] };
394
+ this.diagnostics.set(params.uri, entry);
395
+ for (const waiter of [...this.diagnosticsWaiters]) {
396
+ if (waiter.uri === params.uri && entry.generation > waiter.minGeneration) waiter.deliver(entry.items);
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Every server request gets an answer. A server left waiting on a reply it
402
+ * asked for will stall the whole session, so unknown methods are refused
403
+ * explicitly rather than ignored.
404
+ */
405
+ private handleServerRequest(message: JsonRpcMessage): void {
406
+ const reply = (result: unknown): void =>
407
+ this.transport.send(encodeFrame({ jsonrpc: "2.0", id: message.id, result }));
408
+
409
+ switch (message.method) {
410
+ case "workspace/configuration": {
411
+ const items = (message.params as { items?: unknown[] } | undefined)?.items ?? [];
412
+ reply(items.map(() => null));
413
+ return;
414
+ }
415
+ case "client/registerCapability":
416
+ case "client/unregisterCapability":
417
+ case "window/workDoneProgress/create":
418
+ case "window/showMessageRequest":
419
+ reply(null);
420
+ return;
421
+ case "workspace/applyEdit":
422
+ // This subsystem is read-only; refusing is the honest answer.
423
+ reply({ applied: false });
424
+ return;
425
+ default:
426
+ this.transport.send(
427
+ encodeFrame({
428
+ jsonrpc: "2.0",
429
+ id: message.id,
430
+ error: { code: METHOD_NOT_FOUND, message: `unsupported request: ${message.method}` },
431
+ }),
432
+ );
433
+ }
434
+ }
435
+ }