@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,529 @@
1
+ /**
2
+ * `lsp` — code intelligence for pi, which has none natively.
3
+ *
4
+ * One composite call replaces a multi-turn read/grep loop, which saves tokens
5
+ * twice: fewer round trips, and less context growth before compaction.
6
+ *
7
+ * The same tool is registered in two roles. In the main process it drives a
8
+ * pool of language servers directly. In a dispatch child it forwards to the
9
+ * parent's pool over the broker socket, so a fan-out shares one warm server
10
+ * instead of cold-starting one per child. `runLspOperation` is the single
11
+ * orchestration core behind both, and is what the broker serves.
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
17
+ import { StringEnum } from "@mariozechner/pi-ai";
18
+ import { Text } from "@mariozechner/pi-tui";
19
+ import { Type } from "typebox";
20
+ import {
21
+ getLspPrefsPath,
22
+ isSubagent,
23
+ LSP_BROKER_ENV,
24
+ LSP_BROKER_TOKEN_ENV,
25
+ LSP_DIAGNOSTICS_WAIT_MS,
26
+ LSP_IDLE_MS,
27
+ LSP_MAX_REFERENCES,
28
+ LSP_MAX_SERVERS,
29
+ LSP_REQUEST_TIMEOUT_MS,
30
+ LSP_WARMUP_TIMEOUT_MS,
31
+ } from "./config.js";
32
+ import { LspBrokerClient, startLspBroker, type LspBrokerHandle } from "./lsp-broker.js";
33
+ import {
34
+ buildLspOffer,
35
+ detectWorkspaceServers,
36
+ installServer,
37
+ loadDismissedLanguages,
38
+ saveDismissedLanguages,
39
+ type InstallResult,
40
+ } from "./lsp-install.js";
41
+ import type { LspClient } from "./lsp-client.js";
42
+ import {
43
+ createLineReader,
44
+ displayPath,
45
+ formatDiagnostics,
46
+ formatHover,
47
+ formatLocations,
48
+ formatSymbols,
49
+ normalizeLocations,
50
+ type LspDiagnostic,
51
+ } from "./lsp-format.js";
52
+ import {
53
+ LSP_OPERATIONS,
54
+ pathToUri,
55
+ POSITION_OPERATIONS,
56
+ toLspPosition,
57
+ type LspOperation,
58
+ type LspOperationParams,
59
+ } from "./lsp-protocol.js";
60
+ import {
61
+ findWorkspaceRoot,
62
+ languageIdFor,
63
+ LSP_SERVERS,
64
+ LspServerPool,
65
+ missingServerText,
66
+ resolveBinary,
67
+ specForFile,
68
+ specForLanguage,
69
+ supportedExtensions,
70
+ type WhichFn,
71
+ } from "./lsp-servers.js";
72
+
73
+ const LspParams = Type.Object({
74
+ operation: StringEnum(LSP_OPERATIONS, {
75
+ description: "definition, references and hover require line + column; documentSymbol and diagnostics take only path.",
76
+ }),
77
+ path: Type.String({ description: "File to query, absolute or relative to the working directory." }),
78
+ line: Type.Optional(Type.Number({ description: "1-based line. Required for definition, references and hover." })),
79
+ column: Type.Optional(
80
+ Type.Number({ description: "1-based column in UTF-16 units, as editors count. Required for definition, references and hover." }),
81
+ ),
82
+ });
83
+
84
+ export interface LspRunDeps {
85
+ pool: LspServerPool;
86
+ which?: WhichFn;
87
+ readFile?(target: string): string;
88
+ exists?(target: string): boolean;
89
+ maxReferences?: number;
90
+ diagnosticsWaitMs?: number;
91
+ }
92
+
93
+ /** Models sometimes echo pi's `@file` mention syntax into tool arguments. */
94
+ export function normalizeToolPath(rawPath: string, cwd: string): string {
95
+ const stripped = rawPath.trim().replace(/^@/, "");
96
+ return path.isAbsolute(stripped) ? stripped : path.resolve(cwd, stripped);
97
+ }
98
+
99
+ /**
100
+ * Per-operation requiredness. `Type.Union` would express this in the schema but
101
+ * is banned here: it breaks Google's API, which is the same reason `StringEnum`
102
+ * exists. A thrown message is what pi turns into an error the model can act on.
103
+ */
104
+ export function assertOperationParams(params: LspOperationParams): void {
105
+ if (!LSP_OPERATIONS.includes(params.operation)) {
106
+ throw new Error(`Unknown lsp operation "${params.operation}". Valid operations: ${LSP_OPERATIONS.join(", ")}.`);
107
+ }
108
+ if (typeof params.path !== "string" || params.path.trim() === "") throw new Error("lsp requires a `path`.");
109
+ if (!POSITION_OPERATIONS.has(params.operation)) return;
110
+ const missing = [
111
+ typeof params.line === "number" ? null : "line",
112
+ typeof params.column === "number" ? null : "column",
113
+ ].filter(Boolean);
114
+ if (missing.length > 0) {
115
+ throw new Error(
116
+ `lsp ${params.operation} requires \`${missing.join("` and `")}\` (1-based). Use documentSymbol first to locate the symbol if you do not know its position.`,
117
+ );
118
+ }
119
+ }
120
+
121
+ async function queryClient(
122
+ client: LspClient,
123
+ params: LspOperationParams,
124
+ target: string,
125
+ cwd: string,
126
+ deps: LspRunDeps,
127
+ generation: number,
128
+ syncAction: "open" | "change" | "none",
129
+ signal?: AbortSignal,
130
+ ): Promise<string> {
131
+ const uri = pathToUri(target);
132
+ const shown = displayPath(target, cwd);
133
+ const readLine = createLineReader(deps.readFile);
134
+ const textDocument = { uri };
135
+
136
+ switch (params.operation) {
137
+ case "definition": {
138
+ const position = toLspPosition(params.line as number, params.column as number);
139
+ const result = await client.request("textDocument/definition", { textDocument, position }, { signal });
140
+ const locations = normalizeLocations(result);
141
+ if (locations.length === 0) return `No definition found at ${shown}:${params.line}:${params.column}`;
142
+ return formatLocations(locations, { cwd, readLine, limit: locations.length });
143
+ }
144
+ case "references": {
145
+ const position = toLspPosition(params.line as number, params.column as number);
146
+ const result = await client.request(
147
+ "textDocument/references",
148
+ { textDocument, position, context: { includeDeclaration: false } },
149
+ { signal },
150
+ );
151
+ const locations = normalizeLocations(result);
152
+ if (locations.length === 0) return `No references found at ${shown}:${params.line}:${params.column}`;
153
+ return formatLocations(locations, {
154
+ cwd,
155
+ readLine,
156
+ limit: deps.maxReferences ?? LSP_MAX_REFERENCES,
157
+ label: "references",
158
+ });
159
+ }
160
+ case "hover": {
161
+ const position = toLspPosition(params.line as number, params.column as number);
162
+ const result = await client.request("textDocument/hover", { textDocument, position }, { signal });
163
+ const rendered = formatHover(result);
164
+ return rendered || `No hover information at ${shown}:${params.line}:${params.column}`;
165
+ }
166
+ case "documentSymbol": {
167
+ const result = await client.request("textDocument/documentSymbol", { textDocument }, { signal });
168
+ const rendered = formatSymbols(result, cwd);
169
+ return rendered || `No symbols found in ${shown}`;
170
+ }
171
+ case "diagnostics": {
172
+ // A no-op sync means the server already analyzed exactly this buffer, so
173
+ // whatever it last published for the file IS current — demanding a newer
174
+ // generation would burn the whole wait window and then mislabel a
175
+ // perfectly fresh result as stale. Only a real didOpen/didChange (which
176
+ // provokes a republish) requires a publish newer than the pre-sync mark.
177
+ const sinceGeneration = syncAction === "none" ? 0 : generation;
178
+ const { items, fresh } = await client.waitForDiagnostics(
179
+ uri,
180
+ sinceGeneration,
181
+ deps.diagnosticsWaitMs ?? LSP_DIAGNOSTICS_WAIT_MS,
182
+ signal,
183
+ );
184
+ const rendered = formatDiagnostics(items as LspDiagnostic[], target, cwd);
185
+ if (!rendered) return `No diagnostics reported for ${shown}`;
186
+ return fresh ? rendered : `${rendered}\n(may be stale — the language server did not publish in time)`;
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The single code path that answers every query, whoever asked: the main-loop
193
+ * tool, and the broker on behalf of a subagent.
194
+ *
195
+ * Environmental dead ends (unknown file type, server not installed) return
196
+ * text rather than throwing. The model cannot install gopls, and an error
197
+ * invites a retry loop; text lets it fall back to grep.
198
+ */
199
+ export async function runLspOperation(
200
+ params: LspOperationParams,
201
+ cwd: string,
202
+ deps: LspRunDeps,
203
+ signal?: AbortSignal,
204
+ ): Promise<string> {
205
+ assertOperationParams(params);
206
+ const exists = deps.exists ?? fs.existsSync;
207
+ const readFile = deps.readFile ?? ((target: string) => fs.readFileSync(target, "utf8"));
208
+
209
+ const target = normalizeToolPath(params.path, cwd);
210
+ if (!exists(target)) throw new Error(`File not found: ${params.path}`);
211
+
212
+ const spec = specForFile(target);
213
+ if (!spec) {
214
+ const extension = path.extname(target) || "this file type";
215
+ return `No language server is configured for ${extension}. Supported: ${supportedExtensions().join(", ")}. Use grep/read for this file instead.`;
216
+ }
217
+ const binary = resolveBinary(spec, deps.which);
218
+ if (!binary) return missingServerText(spec);
219
+ const root = findWorkspaceRoot(target, spec.rootMarkers, cwd);
220
+
221
+ let lastError: Error | undefined;
222
+ for (let attempt = 0; attempt < 2; attempt++) {
223
+ const client = await deps.pool.acquire(spec, binary, root, signal);
224
+ try {
225
+ // Capture before syncing: an ensureDocument that provokes a publish must
226
+ // count as newer than what we already had cached.
227
+ const generation = client.diagnosticsGeneration();
228
+ const syncAction = await client.ensureDocument(pathToUri(target), readFile(target), languageIdFor(spec, target));
229
+ return await queryClient(client, params, target, cwd, deps, generation, syncAction, signal);
230
+ } catch (error) {
231
+ lastError = error as Error;
232
+ // Only a died-under-us server earns a second spawn. Retrying a timeout or
233
+ // a genuine LSP error would just double the wait.
234
+ if (!client.dead || signal?.aborted) throw lastError;
235
+ }
236
+ }
237
+ throw lastError ?? new Error("lsp request failed");
238
+ }
239
+
240
+ /** What dispatch needs to hand a child access to the parent's pool. */
241
+ export interface LspBrokerProvider {
242
+ ensure(): Promise<Record<string, string>>;
243
+ active(): boolean;
244
+ }
245
+
246
+ export interface LspToolDeps {
247
+ pool?: LspServerPool;
248
+ which?: WhichFn;
249
+ brokerClient?: LspBrokerClient;
250
+ startBroker?: typeof startLspBroker;
251
+ prefsPath?: string;
252
+ install?: typeof installServer;
253
+ detect?: typeof detectWorkspaceServers;
254
+ }
255
+
256
+ const TOOL_DESCRIPTION = [
257
+ "Code intelligence through a language server.",
258
+ "definition: where a symbol is defined. references: every use of a symbol. hover: type signature and docs.",
259
+ "documentSymbol: the outline of one file. diagnostics: compiler and type errors for one file.",
260
+ `Supports ${supportedExtensions().join(", ")}, and needs that language's server on PATH (gopls, typescript-language-server, pyright, rust-analyzer).`,
261
+ "Prefer this over grep for anything about symbols — it resolves through imports and types instead of matching text.",
262
+ `References are capped at ${LSP_MAX_REFERENCES}. The first query in a workspace can take a while as the server indexes.`,
263
+ ].join(" ");
264
+
265
+ const TOOL_PROMPT_SNIPPET = "Language-server code intelligence: definition, references, hover, documentSymbol, diagnostics (Go, TS/JS, Python, Rust)";
266
+
267
+ // The description alone does not change tool choice: models reach for text
268
+ // search on symbol questions by habit. These land in the system prompt's
269
+ // Guidelines section, which is what actually steers the pick.
270
+ const TOOL_PROMPT_GUIDELINES = [
271
+ "Use lsp instead of grep for symbol questions — where a symbol is defined or used, its type or signature, a file's structure, or compile errors. Text search matches strings; lsp resolves through imports and types.",
272
+ "To find every usage of a symbol: locate its declaration first (grep or lsp documentSymbol), then call lsp references at that exact line and column — a text match list is not a references answer.",
273
+ ];
274
+
275
+ const EXIT_SWEEP_KEY = Symbol.for("weave.pi.lsp.exitSweep");
276
+
277
+ interface ExitSweepState {
278
+ sweep(): void;
279
+ }
280
+
281
+ /**
282
+ * pi loads extensions with moduleCache:false, so `/reload` re-executes this
283
+ * module. Without the symbol guard every reload would add another exit
284
+ * listener; the guard instead re-points the one listener at the newest pool.
285
+ */
286
+ function installExitSweep(sweep: () => void): void {
287
+ const store = globalThis as unknown as Record<symbol, ExitSweepState | undefined>;
288
+ const existing = store[EXIT_SWEEP_KEY];
289
+ if (existing) {
290
+ existing.sweep = sweep;
291
+ return;
292
+ }
293
+ const state: ExitSweepState = { sweep };
294
+ store[EXIT_SWEEP_KEY] = state;
295
+ process.on("exit", () => state.sweep());
296
+ }
297
+
298
+ function toolResult(text: string, params: LspOperationParams) {
299
+ return {
300
+ content: [{ type: "text" as const, text }],
301
+ details: { operation: params.operation, path: params.path },
302
+ };
303
+ }
304
+
305
+ function renderLspCall(args: Partial<LspOperationParams>, theme: { fg(key: string, text: string): string; bold(text: string): string }): Text {
306
+ const position = typeof args.line === "number" ? `:${args.line}${typeof args.column === "number" ? `:${args.column}` : ""}` : "";
307
+ const target = `${args.path ?? ""}${position}`;
308
+ return new Text(
309
+ `${theme.fg("toolTitle", theme.bold("lsp "))}${theme.fg("accent", args.operation ?? "")}${target ? ` ${theme.fg("dim", target)}` : ""}`,
310
+ 0,
311
+ 0,
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Registers the `lsp` tool for this process's role and, in the main process,
317
+ * returns the broker provider for index.ts to hand to dispatch. dispatch never
318
+ * imports this module — composition stays in index.ts.
319
+ */
320
+ export function registerLsp(pi: ExtensionAPI, deps: LspToolDeps = {}): LspBrokerProvider | undefined {
321
+ if (isSubagent()) {
322
+ registerBrokerBackedTool(pi, deps);
323
+ return undefined;
324
+ }
325
+ return registerPoolBackedTool(pi, deps);
326
+ }
327
+
328
+ function registerBrokerBackedTool(pi: ExtensionAPI, deps: LspToolDeps): void {
329
+ const socketPath = process.env[LSP_BROKER_ENV];
330
+ const token = process.env[LSP_BROKER_TOKEN_ENV];
331
+ // A child spawned without a broker simply has no lsp tool, rather than one
332
+ // that always fails.
333
+ if (!deps.brokerClient && (!socketPath || !token)) return;
334
+
335
+ const client =
336
+ deps.brokerClient ??
337
+ new LspBrokerClient(socketPath as string, token as string, LSP_WARMUP_TIMEOUT_MS + LSP_REQUEST_TIMEOUT_MS);
338
+
339
+ pi.registerTool({
340
+ name: "lsp",
341
+ label: "LSP",
342
+ description: TOOL_DESCRIPTION,
343
+ promptSnippet: TOOL_PROMPT_SNIPPET,
344
+ promptGuidelines: TOOL_PROMPT_GUIDELINES,
345
+ parameters: LspParams,
346
+ executionMode: "parallel",
347
+
348
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
349
+ assertOperationParams(params as LspOperationParams);
350
+ // The parent may be cold-starting a server on our behalf, so the child
351
+ // sends its own cwd and lets the parent resolve paths against it.
352
+ const text = await client.execute(
353
+ { ...(params as LspOperationParams), path: normalizeToolPath(params.path, ctx.cwd) },
354
+ ctx.cwd,
355
+ signal,
356
+ );
357
+ return toolResult(text, params as LspOperationParams);
358
+ },
359
+
360
+ renderCall: renderLspCall,
361
+ });
362
+
363
+ pi.on("session_shutdown", () => client.close());
364
+ }
365
+
366
+ function registerPoolBackedTool(pi: ExtensionAPI, deps: LspToolDeps): LspBrokerProvider {
367
+ const pool =
368
+ deps.pool ??
369
+ new LspServerPool({
370
+ maxServers: LSP_MAX_SERVERS,
371
+ idleMs: LSP_IDLE_MS,
372
+ requestTimeoutMs: LSP_REQUEST_TIMEOUT_MS,
373
+ warmupTimeoutMs: LSP_WARMUP_TIMEOUT_MS,
374
+ });
375
+ const runDeps: LspRunDeps = { pool, which: deps.which };
376
+
377
+ pi.registerTool({
378
+ name: "lsp",
379
+ label: "LSP",
380
+ description: TOOL_DESCRIPTION,
381
+ promptSnippet: TOOL_PROMPT_SNIPPET,
382
+ promptGuidelines: TOOL_PROMPT_GUIDELINES,
383
+ parameters: LspParams,
384
+ executionMode: "parallel",
385
+
386
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
387
+ const text = await runLspOperation(params as LspOperationParams, ctx.cwd, runDeps, signal);
388
+ return toolResult(text, params as LspOperationParams);
389
+ },
390
+
391
+ renderCall: renderLspCall,
392
+ });
393
+
394
+ registerLspEnable(pi, deps);
395
+
396
+ let broker: LspBrokerHandle | undefined;
397
+ let starting: Promise<LspBrokerHandle> | undefined;
398
+ const start = deps.startBroker ?? startLspBroker;
399
+
400
+ const provider: LspBrokerProvider = {
401
+ active: () => broker !== undefined,
402
+ async ensure(): Promise<Record<string, string>> {
403
+ if (!starting) {
404
+ starting = start((params, cwd, signal) => runLspOperation(params, cwd, runDeps, signal)).then((handle) => {
405
+ broker = handle;
406
+ return handle;
407
+ });
408
+ starting.catch(() => {
409
+ // A broker that will not listen must not wedge every later fan-out.
410
+ starting = undefined;
411
+ });
412
+ }
413
+ const handle = await starting;
414
+ return { [LSP_BROKER_ENV]: handle.socketPath, [LSP_BROKER_TOKEN_ENV]: handle.token };
415
+ },
416
+ };
417
+
418
+ pi.on("session_shutdown", async () => {
419
+ const handle = broker;
420
+ broker = undefined;
421
+ starting = undefined;
422
+ // Close the socket before the servers so no child request arrives at a
423
+ // pool that is already tearing down.
424
+ await handle?.close();
425
+ await pool.shutdownAll();
426
+ });
427
+
428
+ installExitSweep(() => {
429
+ pool.killAllSync();
430
+ broker?.removeSocket();
431
+ });
432
+ return provider;
433
+ }
434
+
435
+ const LspEnableParams = Type.Object({
436
+ language: StringEnum(LSP_SERVERS.map((spec) => spec.language) as ["go", "typescript", "python", "rust"], {
437
+ description: "Language whose server to install or dismiss.",
438
+ }),
439
+ action: Type.Optional(
440
+ StringEnum(["install", "dismiss"] as const, {
441
+ description: 'Default "install". "dismiss" records that the user does not want this language server offered again.',
442
+ }),
443
+ ),
444
+ });
445
+
446
+ /**
447
+ * The consent-gated half of provisioning, registered in the main process only
448
+ * — subagents never get it, so a fan-out cannot trigger installs. A
449
+ * before_agent_start addendum tells the assistant which detected languages
450
+ * lack a server so it can offer once; this tool is how a user's "yes" (or
451
+ * "stop asking") takes effect.
452
+ */
453
+ function registerLspEnable(pi: ExtensionAPI, deps: LspToolDeps): void {
454
+ const prefsPath = deps.prefsPath ?? getLspPrefsPath();
455
+ const install = deps.install ?? installServer;
456
+ const detect = deps.detect ?? detectWorkspaceServers;
457
+
458
+ // Lazily built once per session; undefined once resolved (installed,
459
+ // dismissed, or nothing to offer) so the addendum disappears from later turns.
460
+ let offer: string | undefined;
461
+ let offerComputed = false;
462
+ const resetOffer = (): void => {
463
+ offer = undefined;
464
+ offerComputed = false;
465
+ };
466
+ pi.on("session_start", resetOffer);
467
+
468
+ pi.on("before_agent_start", (event: { systemPrompt: string }, ctx: { cwd: string }) => {
469
+ if (!offerComputed) {
470
+ offerComputed = true;
471
+ const missing = detect(ctx.cwd).filter((spec) => !resolveBinary(spec, deps.which));
472
+ offer = buildLspOffer(missing, loadDismissedLanguages(prefsPath));
473
+ }
474
+ if (!offer) return undefined;
475
+ return { systemPrompt: `${event.systemPrompt}\n\n${offer}` };
476
+ });
477
+
478
+ pi.registerTool({
479
+ name: "lsp_enable",
480
+ label: "LSP enable",
481
+ description: [
482
+ "Install a language server so the lsp tool works for that language, or record the user's wish not to be offered it again.",
483
+ "Only call this after the user has explicitly agreed (install) or declined (dismiss) in conversation — never preemptively.",
484
+ ].join(" "),
485
+ parameters: LspEnableParams,
486
+
487
+ async execute(_toolCallId, params, signal) {
488
+ const spec = specForLanguage(params.language);
489
+ if (!spec) throw new Error(`Unknown language "${params.language}".`);
490
+
491
+ if (params.action === "dismiss") {
492
+ const dismissed = loadDismissedLanguages(prefsPath);
493
+ dismissed.add(spec.language);
494
+ saveDismissedLanguages(prefsPath, dismissed);
495
+ offer = undefined;
496
+ return {
497
+ content: [
498
+ {
499
+ type: "text" as const,
500
+ text: `Noted — the ${spec.language} language server will not be offered again. The user can re-enable it any time by asking to enable ${spec.language} LSP support.`,
501
+ },
502
+ ],
503
+ details: { language: spec.language, action: "dismiss" },
504
+ };
505
+ }
506
+
507
+ const result: InstallResult = await install(spec, { which: deps.which }, signal);
508
+ if (result.ok) {
509
+ offer = undefined;
510
+ // An earlier dismissal is void once the user asks for the install.
511
+ const dismissed = loadDismissedLanguages(prefsPath);
512
+ if (dismissed.delete(spec.language)) saveDismissedLanguages(prefsPath, dismissed);
513
+ }
514
+ return {
515
+ content: [{ type: "text" as const, text: result.text }],
516
+ details: { language: spec.language, action: "install", ok: result.ok },
517
+ isError: !result.ok,
518
+ };
519
+ },
520
+
521
+ renderCall(args, theme) {
522
+ return new Text(
523
+ `${theme.fg("toolTitle", theme.bold("lsp_enable "))}${theme.fg("accent", args.language ?? "")}${args.action === "dismiss" ? theme.fg("dim", " dismiss") : ""}`,
524
+ 0,
525
+ 0,
526
+ );
527
+ },
528
+ });
529
+ }