@workweave/router 0.2.10 → 0.2.11
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 +15 -3
- package/cc-statusline.sh +179 -7
- package/codex-skills/force-model/SKILL.md +14 -0
- package/codex-skills/router-feedback/SKILL.md +14 -0
- package/codex-skills/unforce-model/SKILL.md +14 -0
- package/commands/models.md +46 -0
- package/commands/router-models.md +46 -0
- package/directives.tsv +12 -0
- package/install.sh +1036 -78
- package/package.json +6 -1
- package/pi-router/README.md +36 -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/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 +10 -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 +6 -1
- package/pi-router/src/provider.ts +15 -2
- package/pi-router/src/routed-model.ts +17 -0
- package/registry.sh +100 -0
- package/uninstall.sh +173 -30
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP result shapes to the text the model reads.
|
|
3
|
+
*
|
|
4
|
+
* Pure: the only filesystem touch (a source line for grep-style context) is an
|
|
5
|
+
* injected reader. Empty results are friendly text rather than errors, matching
|
|
6
|
+
* pi's own grep tool ("No matches found") — a model that reads "not found"
|
|
7
|
+
* moves on, a model that reads an error retries.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { fromLspPosition, uriToPath, type LspPosition } from "./lsp-protocol.js";
|
|
13
|
+
|
|
14
|
+
const MAX_LINE_CHARS = 500;
|
|
15
|
+
const MAX_HOVER_CHARS = 8000;
|
|
16
|
+
const MAX_SYMBOLS = 200;
|
|
17
|
+
const MAX_DIAGNOSTICS = 50;
|
|
18
|
+
|
|
19
|
+
export interface LspRange {
|
|
20
|
+
start: LspPosition;
|
|
21
|
+
end: LspPosition;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LspLocation {
|
|
25
|
+
uri: string;
|
|
26
|
+
range: LspRange;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LspLocationLink {
|
|
30
|
+
targetUri: string;
|
|
31
|
+
targetRange: LspRange;
|
|
32
|
+
targetSelectionRange?: LspRange;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NormalizedLocation {
|
|
36
|
+
path: string;
|
|
37
|
+
line: number;
|
|
38
|
+
column: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LspDiagnostic {
|
|
42
|
+
range: LspRange;
|
|
43
|
+
severity?: number;
|
|
44
|
+
code?: string | number;
|
|
45
|
+
source?: string;
|
|
46
|
+
message: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface LspDocumentSymbol {
|
|
50
|
+
name: string;
|
|
51
|
+
kind: number;
|
|
52
|
+
detail?: string;
|
|
53
|
+
range?: LspRange;
|
|
54
|
+
selectionRange?: LspRange;
|
|
55
|
+
children?: LspDocumentSymbol[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface LspSymbolInformation {
|
|
59
|
+
name: string;
|
|
60
|
+
kind: number;
|
|
61
|
+
containerName?: string;
|
|
62
|
+
location: LspLocation;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** LSP 3.17 SymbolKind, 1-based. */
|
|
66
|
+
const SYMBOL_KINDS = [
|
|
67
|
+
"File", "Module", "Namespace", "Package", "Class", "Method", "Property", "Field", "Constructor",
|
|
68
|
+
"Enum", "Interface", "Function", "Variable", "Constant", "String", "Number", "Boolean", "Array",
|
|
69
|
+
"Object", "Key", "Null", "EnumMember", "Struct", "Event", "Operator", "TypeParameter",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const SEVERITY_NAMES = ["error", "warning", "info", "hint"];
|
|
73
|
+
|
|
74
|
+
export type LineReader = (filePath: string, line: number) => string | undefined;
|
|
75
|
+
|
|
76
|
+
/** Memoized per call: a references result routinely hits the same file dozens of times. */
|
|
77
|
+
export function createLineReader(readFile: (p: string) => string = (p) => fs.readFileSync(p, "utf8")): LineReader {
|
|
78
|
+
const cache = new Map<string, string[] | undefined>();
|
|
79
|
+
return (filePath, line) => {
|
|
80
|
+
if (!cache.has(filePath)) {
|
|
81
|
+
try {
|
|
82
|
+
cache.set(filePath, readFile(filePath).split(/\r?\n/));
|
|
83
|
+
} catch {
|
|
84
|
+
cache.set(filePath, undefined);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return cache.get(filePath)?.[line - 1];
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function displayPath(target: string, cwd: string): string {
|
|
92
|
+
const relative = path.relative(cwd, target);
|
|
93
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return target;
|
|
94
|
+
return relative;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function truncateLine(text: string): string {
|
|
98
|
+
const trimmed = text.trim();
|
|
99
|
+
return trimmed.length > MAX_LINE_CHARS ? `${trimmed.slice(0, MAX_LINE_CHARS)}... [truncated]` : trimmed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
103
|
+
return typeof value === "object" && value !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `Location | Location[] | LocationLink[] | null` collapsed to one 1-based shape. */
|
|
107
|
+
export function normalizeLocations(result: unknown): NormalizedLocation[] {
|
|
108
|
+
if (result === null || result === undefined) return [];
|
|
109
|
+
const items = Array.isArray(result) ? result : [result];
|
|
110
|
+
const out: NormalizedLocation[] = [];
|
|
111
|
+
for (const item of items) {
|
|
112
|
+
if (!isRecord(item)) continue;
|
|
113
|
+
// LocationLink points at targetSelectionRange (the name) rather than
|
|
114
|
+
// targetRange (the whole declaration body), which is what a reader wants.
|
|
115
|
+
const uri = typeof item.uri === "string" ? item.uri : typeof item.targetUri === "string" ? item.targetUri : undefined;
|
|
116
|
+
if (!uri) continue;
|
|
117
|
+
const range = (item.range ?? item.targetSelectionRange ?? item.targetRange) as LspRange | undefined;
|
|
118
|
+
const { line, column } = fromLspPosition(range?.start);
|
|
119
|
+
out.push({ path: uriToPath(uri), line, column });
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface LocationListOptions {
|
|
125
|
+
cwd: string;
|
|
126
|
+
readLine: LineReader;
|
|
127
|
+
limit: number;
|
|
128
|
+
/** Plural noun for the header, e.g. "references". Omit for a bare list. */
|
|
129
|
+
label?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function formatLocations(locations: NormalizedLocation[], options: LocationListOptions): string {
|
|
133
|
+
const shown = locations.slice(0, Math.max(1, options.limit));
|
|
134
|
+
const lines = shown.map((location) => {
|
|
135
|
+
const source = options.readLine(location.path, location.line);
|
|
136
|
+
const suffix = source === undefined ? "" : ` ${truncateLine(source)}`;
|
|
137
|
+
return `${displayPath(location.path, options.cwd)}:${location.line}:${location.column}:${suffix}`;
|
|
138
|
+
});
|
|
139
|
+
if (!options.label) return lines.join("\n");
|
|
140
|
+
const header =
|
|
141
|
+
locations.length > shown.length
|
|
142
|
+
? `${locations.length} ${options.label} (showing first ${shown.length})`
|
|
143
|
+
: `${locations.length} ${options.label}`;
|
|
144
|
+
return [header, ...lines].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function formatHover(result: unknown): string {
|
|
148
|
+
if (!isRecord(result)) return "";
|
|
149
|
+
const rendered = renderHoverContents(result.contents).trim();
|
|
150
|
+
return rendered.length > MAX_HOVER_CHARS ? `${rendered.slice(0, MAX_HOVER_CHARS)}\n... [truncated]` : rendered;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderHoverContents(contents: unknown): string {
|
|
154
|
+
if (typeof contents === "string") return contents;
|
|
155
|
+
if (Array.isArray(contents)) return contents.map(renderHoverContents).filter(Boolean).join("\n\n");
|
|
156
|
+
if (!isRecord(contents)) return "";
|
|
157
|
+
// MarkupContent
|
|
158
|
+
if (typeof contents.value === "string" && typeof contents.kind === "string") return contents.value;
|
|
159
|
+
// MarkedString: {language, value}
|
|
160
|
+
if (typeof contents.value === "string") {
|
|
161
|
+
const language = typeof contents.language === "string" ? contents.language : "";
|
|
162
|
+
return `\`\`\`${language}\n${contents.value}\n\`\`\``;
|
|
163
|
+
}
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function symbolKindName(kind: number): string {
|
|
168
|
+
return SYMBOL_KINDS[kind - 1] ?? "Symbol";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function formatSymbols(result: unknown, cwd: string): string {
|
|
172
|
+
if (!Array.isArray(result) || result.length === 0) return "";
|
|
173
|
+
const lines: string[] = [];
|
|
174
|
+
let remaining = MAX_SYMBOLS;
|
|
175
|
+
|
|
176
|
+
const walk = (symbols: LspDocumentSymbol[], depth: number): void => {
|
|
177
|
+
for (const symbol of symbols) {
|
|
178
|
+
if (remaining <= 0) return;
|
|
179
|
+
remaining -= 1;
|
|
180
|
+
const { line } = fromLspPosition((symbol.selectionRange ?? symbol.range)?.start);
|
|
181
|
+
const detail = symbol.detail ? ` ${symbol.detail}` : "";
|
|
182
|
+
lines.push(`${" ".repeat(depth)}${symbolKindName(symbol.kind)} ${symbol.name}${detail} :${line}`);
|
|
183
|
+
if (symbol.children?.length) walk(symbol.children, depth + 1);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const first = result[0] as Record<string, unknown>;
|
|
188
|
+
if (isRecord(first) && isRecord(first.location)) {
|
|
189
|
+
// Flat SymbolInformation[] — older servers, no hierarchy available.
|
|
190
|
+
for (const symbol of (result as LspSymbolInformation[]).slice(0, MAX_SYMBOLS)) {
|
|
191
|
+
const { line } = fromLspPosition(symbol.location?.range?.start);
|
|
192
|
+
const container = symbol.containerName ? ` (in ${symbol.containerName})` : "";
|
|
193
|
+
const file = displayPath(uriToPath(symbol.location?.uri ?? ""), cwd);
|
|
194
|
+
lines.push(`${symbolKindName(symbol.kind)} ${symbol.name}${container} ${file}:${line}`);
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
walk(result as LspDocumentSymbol[], 0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const total = countSymbols(result as LspDocumentSymbol[]);
|
|
201
|
+
if (total > lines.length) lines.push(`... ${total - lines.length} more symbols`);
|
|
202
|
+
return lines.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function countSymbols(symbols: LspDocumentSymbol[]): number {
|
|
206
|
+
let total = 0;
|
|
207
|
+
for (const symbol of symbols) {
|
|
208
|
+
total += 1 + (symbol.children ? countSymbols(symbol.children) : 0);
|
|
209
|
+
}
|
|
210
|
+
return total;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function formatDiagnostics(diagnostics: LspDiagnostic[], filePath: string, cwd: string): string {
|
|
214
|
+
if (diagnostics.length === 0) return "";
|
|
215
|
+
const counts = [0, 0, 0, 0];
|
|
216
|
+
for (const diagnostic of diagnostics) counts[(diagnostic.severity ?? 1) - 1] += 1;
|
|
217
|
+
const summary = SEVERITY_NAMES.map((name, index) => (counts[index] > 0 ? `${counts[index]} ${name}${counts[index] === 1 ? "" : "s"}` : null))
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
.join(", ");
|
|
220
|
+
|
|
221
|
+
const file = displayPath(filePath, cwd);
|
|
222
|
+
const lines = diagnostics.slice(0, MAX_DIAGNOSTICS).map((diagnostic) => {
|
|
223
|
+
const { line, column } = fromLspPosition(diagnostic.range?.start);
|
|
224
|
+
const severity = SEVERITY_NAMES[(diagnostic.severity ?? 1) - 1] ?? "error";
|
|
225
|
+
const source = diagnostic.source ? ` [${diagnostic.source}]` : "";
|
|
226
|
+
return `${severity} ${file}:${line}:${column}: ${diagnostic.message.replace(/\s*\n\s*/g, " ")}${source}`;
|
|
227
|
+
});
|
|
228
|
+
if (diagnostics.length > lines.length) lines.push(`... ${diagnostics.length - lines.length} more`);
|
|
229
|
+
return [summary, ...lines].join("\n");
|
|
230
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in language-server provisioning.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here runs uninvited: the assistant is told (via a system-prompt
|
|
5
|
+
* addendum) which detected languages lack a server, offers ONCE in
|
|
6
|
+
* conversation ("I can enable Go LSP support if you'd like — just say the
|
|
7
|
+
* word!"), and only a user "yes" relayed through the lsp_enable tool triggers
|
|
8
|
+
* an install. A "no, stop asking" is persisted per language and respected
|
|
9
|
+
* across sessions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import type { SpawnFn } from "./lsp-client.js";
|
|
15
|
+
import {
|
|
16
|
+
defaultWhich,
|
|
17
|
+
detectMarkers,
|
|
18
|
+
installCommandText,
|
|
19
|
+
LSP_SERVERS,
|
|
20
|
+
resolveBinary,
|
|
21
|
+
type LanguageServerSpec,
|
|
22
|
+
type WhichFn,
|
|
23
|
+
} from "./lsp-servers.js";
|
|
24
|
+
|
|
25
|
+
const INSTALL_TIMEOUT_MS = 300_000;
|
|
26
|
+
const INSTALL_KILL_GRACE_MS = 5000;
|
|
27
|
+
const OUTPUT_TAIL_BYTES = 2048;
|
|
28
|
+
const MAX_SCAN_ENTRIES = 50;
|
|
29
|
+
const SKIPPED_SCAN_DIRS = new Set(["node_modules", "vendor", "dist", "build", "target"]);
|
|
30
|
+
|
|
31
|
+
export interface DetectDeps {
|
|
32
|
+
exists?(target: string): boolean;
|
|
33
|
+
readDir?(dir: string): string[];
|
|
34
|
+
isDir?(target: string): boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Which registry languages are present in a workspace: positive markers at the
|
|
39
|
+
* root or one level down (enough for common monorepo layouts, cheap enough to
|
|
40
|
+
* run at session start).
|
|
41
|
+
*/
|
|
42
|
+
export function detectWorkspaceServers(cwd: string, deps: DetectDeps = {}): LanguageServerSpec[] {
|
|
43
|
+
const exists = deps.exists ?? fs.existsSync;
|
|
44
|
+
const isDir = deps.isDir ?? ((target: string) => {
|
|
45
|
+
try {
|
|
46
|
+
return fs.statSync(target).isDirectory();
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
const readDir = deps.readDir ?? ((dir: string) => {
|
|
52
|
+
try {
|
|
53
|
+
return fs.readdirSync(dir);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const roots = [cwd];
|
|
60
|
+
for (const entry of readDir(cwd).slice(0, MAX_SCAN_ENTRIES)) {
|
|
61
|
+
if (entry.startsWith(".") || SKIPPED_SCAN_DIRS.has(entry)) continue;
|
|
62
|
+
const child = `${cwd}/${entry}`;
|
|
63
|
+
if (isDir(child)) roots.push(child);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return LSP_SERVERS.filter((spec) =>
|
|
67
|
+
roots.some((root) => detectMarkers(spec).some((marker) => exists(`${root}/${marker}`))),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface LspPrefs {
|
|
72
|
+
dismissed: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Tolerant read: a missing or corrupt prefs file means "nothing dismissed", never an error. */
|
|
76
|
+
export function loadDismissedLanguages(prefsPath: string, readFile: (p: string) => string = (p) => fs.readFileSync(p, "utf8")): Set<string> {
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(readFile(prefsPath)) as Partial<LspPrefs>;
|
|
79
|
+
return new Set(Array.isArray(parsed.dismissed) ? parsed.dismissed.filter((entry) => typeof entry === "string") : []);
|
|
80
|
+
} catch {
|
|
81
|
+
return new Set();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function saveDismissedLanguages(
|
|
86
|
+
prefsPath: string,
|
|
87
|
+
dismissed: Set<string>,
|
|
88
|
+
writeFile: (p: string, data: string) => void = (p, data) => fs.writeFileSync(p, data),
|
|
89
|
+
): void {
|
|
90
|
+
writeFile(prefsPath, `${JSON.stringify({ dismissed: [...dismissed].sort() }, null, 2)}\n`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The system-prompt addendum that turns detection into a conversational offer.
|
|
95
|
+
* Returns undefined when there is nothing to offer (nothing detected missing,
|
|
96
|
+
* or everything relevant was dismissed).
|
|
97
|
+
*/
|
|
98
|
+
export function buildLspOffer(missing: LanguageServerSpec[], dismissed: Set<string>): string | undefined {
|
|
99
|
+
const offerable = missing.filter((spec) => !dismissed.has(spec.language));
|
|
100
|
+
if (offerable.length === 0) return undefined;
|
|
101
|
+
const languages = offerable.map((spec) => spec.language).join(", ");
|
|
102
|
+
return [
|
|
103
|
+
"## Code intelligence (LSP)",
|
|
104
|
+
`This workspace contains ${languages} code, but the matching language server(s) are not installed, so the \`lsp\` tool cannot serve those files yet.`,
|
|
105
|
+
`At a natural moment, offer ONCE — briefly, in your own words (e.g. "I can enable ${offerable[0].language} LSP support if you'd like — just say the word!") — to enable it.`,
|
|
106
|
+
'If the user agrees, call lsp_enable with {"language": "<language>"} for each language they want.',
|
|
107
|
+
'If they decline or ask not to be asked again, call lsp_enable with {"language": "<language>", "action": "dismiss"} and drop the subject.',
|
|
108
|
+
"Never call lsp_enable without the user's explicit go-ahead, and do not repeat the offer in this session.",
|
|
109
|
+
].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface InstallResult {
|
|
113
|
+
ok: boolean;
|
|
114
|
+
text: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface InstallDeps {
|
|
118
|
+
which?: WhichFn;
|
|
119
|
+
spawnFn?: SpawnFn;
|
|
120
|
+
timeoutMs?: number;
|
|
121
|
+
killGraceMs?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Run the spec's install command. Only ever called with the user's explicit consent. */
|
|
125
|
+
export async function installServer(spec: LanguageServerSpec, deps: InstallDeps = {}, signal?: AbortSignal): Promise<InstallResult> {
|
|
126
|
+
const which = deps.which ?? defaultWhich;
|
|
127
|
+
const commandText = installCommandText(spec);
|
|
128
|
+
|
|
129
|
+
if (resolveBinary(spec, which)) {
|
|
130
|
+
return { ok: true, text: `The ${spec.language} language server is already installed — the lsp tool is ready for ${spec.language} files.` };
|
|
131
|
+
}
|
|
132
|
+
if (!which(spec.install.requires)) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
text: `Cannot install the ${spec.language} language server: it needs \`${spec.install.requires}\` on PATH (the install command is \`${commandText}\`). The install-lsps skill covers installing the ${spec.install.requires} toolchain with the user's consent.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const [command, ...args] = spec.install.command;
|
|
140
|
+
const spawnFn = deps.spawnFn ?? nodeSpawn;
|
|
141
|
+
const timeoutMs = deps.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
142
|
+
const killGraceMs = deps.killGraceMs ?? INSTALL_KILL_GRACE_MS;
|
|
143
|
+
|
|
144
|
+
const outcome = await new Promise<{ code: number | null; output: string; cancelled?: "aborted" | "timed out" }>((resolve) => {
|
|
145
|
+
const child = spawnFn(command, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
146
|
+
let tail = "";
|
|
147
|
+
let settled = false;
|
|
148
|
+
let cancelled: "aborted" | "timed out" | undefined;
|
|
149
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
150
|
+
const settle = (code: number | null): void => {
|
|
151
|
+
if (settled) return;
|
|
152
|
+
settled = true;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
if (killTimer) clearTimeout(killTimer);
|
|
155
|
+
signal?.removeEventListener("abort", onAbort);
|
|
156
|
+
resolve({ code, output: tail, cancelled });
|
|
157
|
+
};
|
|
158
|
+
const append = (chunk: Buffer): void => {
|
|
159
|
+
tail = (tail + chunk.toString("utf8")).slice(-OUTPUT_TAIL_BYTES);
|
|
160
|
+
};
|
|
161
|
+
// Cancellation must not settle before the child actually exits — an
|
|
162
|
+
// installer that shrugs off SIGTERM would keep mutating the system after
|
|
163
|
+
// we reported failure. Settle on close, escalating to SIGKILL if needed.
|
|
164
|
+
const cancel = (reason: "aborted" | "timed out"): void => {
|
|
165
|
+
if (settled || cancelled) return;
|
|
166
|
+
cancelled = reason;
|
|
167
|
+
child.kill("SIGTERM");
|
|
168
|
+
killTimer = setTimeout(() => {
|
|
169
|
+
if (!settled) child.kill("SIGKILL");
|
|
170
|
+
}, killGraceMs);
|
|
171
|
+
killTimer.unref?.();
|
|
172
|
+
};
|
|
173
|
+
const onAbort = (): void => cancel("aborted");
|
|
174
|
+
const timer = setTimeout(() => cancel("timed out"), timeoutMs);
|
|
175
|
+
timer.unref?.();
|
|
176
|
+
|
|
177
|
+
child.stdout?.on("data", append);
|
|
178
|
+
child.stderr?.on("data", append);
|
|
179
|
+
child.on("close", (code) => settle(code));
|
|
180
|
+
child.on("error", (error: Error) => {
|
|
181
|
+
append(Buffer.from(error.message));
|
|
182
|
+
settle(null);
|
|
183
|
+
});
|
|
184
|
+
if (signal) {
|
|
185
|
+
if (signal.aborted) onAbort();
|
|
186
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const lastOutputLine =
|
|
191
|
+
outcome.output
|
|
192
|
+
.split("\n")
|
|
193
|
+
.map((line) => line.trim())
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.pop() ?? "";
|
|
196
|
+
if (outcome.cancelled) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
text: `Installing the ${spec.language} language server ${outcome.cancelled} (\`${commandText}\`); the installer process was stopped. The install may be partial — re-run \`${commandText}\` to complete it.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (outcome.code !== 0) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
text: `Installing the ${spec.language} language server failed (\`${commandText}\`${outcome.code === null ? "" : `, exit ${outcome.code}`})${lastOutputLine ? `: ${lastOutputLine}` : ""}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (!resolveBinary(spec, which)) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
text: `\`${commandText}\` succeeded but its binary is still not resolvable — the install location is probably not on PATH. Ask the user to add it and restart pi.`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return { ok: true, text: `Installed the ${spec.language} language server — the lsp tool now works for ${spec.language} files in this session.` };
|
|
215
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Length framing and coordinate conversion.
|
|
3
|
+
*
|
|
4
|
+
* Both transports in this subsystem speak the same wire format: LSP servers
|
|
5
|
+
* over stdio, and the subagent broker over a local socket. Framing lives here
|
|
6
|
+
* so neither owns it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
10
|
+
|
|
11
|
+
export interface JsonRpcMessage {
|
|
12
|
+
jsonrpc?: string;
|
|
13
|
+
id?: number | string | null;
|
|
14
|
+
method?: string;
|
|
15
|
+
params?: unknown;
|
|
16
|
+
result?: unknown;
|
|
17
|
+
error?: { code: number; message: string; data?: unknown };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const HEADER_SEPARATOR = "\r\n\r\n";
|
|
21
|
+
|
|
22
|
+
/** Buffered-bytes ceiling. A peer that blows through it is malfunctioning; the owner kills it. */
|
|
23
|
+
export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
export function encodeFrame(message: unknown): Buffer {
|
|
26
|
+
const body = Buffer.from(JSON.stringify(message), "utf8");
|
|
27
|
+
// Content-Length counts BYTES, not characters — a UTF-8 body with any
|
|
28
|
+
// non-ASCII content would otherwise desynchronize the peer's parser.
|
|
29
|
+
return Buffer.concat([Buffer.from(`Content-Length: ${body.length}${HEADER_SEPARATOR}`, "ascii"), body]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FrameParser {
|
|
33
|
+
push(chunk: Buffer): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Incremental parser: handles several frames per chunk, one frame split across
|
|
38
|
+
* chunks, and extra headers (`Content-Type`). A body that is not JSON costs
|
|
39
|
+
* that one message; `onOverflow` fires once and the parser then stays quiet so
|
|
40
|
+
* the owner can tear the peer down.
|
|
41
|
+
*/
|
|
42
|
+
export function createFrameParser(onMessage: (message: JsonRpcMessage) => void, onOverflow?: (error: Error) => void): FrameParser {
|
|
43
|
+
let buffer = Buffer.alloc(0);
|
|
44
|
+
let overflowed = false;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
push(chunk: Buffer): void {
|
|
48
|
+
if (overflowed) return;
|
|
49
|
+
buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
|
|
50
|
+
if (buffer.length > MAX_FRAME_BYTES) {
|
|
51
|
+
overflowed = true;
|
|
52
|
+
buffer = Buffer.alloc(0);
|
|
53
|
+
onOverflow?.(new Error(`framed message exceeded ${MAX_FRAME_BYTES} bytes`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
while (true) {
|
|
58
|
+
const headerEnd = buffer.indexOf(HEADER_SEPARATOR);
|
|
59
|
+
if (headerEnd === -1) return;
|
|
60
|
+
const header = buffer.subarray(0, headerEnd).toString("ascii");
|
|
61
|
+
const match = /content-length:\s*(\d+)/i.exec(header);
|
|
62
|
+
if (!match) {
|
|
63
|
+
// No length means no way to find the next boundary; drop the header
|
|
64
|
+
// block and resynchronize on whatever follows it.
|
|
65
|
+
buffer = buffer.subarray(headerEnd + HEADER_SEPARATOR.length);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const bodyStart = headerEnd + HEADER_SEPARATOR.length;
|
|
69
|
+
const bodyLength = Number(match[1]);
|
|
70
|
+
if (buffer.length < bodyStart + bodyLength) return;
|
|
71
|
+
const body = buffer.subarray(bodyStart, bodyStart + bodyLength).toString("utf8");
|
|
72
|
+
buffer = buffer.subarray(bodyStart + bodyLength);
|
|
73
|
+
try {
|
|
74
|
+
onMessage(JSON.parse(body) as JsonRpcMessage);
|
|
75
|
+
} catch {
|
|
76
|
+
/* malformed body — skip this message, keep the stream */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function pathToUri(filePath: string): string {
|
|
84
|
+
return pathToFileURL(filePath).toString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function uriToPath(uri: string): string {
|
|
88
|
+
if (!uri.startsWith("file:")) return uri;
|
|
89
|
+
try {
|
|
90
|
+
return fileURLToPath(uri);
|
|
91
|
+
} catch {
|
|
92
|
+
// Some servers emit non-canonical file URIs (unencoded spaces).
|
|
93
|
+
return decodeURIComponent(uri.replace(/^file:\/\//, ""));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The operation vocabulary. It lives beside the framing rather than with the pi
|
|
99
|
+
* tool because it is literally the broker's wire payload — keeping it here lets
|
|
100
|
+
* the broker stay below the tool adapter instead of importing back into it.
|
|
101
|
+
*/
|
|
102
|
+
export const LSP_OPERATIONS = ["definition", "references", "hover", "documentSymbol", "diagnostics"] as const;
|
|
103
|
+
|
|
104
|
+
export type LspOperation = (typeof LSP_OPERATIONS)[number];
|
|
105
|
+
|
|
106
|
+
/** Operations that resolve a point in a file, and so require line + column. */
|
|
107
|
+
export const POSITION_OPERATIONS = new Set<LspOperation>(["definition", "references", "hover"]);
|
|
108
|
+
|
|
109
|
+
export interface LspOperationParams {
|
|
110
|
+
operation: LspOperation;
|
|
111
|
+
path: string;
|
|
112
|
+
line?: number;
|
|
113
|
+
column?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface LspPosition {
|
|
117
|
+
line: number;
|
|
118
|
+
character: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Model-facing 1-based line/column to the 0-based position LSP uses. */
|
|
122
|
+
export function toLspPosition(line: number, column: number): LspPosition {
|
|
123
|
+
return { line: Math.max(0, Math.trunc(line) - 1), character: Math.max(0, Math.trunc(column) - 1) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function fromLspPosition(position: LspPosition | undefined): { line: number; column: number } {
|
|
127
|
+
return { line: (position?.line ?? 0) + 1, column: (position?.character ?? 0) + 1 };
|
|
128
|
+
}
|