@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.
- package/README.md +40 -14
- package/cc-statusline.sh +274 -23
- package/codex-skills/fm/SKILL.md +15 -0
- package/codex-skills/fm/scripts/emit.sh +8 -0
- package/codex-skills/force-model/SKILL.md +15 -0
- package/codex-skills/force-model/scripts/emit.sh +9 -0
- package/codex-skills/rf/SKILL.md +15 -0
- package/codex-skills/rf/scripts/emit.sh +7 -0
- package/codex-skills/router-feedback/SKILL.md +15 -0
- package/codex-skills/router-feedback/scripts/emit.sh +9 -0
- package/codex-skills/router-models/SKILL.md +51 -0
- package/codex-skills/router-off/SKILL.md +22 -0
- package/codex-skills/router-on/SKILL.md +22 -0
- package/codex-skills/router-status/SKILL.md +19 -0
- package/codex-skills/ufm/SKILL.md +14 -0
- package/codex-skills/ufm/scripts/emit.sh +3 -0
- package/codex-skills/unforce-model/SKILL.md +14 -0
- package/codex-skills/unforce-model/scripts/emit.sh +4 -0
- package/codex-status.sh +312 -0
- package/commands/beta.md +5 -0
- package/commands/models.md +46 -0
- package/commands/router-models.md +46 -0
- package/directives.tsv +13 -0
- package/install.sh +1849 -263
- package/package.json +7 -1
- package/pi-router/README.md +39 -7
- package/pi-router/skills/install-lsps/SKILL.md +75 -0
- package/pi-router/skills/lsp-guide/SKILL.md +63 -0
- package/pi-router/src/beta.ts +21 -0
- package/pi-router/src/compaction.ts +46 -8
- package/pi-router/src/config.ts +34 -5
- package/pi-router/src/context-window.ts +12 -0
- package/pi-router/src/dispatch.ts +35 -2
- package/pi-router/src/index.ts +12 -1
- package/pi-router/src/lsp-broker.ts +255 -0
- package/pi-router/src/lsp-client.ts +435 -0
- package/pi-router/src/lsp-format.ts +230 -0
- package/pi-router/src/lsp-install.ts +215 -0
- package/pi-router/src/lsp-protocol.ts +128 -0
- package/pi-router/src/lsp-servers.ts +361 -0
- package/pi-router/src/lsp.ts +529 -0
- package/pi-router/src/pricing.generated.ts +77 -70
- package/pi-router/src/provider.ts +15 -2
- package/pi-router/src/routed-model.ts +17 -0
- package/pi-router/src/savings.ts +1 -1
- package/registry.sh +102 -0
- 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
|
+
}
|