acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/lsp.mjs
ADDED
|
@@ -0,0 +1,1613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ LSP — THE AGENT STOPS GREPPING AND STARTS KNOWING ────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Every navigation verb in this package is a TEXT verb. `search_text` finds the
|
|
5
|
+
* string `handleClick`; it cannot tell the definition from the eleven call sites
|
|
6
|
+
* from the word in a comment, and it silently misses the one that was imported
|
|
7
|
+
* under another name. A model handed that list has to guess, and guessing is
|
|
8
|
+
* what burns rounds.
|
|
9
|
+
*
|
|
10
|
+
* A language server already knows. It has parsed the project, resolved the
|
|
11
|
+
* imports and built the symbol table — and on most developer machines one is
|
|
12
|
+
* already installed, because their editor needs it. So this is the rare
|
|
13
|
+
* capability where the expensive part is somebody else's and we only have to
|
|
14
|
+
* ask.
|
|
15
|
+
*
|
|
16
|
+
* ── ⭐ WHY IT IS CHEAP FOR US SPECIFICALLY ──────────────────────────────────
|
|
17
|
+
* LSP is JSON-RPC over a child process's stdio, which is exactly what
|
|
18
|
+
* `mcp.mjs` already speaks. Its child-process lifecycle, its stderr tail, its
|
|
19
|
+
* "a broken server is data, never a throw" rule and its Windows spawn
|
|
20
|
+
* archaeology are all reused here deliberately. Read that file alongside this
|
|
21
|
+
* one; the differences are the interesting part, and there are four:
|
|
22
|
+
*
|
|
23
|
+
* 1. ⚠️ **THE FRAMING IS BYTE-LENGTH, NOT NEWLINE.** MCP delimits messages
|
|
24
|
+
* with `\n` and can therefore buffer a decoded STRING. LSP prefixes each
|
|
25
|
+
* message with `Content-Length: N`, counted in BYTES. Buffering a string
|
|
26
|
+
* would be a real corruption bug rather than a style difference: a
|
|
27
|
+
* multi-byte character split across two `data` events decodes to two
|
|
28
|
+
* replacement characters, and thereafter every byte offset in the stream
|
|
29
|
+
* is wrong. So this file buffers `Buffer`s and never calls `setEncoding`.
|
|
30
|
+
* 2. ⚠️ **THE SERVER SENDS US REQUESTS.** MCP servers only answer. A language
|
|
31
|
+
* server asks the client for configuration and to register capabilities,
|
|
32
|
+
* and some of them BLOCK until answered — an unanswered `client/…` request
|
|
33
|
+
* is a hang that looks exactly like a slow project scan. So there is a
|
|
34
|
+
* responder below, and unknown methods get a proper JSON-RPC
|
|
35
|
+
* MethodNotFound rather than silence.
|
|
36
|
+
* 3. ⚠️ **DOCUMENTS GO STALE, AND THIS AGENT EDITS FILES.** The server answers
|
|
37
|
+
* from the copy of the document IT holds, not from disk. Open a file, let
|
|
38
|
+
* the model rewrite it, ask again, and you get diagnostics for the previous
|
|
39
|
+
* version — a wrong answer that looks completely plausible. Every query
|
|
40
|
+
* here re-reads the file and sends `didChange` when the bytes differ.
|
|
41
|
+
* 4. ⚠️ **THE CHILD FORKS ITS OWN CHILD.** `typescript-language-server` is a
|
|
42
|
+
* thin wrapper that runs `tsserver` as a separate process, and tsserver is
|
|
43
|
+
* the one holding a gigabyte of project graph. `child.kill()` reaches the
|
|
44
|
+
* wrapper and orphans the expensive half. This machine has been overheated
|
|
45
|
+
* by exactly that class of leak, so shutdown here is graceful-then-tree-kill
|
|
46
|
+
* and is wired to `exit` AND to the signals — because an `exit` listener
|
|
47
|
+
* does not run on Ctrl-C, which `turn.mjs` learned the hard way.
|
|
48
|
+
*
|
|
49
|
+
* ── ⚠️ WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────
|
|
50
|
+
* No `rename`, no `codeAction`, no `formatting`. Those are WRITES, and a write
|
|
51
|
+
* that arrives as a workspace-edit payload bypasses `edit.mjs` — the diff the
|
|
52
|
+
* user reviews, the size caps, the refusals. Reads first; a write verb needs its
|
|
53
|
+
* own argument.
|
|
54
|
+
*
|
|
55
|
+
* ── ⚠️ READINESS IS TWO QUESTIONS, NOT ONE ─────────────────────────────────
|
|
56
|
+
* "Is the server installed" and "can it serve THIS workspace" are different
|
|
57
|
+
* questions with different answers, and conflating them shipped four tools that
|
|
58
|
+
* fail on first use. See `workspaceCanBeServed` — it carries the measurements.
|
|
59
|
+
*
|
|
60
|
+
* ── ⚠️ AND THE HONEST LIMIT ─────────────────────────────────────────────────
|
|
61
|
+
* A language server is a program the USER installed, spawned from their own
|
|
62
|
+
* project. We do not sandbox it and must not claim to. What is guaranteed is
|
|
63
|
+
* that nothing is spawned except a server from this file's fixed registry,
|
|
64
|
+
* found at a path we resolved ourselves — a model cannot name the program, only
|
|
65
|
+
* the file it wants to know about.
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
import { spawn } from 'node:child_process';
|
|
69
|
+
import { exitIsDeferred } from './interrupt.mjs';
|
|
70
|
+
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
71
|
+
import { join, dirname, resolve, extname, relative, sep } from 'node:path';
|
|
72
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
73
|
+
import { createRequire } from 'node:module';
|
|
74
|
+
import { detachChild } from './child-lifetime.mjs';
|
|
75
|
+
|
|
76
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ── BOUNDS ──────────────────────────────────────────────────────────────────
|
|
80
|
+
* Each one exists because the failure it prevents is either silent or expensive.
|
|
81
|
+
*/
|
|
82
|
+
/** tsserver cold-starting on a large monorepo is genuinely slow; a minute is not. */
|
|
83
|
+
export const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
84
|
+
/** A resolved query is milliseconds. Twenty seconds means something is wedged. */
|
|
85
|
+
export const REQUEST_TIMEOUT_MS = 20_000;
|
|
86
|
+
/** How long to wait for push diagnostics after a document changes. */
|
|
87
|
+
export const DIAGNOSTICS_TIMEOUT_MS = 15_000;
|
|
88
|
+
/**
|
|
89
|
+
* ⚠️ tsserver publishes an EMPTY diagnostic set the instant a file opens and the
|
|
90
|
+
* real one a beat later. Returning the first would report "no problems" for a
|
|
91
|
+
* file full of them — the most dangerous lie this module could tell. So after
|
|
92
|
+
* the first publish we wait this long for a revision and take the last.
|
|
93
|
+
*/
|
|
94
|
+
export const DIAGNOSTICS_QUIET_MS = 600;
|
|
95
|
+
/** Time given to a polite `shutdown` before the tree is killed. */
|
|
96
|
+
export const SHUTDOWN_GRACE_MS = 2_000;
|
|
97
|
+
/** A single LSP message larger than this is a runaway, not a response. */
|
|
98
|
+
export const MAX_MESSAGE_BYTES = 32 * 1024 * 1024;
|
|
99
|
+
/** A document we are willing to hand a server in full. */
|
|
100
|
+
export const MAX_OPEN_DOC_BYTES = 2_000_000;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* ── ⚠️ THE CAPS ARE THE POINT, NOT HOUSEKEEPING ─────────────────────────────
|
|
104
|
+
* A raw `textDocument/references` response for a common symbol is tens of
|
|
105
|
+
* thousands of tokens of JSON — ranges, URIs, nesting — of which the model needs
|
|
106
|
+
* a path, a line and enough text to recognise the site. React's `useState` in a
|
|
107
|
+
* real app returns hundreds of locations. Uncapped, one navigation call costs
|
|
108
|
+
* more than the entire rest of the round and can exceed the context window
|
|
109
|
+
* outright, which is not a slow answer but no answer at all.
|
|
110
|
+
*/
|
|
111
|
+
export const MAX_LOCATIONS = 25;
|
|
112
|
+
export const MAX_DIAGNOSTICS = 30;
|
|
113
|
+
export const MAX_SYMBOLS = 80;
|
|
114
|
+
/** Same 200 characters `search.mjs` clamps a match line to, for the same reason. */
|
|
115
|
+
export const EXCERPT_MAX_CHARS = 200;
|
|
116
|
+
export const MAX_DIAGNOSTIC_CHARS = 300;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* ── THE SERVER REGISTRY ─────────────────────────────────────────────────────
|
|
120
|
+
*
|
|
121
|
+
* ⚠️ FIXED, AND NOT READ FROM ANY FILE A MODEL CAN WRITE. `mcp.mjs` can take its
|
|
122
|
+
* server list from a config because a HUMAN authors that config and can review
|
|
123
|
+
* it. There is no equivalent here and there must not be: "which program do we
|
|
124
|
+
* spawn" is the one decision a language model never gets to make.
|
|
125
|
+
*
|
|
126
|
+
* `package` servers ship as npm packages and are started by running their own
|
|
127
|
+
* JavaScript entry point with the `node` we are already running — the only
|
|
128
|
+
* Windows-safe way, proven in `mcp.mjs` (`.cmd` shims cannot be spawned without
|
|
129
|
+
* a shell since CVE-2024-27980, and a shell is not on the table). `binary`
|
|
130
|
+
* servers are native executables found on PATH.
|
|
131
|
+
*
|
|
132
|
+
* ⭐ `workspace` (only `typescript` has one) is the SECOND half of readiness —
|
|
133
|
+
* see `workspaceCanBeServed` below. Three of the four servers ship everything
|
|
134
|
+
* they need: pyright bundles its own type checker, and rust-analyzer and gopls
|
|
135
|
+
* are single static binaries. `typescript-language-server` is the odd one out
|
|
136
|
+
* because it is not a language server at all — it is a translator in front of
|
|
137
|
+
* `tsserver`, and tsserver ships in a package it does not depend on.
|
|
138
|
+
*/
|
|
139
|
+
export const LANGUAGE_SERVERS = {
|
|
140
|
+
typescript: {
|
|
141
|
+
label: 'typescript-language-server',
|
|
142
|
+
package: 'typescript-language-server',
|
|
143
|
+
entries: ['lib/cli.mjs', 'lib/cli.js'],
|
|
144
|
+
args: ['--stdio'],
|
|
145
|
+
install: 'npm i -D typescript-language-server typescript',
|
|
146
|
+
/**
|
|
147
|
+
* ⚠️ COPIED FROM THE SERVER'S OWN SOURCE, NOT GUESSED — v5.3.0's
|
|
148
|
+
* `TypeScriptVersionProvider.getWorkspaceVersion()` calls
|
|
149
|
+
* `findPathToModule(workspaceRoot, MODULE_FOLDERS)` and joins `tsserver.js`
|
|
150
|
+
* onto whichever it finds. Mirroring its list rather than inventing one is
|
|
151
|
+
* what keeps this check from being STRICTER than the server it is
|
|
152
|
+
* predicting: the yarn-PnP and pnpify layouts have no `node_modules` at all,
|
|
153
|
+
* and a check that only looked there would withhold four working tools from
|
|
154
|
+
* every PnP repository.
|
|
155
|
+
*/
|
|
156
|
+
workspace: {
|
|
157
|
+
moduleFolders: ['node_modules/typescript/lib', '.vscode/pnpify/typescript/lib', '.yarn/sdks/typescript/lib'],
|
|
158
|
+
probe: 'tsserver.js',
|
|
159
|
+
/** Named in the refusal, because "did not initialize" teaches nobody anything. */
|
|
160
|
+
fix: 'npm i -D typescript',
|
|
161
|
+
needs: 'the `typescript` package (it drives `tsserver`; it does not contain one)',
|
|
162
|
+
},
|
|
163
|
+
extensions: {
|
|
164
|
+
'.ts': 'typescript',
|
|
165
|
+
'.mts': 'typescript',
|
|
166
|
+
'.cts': 'typescript',
|
|
167
|
+
'.tsx': 'typescriptreact',
|
|
168
|
+
'.js': 'javascript',
|
|
169
|
+
'.mjs': 'javascript',
|
|
170
|
+
'.cjs': 'javascript',
|
|
171
|
+
'.jsx': 'javascriptreact',
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
python: {
|
|
175
|
+
label: 'pyright-langserver',
|
|
176
|
+
package: 'pyright',
|
|
177
|
+
entries: ['langserver.index.js', 'dist/pyright-langserver.js'],
|
|
178
|
+
args: ['--stdio'],
|
|
179
|
+
install: 'npm i -D pyright',
|
|
180
|
+
extensions: { '.py': 'python', '.pyi': 'python' },
|
|
181
|
+
},
|
|
182
|
+
rust: {
|
|
183
|
+
label: 'rust-analyzer',
|
|
184
|
+
binary: 'rust-analyzer',
|
|
185
|
+
args: [],
|
|
186
|
+
install: 'rustup component add rust-analyzer',
|
|
187
|
+
extensions: { '.rs': 'rust' },
|
|
188
|
+
},
|
|
189
|
+
go: {
|
|
190
|
+
label: 'gopls',
|
|
191
|
+
binary: 'gopls',
|
|
192
|
+
// Bare `gopls` serves LSP on stdio; `serve` is the same thing spelled out.
|
|
193
|
+
args: [],
|
|
194
|
+
install: 'go install golang.org/x/tools/gopls@latest',
|
|
195
|
+
extensions: { '.go': 'go' },
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @typedef {{ ok: false, error: string, missing?: boolean }} LspRefused
|
|
201
|
+
* @typedef {{ path: string, line: number, column: number, excerpt: string }} LspLocation
|
|
202
|
+
* @typedef {{ ok: true, kind: string, path: string, count: number, shown: number, truncated: boolean, locations: LspLocation[] }} LspLocations
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
/** Which server handles this file, by extension. Null when nothing does. */
|
|
206
|
+
export function languageForFile(path) {
|
|
207
|
+
const ext = extname(String(path ?? '')).toLowerCase();
|
|
208
|
+
if (!ext) return null;
|
|
209
|
+
for (const [language, spec] of Object.entries(LANGUAGE_SERVERS)) {
|
|
210
|
+
if (spec.extensions[ext]) return language;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The `languageId` the server expects in `didOpen` — NOT the same as our key
|
|
216
|
+
* ('.tsx' is 'typescriptreact', and a server that is told 'typescript'
|
|
217
|
+
* silently mis-parses JSX). */
|
|
218
|
+
export function languageIdForFile(path) {
|
|
219
|
+
const ext = extname(String(path ?? '')).toLowerCase();
|
|
220
|
+
for (const spec of Object.values(LANGUAGE_SERVERS)) {
|
|
221
|
+
if (spec.extensions[ext]) return spec.extensions[ext];
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Walk up from the workspace looking for `node_modules`. Monorepos hoist, so the
|
|
228
|
+
* server a project depends on is very often two or three levels above the
|
|
229
|
+
* directory the CLI was pointed at.
|
|
230
|
+
*/
|
|
231
|
+
function nodeModulesDirs(root) {
|
|
232
|
+
const out = [];
|
|
233
|
+
let dir = resolve(root);
|
|
234
|
+
for (let i = 0; i < 6; i += 1) {
|
|
235
|
+
out.push(join(dir, 'node_modules'));
|
|
236
|
+
const parent = dirname(dir);
|
|
237
|
+
if (parent === dir) break;
|
|
238
|
+
dir = parent;
|
|
239
|
+
}
|
|
240
|
+
// The global install, last — a project-local server matches the project's own
|
|
241
|
+
// TypeScript version, and a global one is a fallback, never a preference.
|
|
242
|
+
const appData = process.env.APPDATA;
|
|
243
|
+
if (appData) out.push(join(appData, 'npm', 'node_modules'));
|
|
244
|
+
if (process.env.HOME) out.push(join(process.env.HOME, '.npm-global', 'lib', 'node_modules'));
|
|
245
|
+
out.push('/usr/local/lib/node_modules', '/usr/lib/node_modules');
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Find a native executable on PATH.
|
|
251
|
+
*
|
|
252
|
+
* ⚠️ ON WINDOWS A `.cmd` HIT IS A MISS. Node refuses to spawn `.cmd`/`.bat`
|
|
253
|
+
* without `shell: true` (the BatBadBut fix, CVE-2024-27980), and `shell: true`
|
|
254
|
+
* is not available to us for the reasons `mcp.mjs` documents at length. Finding
|
|
255
|
+
* one and reporting "installed" would produce an EINVAL from a path that
|
|
256
|
+
* demonstrably exists, which is the hardest kind of failure to diagnose. So it
|
|
257
|
+
* is reported as what it is: present, unusable, here is the way round it.
|
|
258
|
+
*/
|
|
259
|
+
export function resolveOnPath(command, env = process.env) {
|
|
260
|
+
const dirs = (env.PATH || env.Path || '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
|
|
261
|
+
const exts = process.platform === 'win32'
|
|
262
|
+
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
263
|
+
: [''];
|
|
264
|
+
for (const dir of dirs) {
|
|
265
|
+
// Extensions before the bare name: `mcp.mjs` shipped the other order for one
|
|
266
|
+
// commit and resolved to an extensionless bash script Windows cannot run.
|
|
267
|
+
for (const ext of process.platform === 'win32' ? [...exts, ''] : ['']) {
|
|
268
|
+
const candidate = join(dir, command + ext);
|
|
269
|
+
try {
|
|
270
|
+
if (!existsSync(candidate) || !statSync(candidate).isFile()) continue;
|
|
271
|
+
} catch { continue; /* unreadable PATH entry */ }
|
|
272
|
+
const lower = candidate.toLowerCase();
|
|
273
|
+
if (process.platform === 'win32' && (lower.endsWith('.cmd') || lower.endsWith('.bat'))) {
|
|
274
|
+
return { ok: false, shim: candidate };
|
|
275
|
+
}
|
|
276
|
+
return { ok: true, file: candidate };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Every directory from `root` up to the filesystem root.
|
|
284
|
+
*
|
|
285
|
+
* ⚠️ UNBOUNDED ON PURPOSE (past a sanity stop), because
|
|
286
|
+
* `typescript-language-server` is unbounded: its `findPathToModule` recurses to
|
|
287
|
+
* `/`. `nodeModulesDirs` above stops at six levels, which is right for "where
|
|
288
|
+
* might a server be installed" and WRONG here — a check that gave up two levels
|
|
289
|
+
* before the server does would report "cannot serve" for a deep monorepo the
|
|
290
|
+
* server serves perfectly, and withholding four working tools is the more
|
|
291
|
+
* expensive of the two mistakes.
|
|
292
|
+
*/
|
|
293
|
+
function ancestors(from, max = 64) {
|
|
294
|
+
const out = [];
|
|
295
|
+
let dir = resolve(from);
|
|
296
|
+
for (let i = 0; i < max; i += 1) {
|
|
297
|
+
out.push(dir);
|
|
298
|
+
const parent = dirname(dir);
|
|
299
|
+
if (parent === dir) break;
|
|
300
|
+
dir = parent;
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* ── ⚠️⚠️ THE READINESS LIE: INSTALLED ≠ ABLE TO SERVE THIS WORKSPACE ────────
|
|
307
|
+
*
|
|
308
|
+
* MEASURED ON THIS MACHINE, 2026-08-15, with `typescript-language-server`
|
|
309
|
+
* installed globally:
|
|
310
|
+
*
|
|
311
|
+
* · `console/` → discovery said yes, and `list_symbols` returned 8 real
|
|
312
|
+
* symbols with kinds and line numbers.
|
|
313
|
+
* · `acuvo-code/` → discovery said yes, and every verb died on the handshake:
|
|
314
|
+
* *"Could not find a valid TypeScript installation."*
|
|
315
|
+
*
|
|
316
|
+
* Same binary, same machine, opposite outcomes — because discovery only ever
|
|
317
|
+
* asked whether the SERVER EXECUTABLE was findable. `typescript-language-server`
|
|
318
|
+
* is a translator in front of `tsserver`, and tsserver ships inside the
|
|
319
|
+
* `typescript` package, which it does not depend on (v5.3.0's package.json
|
|
320
|
+
* declares no `dependencies` and no `peerDependencies` at all). acuvo-code is
|
|
321
|
+
* zero-dependency BY DESIGN and has no `node_modules`, so that server can never
|
|
322
|
+
* serve it — not today, not after any amount of retrying.
|
|
323
|
+
*
|
|
324
|
+
* The cost of the lie is not the failed call. It is that the model was OFFERED
|
|
325
|
+
* four tools, spent a round on one, was told something that reads like a broken
|
|
326
|
+
* install, and learned that semantic navigation does not work here. Four dead
|
|
327
|
+
* buttons are worse than no buttons, which is the same lesson `tools.mjs`
|
|
328
|
+
* already records for the language-not-spoken half of this gate.
|
|
329
|
+
*
|
|
330
|
+
* ── WHAT IT ACTUALLY NEEDS — MEASURED, NOT ASSUMED ─────────────────────────
|
|
331
|
+
* Four real spawns against real temp workspaces, 2026-08-15:
|
|
332
|
+
*
|
|
333
|
+
* | tsconfig.json | node_modules/typescript | result |
|
|
334
|
+
* |---------------|-------------------------|-----------------------|
|
|
335
|
+
* | yes | no | ❌ did not initialize |
|
|
336
|
+
* | no | yes | ✅ 1 symbol |
|
|
337
|
+
* | yes | yes | ✅ 1 symbol |
|
|
338
|
+
* | no | no | ❌ did not initialize |
|
|
339
|
+
*
|
|
340
|
+
* ⭐ So the answer to "a typescript install, or a tsconfig, or both" is
|
|
341
|
+
* NEITHER OF THE GUESSES: the `typescript` package alone, and a `tsconfig.json`
|
|
342
|
+
* is worth exactly nothing to initialization. That matters, because tsconfig is
|
|
343
|
+
* the intuitive thing to check for and checking it would have produced a gate
|
|
344
|
+
* that is wrong in both directions at once.
|
|
345
|
+
*
|
|
346
|
+
* ── ⭐ AND THAT IS WHY THIS STAYS SPAWN-FREE ────────────────────────────────
|
|
347
|
+
* The brief asked what to do if an honest answer needs a spawn — cache it, or
|
|
348
|
+
* probe lazily on first use and degrade with a good message. The research made
|
|
349
|
+
* the question moot, and that is the best available outcome: what the server
|
|
350
|
+
* needs is a FILE, so predicting it is `existsSync`, exactly as cheap as the
|
|
351
|
+
* dishonest version it replaces. `lspAvailable`'s promise that it "spawns
|
|
352
|
+
* nothing" survives intact, with no cache to go stale after an `npm install`
|
|
353
|
+
* and no first-call round spent discovering the truth.
|
|
354
|
+
*
|
|
355
|
+
* ⚠️ THE HONEST LIMIT: this predicts the server's own lookup rather than
|
|
356
|
+
* performing it, so it can still be wrong if the server changes its resolution
|
|
357
|
+
* order. It is deliberately biased toward YES — every path the server would try
|
|
358
|
+
* is tried here, so the failure mode is "we allowed a start that fails", i.e.
|
|
359
|
+
* exactly today's behaviour, never "we withheld a capability that worked".
|
|
360
|
+
*
|
|
361
|
+
* @param {string} root workspace directory
|
|
362
|
+
* @param {string} language registry key
|
|
363
|
+
* @param {{ serverFile?: string|null }} opts `serverFile` is the server's own
|
|
364
|
+
* entry point, used to emulate its bundled fallback. Optional; omitting it
|
|
365
|
+
* only makes the answer more conservative.
|
|
366
|
+
*/
|
|
367
|
+
export function workspaceCanBeServed(root, language, { serverFile = null } = {}) {
|
|
368
|
+
const spec = LANGUAGE_SERVERS[language];
|
|
369
|
+
// No stated requirement = nothing to check. pyright, rust-analyzer and gopls
|
|
370
|
+
// bring their own everything; saying "ready" for them is not a lie.
|
|
371
|
+
if (!spec?.workspace) return { ok: true, source: 'self-contained' };
|
|
372
|
+
if (typeof root !== 'string' || root === '') return { ok: true, source: 'unknown-root' };
|
|
373
|
+
|
|
374
|
+
const need = spec.workspace;
|
|
375
|
+
try {
|
|
376
|
+
for (const dir of ancestors(root)) {
|
|
377
|
+
for (const folder of need.moduleFolders) {
|
|
378
|
+
const candidate = join(dir, ...folder.split('/'), need.probe);
|
|
379
|
+
try {
|
|
380
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
381
|
+
return { ok: true, source: 'workspace', via: candidate };
|
|
382
|
+
}
|
|
383
|
+
} catch { /* unreadable level on the way up */ }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} catch { /* an unresolvable root is not this function's failure to report */ }
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* ⭐ THE BUNDLED FALLBACK, and it is not theoretical — it is the server's last
|
|
390
|
+
* resort (`bundledVersion()`: `require.resolve('typescript')` from its own
|
|
391
|
+
* location, then `tsserver.js` beside the entry point). Emulating it with the
|
|
392
|
+
* real `createRequire` rather than a hand-rolled walk is the point: node's
|
|
393
|
+
* resolution has rules (scoped dirs, `exports` maps, symlink realpaths) that a
|
|
394
|
+
* reimplementation gets subtly wrong, and being wrong HERE means withholding a
|
|
395
|
+
* capability that works.
|
|
396
|
+
*
|
|
397
|
+
* ⚠️ MEASURED, and it is why acuvo-code still fails: the global npm root here
|
|
398
|
+
* holds `typescript@7.0.2`, whose `require.resolve` lands on `lib/version.cjs`
|
|
399
|
+
* — and whose `lib/` contains no `tsserver.js` at all, because TypeScript 7 is
|
|
400
|
+
* the Go rewrite and ships `tsgo` instead. A globally-installed `typescript`
|
|
401
|
+
* therefore does NOT rescue a workspace, and a check that assumed it did would
|
|
402
|
+
* have gone on offering four dead buttons while looking rigorous.
|
|
403
|
+
*/
|
|
404
|
+
if (serverFile) {
|
|
405
|
+
try {
|
|
406
|
+
const req = createRequire(pathToFileURL(serverFile));
|
|
407
|
+
const main = req.resolve('typescript');
|
|
408
|
+
const bundled = join(dirname(main), need.probe);
|
|
409
|
+
if (existsSync(bundled) && statSync(bundled).isFile()) {
|
|
410
|
+
return { ok: true, source: 'bundled', via: bundled };
|
|
411
|
+
}
|
|
412
|
+
} catch { /* not resolvable from there, which is the common case */ }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
error: [
|
|
418
|
+
`${spec.label} is installed but cannot serve ${root}: it needs ${need.needs},`,
|
|
419
|
+
`and there is none in this workspace or any directory above it.`,
|
|
420
|
+
`THE FIX IS ONE COMMAND, run in this workspace: ${need.fix}.`,
|
|
421
|
+
// ⚠️ Named explicitly because it is the intuitive wrong answer, and a
|
|
422
|
+
// model that adds a tsconfig will be told the same thing again.
|
|
423
|
+
`A tsconfig.json does NOT satisfy this — measured; only the package does.`,
|
|
424
|
+
`Until then use search_text to find the symbol and read_file to read around it,`,
|
|
425
|
+
`and do not call the ${language} navigation tools again in this run.`,
|
|
426
|
+
].join(' '),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Where is this language's server, if anywhere — AND can it serve this root?
|
|
432
|
+
*
|
|
433
|
+
* ⚠️ NEVER THROWS, AND "NOT INSTALLED" IS THE EXPECTED ANSWER. Most machines
|
|
434
|
+
* have a server for one language and none for the rest. The refusal text is
|
|
435
|
+
* written for a model: it names the install command AND names the fallback,
|
|
436
|
+
* because an error that only says no is an error that gets retried.
|
|
437
|
+
*
|
|
438
|
+
* ⚠️ BOTH HALVES BY DEFAULT, and this is a behaviour change with teeth: this
|
|
439
|
+
* function is what `tools.mjs`'s `lspAvailable` consults, so tightening it here
|
|
440
|
+
* closes the readiness lie everywhere at once with no wiring. `require-
|
|
441
|
+
* WorkspaceSupport: false` exists for the caller that genuinely only wants
|
|
442
|
+
* "is the program on this machine" — `doctor`, a fixture — and for nothing else.
|
|
443
|
+
*/
|
|
444
|
+
export function discoverLanguageServer(root, language, { env = process.env, requireWorkspaceSupport = true } = {}) {
|
|
445
|
+
const spec = LANGUAGE_SERVERS[language];
|
|
446
|
+
if (!spec) {
|
|
447
|
+
return {
|
|
448
|
+
ok: false,
|
|
449
|
+
error: `no language server is configured for "${language}". This tool covers ${Object.keys(LANGUAGE_SERVERS).join(', ')}; for anything else use search_text and read_file.`,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* ⚠️ FOUND BY THE READINESS TEST, AND IT PREDATES IT: `nodeModulesDirs` calls
|
|
454
|
+
* `resolve(root)`, which THROWS on `undefined` — "The paths[0] argument must
|
|
455
|
+
* be of type string". It never surfaced because the one caller,
|
|
456
|
+
* `lspAvailable`, wraps the whole thing in a try/catch, so a latent crash was
|
|
457
|
+
* being absorbed by somebody else's defensiveness. Any new caller would have
|
|
458
|
+
* got the throw. This function's contract is "never throws"; make it true here
|
|
459
|
+
* rather than hope every caller keeps guarding.
|
|
460
|
+
*/
|
|
461
|
+
if (typeof root !== 'string' || root === '') {
|
|
462
|
+
return { ok: false, missing: true, error: `${spec.label}: no workspace directory was given, so there is nothing to check. Semantic navigation needs a real project root.` };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (spec.package) {
|
|
466
|
+
/**
|
|
467
|
+
* ⚠️ THE WORKSPACE CHECK RUNS PER CANDIDATE AND A FAILURE DOES NOT STOP THE
|
|
468
|
+
* SEARCH. The bundled fallback depends on WHICH install we found, so a
|
|
469
|
+
* project-local server whose sibling `typescript` exists must still win
|
|
470
|
+
* after a global one without it was seen first. Only the first failure is
|
|
471
|
+
* remembered, so the message names a real install rather than the last one
|
|
472
|
+
* tried.
|
|
473
|
+
*/
|
|
474
|
+
let blocked = null;
|
|
475
|
+
for (const modules of nodeModulesDirs(root)) {
|
|
476
|
+
for (const entry of spec.entries) {
|
|
477
|
+
const candidate = join(modules, spec.package, ...entry.split('/'));
|
|
478
|
+
try {
|
|
479
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
480
|
+
const servable = requireWorkspaceSupport
|
|
481
|
+
? workspaceCanBeServed(root, language, { serverFile: candidate })
|
|
482
|
+
: { ok: true, source: 'unchecked' };
|
|
483
|
+
if (!servable.ok) {
|
|
484
|
+
if (!blocked) blocked = { ok: false, missing: true, unservable: true, error: servable.error };
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
ok: true,
|
|
489
|
+
language,
|
|
490
|
+
label: spec.label,
|
|
491
|
+
// Our own node, running the server's own JS. No shim, no PATH
|
|
492
|
+
// lookup of anything but the interpreter already running.
|
|
493
|
+
file: process.execPath,
|
|
494
|
+
argv: [candidate, ...spec.args],
|
|
495
|
+
via: candidate,
|
|
496
|
+
tsserverFrom: servable.via ?? null,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
} catch { /* unreadable directory on the way up */ }
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (blocked) return blocked;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (spec.binary) {
|
|
506
|
+
const found = resolveOnPath(spec.binary, env);
|
|
507
|
+
if (found?.ok) {
|
|
508
|
+
// ⭐ A no-op for every server in the registry today (none of the native
|
|
509
|
+
// binaries declares a `workspace` requirement) — present so the field
|
|
510
|
+
// means the same thing on both branches. A future binary server that DOES
|
|
511
|
+
// need something in the tree gets the honest gate for free rather than
|
|
512
|
+
// reintroducing the lie on the branch nobody remembered to update.
|
|
513
|
+
const servable = requireWorkspaceSupport
|
|
514
|
+
? workspaceCanBeServed(root, language, { serverFile: found.file })
|
|
515
|
+
: { ok: true, source: 'unchecked' };
|
|
516
|
+
if (!servable.ok) return { ok: false, missing: true, unservable: true, error: servable.error };
|
|
517
|
+
return { ok: true, language, label: spec.label, file: found.file, argv: [...spec.args], via: found.file, tsserverFrom: servable.via ?? null };
|
|
518
|
+
}
|
|
519
|
+
if (found?.shim) {
|
|
520
|
+
return {
|
|
521
|
+
ok: false,
|
|
522
|
+
missing: true,
|
|
523
|
+
error: `${spec.label} was found at ${found.shim}, but Node cannot start a .cmd shim without a shell and this agent never uses one. Install the package into the project instead (${spec.install}) so it can be started directly. Meanwhile search_text and read_file still work.`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
missing: true,
|
|
531
|
+
error: `${spec.label} is not installed, so semantic navigation is unavailable for ${language} on this machine. Install it with: ${spec.install}. Do not call this tool again for ${language} in this run — use search_text to find the symbol and read_file to read around it.`,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* ── THE WIRE ────────────────────────────────────────────────────────────────
|
|
537
|
+
* `Content-Length: N\r\n\r\n<N bytes of JSON>`.
|
|
538
|
+
*/
|
|
539
|
+
export function encodeMessage(obj) {
|
|
540
|
+
const body = Buffer.from(JSON.stringify(obj), 'utf8');
|
|
541
|
+
return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Pull as many complete messages as the buffer holds.
|
|
546
|
+
*
|
|
547
|
+
* ⚠️ THE CONTRACT IS "GIVE ME BYTES, ANY BYTES". A TCP-ish stream splits
|
|
548
|
+
* wherever it likes: mid-header, mid-JSON, mid-CHARACTER. Every test in
|
|
549
|
+
* `lsp.test.mjs` that feeds this one byte at a time exists because getting it
|
|
550
|
+
* subtly wrong is invisible on a fast local server and corrupt under load —
|
|
551
|
+
* which is to say, invisible until it matters.
|
|
552
|
+
*
|
|
553
|
+
* Returns `{ messages, rest, error }`. An `error` means the stream can no longer
|
|
554
|
+
* be trusted; the caller tears the connection down rather than resynchronising,
|
|
555
|
+
* because there is no framing marker to resynchronise ON.
|
|
556
|
+
*/
|
|
557
|
+
export function decodeMessages(buffer) {
|
|
558
|
+
const messages = [];
|
|
559
|
+
let rest = buffer;
|
|
560
|
+
for (;;) {
|
|
561
|
+
const headerEnd = rest.indexOf('\r\n\r\n');
|
|
562
|
+
if (headerEnd === -1) {
|
|
563
|
+
// A header this long is not a header. Refuse to buffer forever.
|
|
564
|
+
if (rest.length > 8 * 1024) return { messages, rest, error: 'no message header in the first 8KB of the stream' };
|
|
565
|
+
return { messages, rest };
|
|
566
|
+
}
|
|
567
|
+
const header = rest.subarray(0, headerEnd).toString('ascii');
|
|
568
|
+
const match = /content-length:\s*(\d+)/i.exec(header);
|
|
569
|
+
if (!match) return { messages, rest, error: `message header has no Content-Length: ${JSON.stringify(header.slice(0, 120))}` };
|
|
570
|
+
const length = Number(match[1]);
|
|
571
|
+
if (!Number.isSafeInteger(length) || length < 0 || length > MAX_MESSAGE_BYTES) {
|
|
572
|
+
return { messages, rest, error: `refusing a ${length}-byte message (limit ${MAX_MESSAGE_BYTES})` };
|
|
573
|
+
}
|
|
574
|
+
const start = headerEnd + 4;
|
|
575
|
+
if (rest.length < start + length) return { messages, rest }; // the body has not all arrived
|
|
576
|
+
const body = rest.subarray(start, start + length).toString('utf8');
|
|
577
|
+
rest = rest.subarray(start + length);
|
|
578
|
+
try {
|
|
579
|
+
messages.push(JSON.parse(body));
|
|
580
|
+
} catch (err) {
|
|
581
|
+
// One unparseable body does not have to poison the stream: the framing
|
|
582
|
+
// told us exactly where it ended, so the next message is still findable.
|
|
583
|
+
messages.push({ __parseError: err instanceof Error ? err.message : String(err) });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* ⚠️ THE SERVER-TO-CLIENT REQUESTS THAT MUST BE ANSWERED.
|
|
590
|
+
*
|
|
591
|
+
* A language server is a peer, not a service. It will ask the client to register
|
|
592
|
+
* capabilities and — if we advertise it — for configuration, and several servers
|
|
593
|
+
* WAIT for the reply. An unanswered request presents as the server never
|
|
594
|
+
* finishing its startup scan, i.e. as our timeout, i.e. as "the server is
|
|
595
|
+
* broken". Answering minimally is what keeps that from happening.
|
|
596
|
+
*/
|
|
597
|
+
function answerServerRequest(msg) {
|
|
598
|
+
switch (msg.method) {
|
|
599
|
+
case 'client/registerCapability':
|
|
600
|
+
case 'client/unregisterCapability':
|
|
601
|
+
case 'window/workDoneProgress/create':
|
|
602
|
+
return { result: null };
|
|
603
|
+
case 'workspace/configuration':
|
|
604
|
+
// One null per requested item: "no opinion, use your defaults."
|
|
605
|
+
return { result: (msg.params?.items ?? [null]).map(() => null) };
|
|
606
|
+
case 'workspace/applyEdit':
|
|
607
|
+
// ⚠️ Refused on purpose. A server-driven write would bypass edit.mjs and
|
|
608
|
+
// land on disk with no diff and no size cap. See the header.
|
|
609
|
+
return { result: { applied: false, failureReason: 'this client is read-only' } };
|
|
610
|
+
default:
|
|
611
|
+
return { error: { code: -32601, message: `acuvo-code does not implement ${msg.method}` } };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function createRpc(child, { onNotification, onBroken }) {
|
|
616
|
+
let nextId = 1;
|
|
617
|
+
const pending = new Map();
|
|
618
|
+
let buffer = Buffer.alloc(0);
|
|
619
|
+
let broken = null;
|
|
620
|
+
|
|
621
|
+
const failAll = (why) => {
|
|
622
|
+
broken = why;
|
|
623
|
+
for (const [id, entry] of [...pending]) {
|
|
624
|
+
pending.delete(id);
|
|
625
|
+
entry.resolve({ error: { message: why } });
|
|
626
|
+
}
|
|
627
|
+
if (onBroken) onBroken(why);
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// ⚠️ NO setEncoding. See the header: the framing is counted in bytes.
|
|
631
|
+
child.stdout.on('data', (chunk) => {
|
|
632
|
+
buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
|
|
633
|
+
const { messages, rest, error } = decodeMessages(buffer);
|
|
634
|
+
buffer = rest;
|
|
635
|
+
for (const msg of messages) {
|
|
636
|
+
if (msg.__parseError) continue;
|
|
637
|
+
if (msg.id !== undefined && msg.method) {
|
|
638
|
+
// A request FROM the server.
|
|
639
|
+
const answer = answerServerRequest(msg);
|
|
640
|
+
try { child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: msg.id, ...answer })); } catch { /* dying */ }
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
644
|
+
const entry = pending.get(msg.id);
|
|
645
|
+
pending.delete(msg.id);
|
|
646
|
+
entry.resolve(msg);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (msg.method && onNotification) onNotification(msg);
|
|
650
|
+
}
|
|
651
|
+
if (error) failAll(`the language server's output could not be framed (${error})`);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
get broken() { return broken; },
|
|
656
|
+
failAll,
|
|
657
|
+
request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
658
|
+
if (broken) return Promise.resolve({ error: { message: broken } });
|
|
659
|
+
const id = nextId++;
|
|
660
|
+
return new Promise((res) => {
|
|
661
|
+
const timer = setTimeout(() => {
|
|
662
|
+
pending.delete(id);
|
|
663
|
+
res({ error: { message: `${method} got no answer in ${Math.round(timeoutMs / 1000)}s` } });
|
|
664
|
+
}, timeoutMs);
|
|
665
|
+
/**
|
|
666
|
+
* ⚠️ DELIBERATELY REF'D, and this line is load-bearing. The server's
|
|
667
|
+
* stdio is unref'd (see child-lifetime.mjs), so an IDLE server no longer
|
|
668
|
+
* holds the process open — which is the whole point. But that removes
|
|
669
|
+
* the last anchor while a request is in flight too, and Node then
|
|
670
|
+
* decides the loop is empty and settles with the promise still pending:
|
|
671
|
+
* `Promise resolution is still pending but the event loop has already
|
|
672
|
+
* resolved`. Measured — it broke twelve tests in this file.
|
|
673
|
+
*
|
|
674
|
+
* So the invariant is: an idle helper never holds the process, a
|
|
675
|
+
* REQUEST does. This timer is that anchor, it is bounded by
|
|
676
|
+
* REQUEST_TIMEOUT_MS, and every resolution path clears it.
|
|
677
|
+
*/
|
|
678
|
+
pending.set(id, { resolve: (m) => { clearTimeout(timer); res(m); } });
|
|
679
|
+
try {
|
|
680
|
+
child.stdin.write(encodeMessage({ jsonrpc: '2.0', id, method, params }));
|
|
681
|
+
} catch (err) {
|
|
682
|
+
clearTimeout(timer);
|
|
683
|
+
pending.delete(id);
|
|
684
|
+
res({ error: { message: `could not write to the language server: ${err?.message ?? err}` } });
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
},
|
|
688
|
+
notify(method, params) {
|
|
689
|
+
if (broken) return;
|
|
690
|
+
try { child.stdin.write(encodeMessage({ jsonrpc: '2.0', method, params })); } catch { /* dying anyway */ }
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* ── ⚠️ THE CHILD MUST DIE, AND `child.kill()` IS NOT ENOUGH ─────────────────
|
|
697
|
+
*
|
|
698
|
+
* `typescript-language-server` forks `tsserver`; `pyright-langserver` forks
|
|
699
|
+
* workers. Killing the pid we hold leaves the expensive process running with a
|
|
700
|
+
* dead parent — a true orphan that survives until reboot, which is exactly the
|
|
701
|
+
* leak that has been cooking this laptop.
|
|
702
|
+
*
|
|
703
|
+
* This is `command.mjs`'s `killProcessTree`, reimplemented rather than imported
|
|
704
|
+
* because that one is module-private there and this file may not edit it. The
|
|
705
|
+
* duplication is deliberate and is noted so a later reader does not "tidy" one
|
|
706
|
+
* of them away: if `command.mjs` ever exports it, delete this.
|
|
707
|
+
*/
|
|
708
|
+
function killProcessTree(child) {
|
|
709
|
+
const pid = child?.pid;
|
|
710
|
+
if (typeof pid !== 'number' || pid <= 0) {
|
|
711
|
+
try { child?.kill?.('SIGKILL'); } catch { /* already gone */ }
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (process.platform === 'win32') {
|
|
715
|
+
try {
|
|
716
|
+
const reaper = spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { windowsHide: true, stdio: 'ignore', shell: false });
|
|
717
|
+
reaper.on('error', () => { /* taskkill missing, or the pid already went */ });
|
|
718
|
+
if (typeof reaper.unref === 'function') reaper.unref();
|
|
719
|
+
} catch { /* already gone */ }
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
process.kill(-pid, 'SIGKILL');
|
|
724
|
+
} catch {
|
|
725
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* ── THE PROCESS-LIFETIME REGISTRY ───────────────────────────────────────────
|
|
731
|
+
* Same shape as `turn.mjs`'s MCP registry, and for the same two measured
|
|
732
|
+
* reasons: a listener registered per session leaks one per turn, and an `exit`
|
|
733
|
+
* listener does not fire on a signal. A language server that outlives Ctrl-C is
|
|
734
|
+
* the single most expensive thing this file could leave behind.
|
|
735
|
+
*/
|
|
736
|
+
const liveSessions = new Set();
|
|
737
|
+
let hooksInstalled = false;
|
|
738
|
+
|
|
739
|
+
function installLifecycleHooks() {
|
|
740
|
+
if (hooksInstalled) return;
|
|
741
|
+
hooksInstalled = true;
|
|
742
|
+
// ⚠️ SYNCHRONOUS on 'exit' — nothing async runs there, so the polite
|
|
743
|
+
// `shutdown` handshake is not an option and the tree kill is all there is.
|
|
744
|
+
process.once('exit', () => { for (const s of [...liveSessions]) hardStop(s); });
|
|
745
|
+
for (const [sig, code] of [['SIGINT', 130], ['SIGTERM', 143], ['SIGBREAK', 149]]) {
|
|
746
|
+
try {
|
|
747
|
+
process.on(sig, () => {
|
|
748
|
+
for (const s of [...liveSessions]) hardStop(s);
|
|
749
|
+
// ⚠️ The language servers are killed either way; only the exit waits.
|
|
750
|
+
// See lib/interrupt.mjs — a first Ctrl-C asks the run to stop cleanly
|
|
751
|
+
// so its session and audit line survive.
|
|
752
|
+
if (!exitIsDeferred()) process.exit(code);
|
|
753
|
+
});
|
|
754
|
+
} catch { /* signal not supported on this platform */ }
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function hardStop(session) {
|
|
759
|
+
liveSessions.delete(session);
|
|
760
|
+
session.stopped = true;
|
|
761
|
+
try { killProcessTree(session.child); } catch { /* already gone */ }
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Windows compares paths case-insensitively, and servers echo URIs back with
|
|
765
|
+
* their own drive-letter casing and percent-encoding. Match on the real path. */
|
|
766
|
+
function uriKey(uri) {
|
|
767
|
+
let path;
|
|
768
|
+
try { path = fileURLToPath(uri); } catch { path = String(uri); }
|
|
769
|
+
return process.platform === 'win32' ? path.toLowerCase() : path;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Start a server and complete the handshake.
|
|
774
|
+
*
|
|
775
|
+
* `server` and `spawnImpl` are injection seams for tests and for nothing else —
|
|
776
|
+
* ⚠️ no tool schema reaches them, so a model can never name the program. The
|
|
777
|
+
* normal path calls `discoverLanguageServer` itself, two lines down.
|
|
778
|
+
*/
|
|
779
|
+
export async function startLanguageServer(root, {
|
|
780
|
+
language,
|
|
781
|
+
server = null,
|
|
782
|
+
spawnImpl = spawn,
|
|
783
|
+
env = process.env,
|
|
784
|
+
handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS,
|
|
785
|
+
} = {}) {
|
|
786
|
+
let realRoot;
|
|
787
|
+
try {
|
|
788
|
+
realRoot = resolve(root);
|
|
789
|
+
if (!statSync(realRoot).isDirectory()) return { ok: false, error: `not a directory: ${root}` };
|
|
790
|
+
} catch {
|
|
791
|
+
return { ok: false, error: `workspace directory does not exist: ${root}` };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const found = server ?? discoverLanguageServer(realRoot, language, { env });
|
|
795
|
+
if (!found.ok) return found;
|
|
796
|
+
|
|
797
|
+
let child;
|
|
798
|
+
try {
|
|
799
|
+
child = spawnImpl(found.file, found.argv, {
|
|
800
|
+
cwd: realRoot,
|
|
801
|
+
env: { ...env },
|
|
802
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
803
|
+
shell: false,
|
|
804
|
+
windowsHide: true,
|
|
805
|
+
// POSIX only: makes the child a process-group leader so `kill(-pid)` can
|
|
806
|
+
// reach what IT forked. On Windows `taskkill /T` does the same job.
|
|
807
|
+
...(process.platform === 'win32' ? {} : { detached: true }),
|
|
808
|
+
});
|
|
809
|
+
} catch (err) {
|
|
810
|
+
return { ok: false, error: `could not start ${found.label}: ${err?.message ?? err}` };
|
|
811
|
+
}
|
|
812
|
+
// A failed spawn leaves stdio null and `createRpc` would throw on it — the
|
|
813
|
+
// exact silent crash `mcp.mjs` documents.
|
|
814
|
+
if (!child?.stdout || !child?.stdin) {
|
|
815
|
+
return { ok: false, error: `could not start ${found.label} (no stdio — is ${found.via ?? found.file} really executable?)` };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* ⚠️ A language server must not decide when acuvo exits. Without this, any
|
|
820
|
+
* session that touched an LSP hangs at the end with no message — see
|
|
821
|
+
* lib/child-lifetime.mjs for the full account and why unreffing the child
|
|
822
|
+
* alone is not enough.
|
|
823
|
+
*/
|
|
824
|
+
detachChild(child);
|
|
825
|
+
|
|
826
|
+
let died = null;
|
|
827
|
+
let stderrTail = '';
|
|
828
|
+
child.on('error', (e) => { died = e?.message ?? String(e); });
|
|
829
|
+
child.stderr?.setEncoding('utf8');
|
|
830
|
+
child.stderr?.on('data', (d) => { stderrTail = (stderrTail + d).slice(-800); });
|
|
831
|
+
|
|
832
|
+
/** @type {any} */
|
|
833
|
+
const session = {
|
|
834
|
+
ok: true,
|
|
835
|
+
language,
|
|
836
|
+
label: found.label,
|
|
837
|
+
root: realRoot,
|
|
838
|
+
child,
|
|
839
|
+
docs: new Map(), // real path → { uri, version, text, languageId }
|
|
840
|
+
published: new Map(), // uriKey → { items, seq }
|
|
841
|
+
waiters: new Map(), // uriKey → [resolve]
|
|
842
|
+
seq: 0,
|
|
843
|
+
stopped: false,
|
|
844
|
+
capabilities: {},
|
|
845
|
+
get stderr() { return stderrTail; },
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
session.rpc = createRpc(child, {
|
|
849
|
+
onNotification: (msg) => {
|
|
850
|
+
if (msg.method !== 'textDocument/publishDiagnostics') return;
|
|
851
|
+
const key = uriKey(msg.params?.uri ?? '');
|
|
852
|
+
session.seq += 1;
|
|
853
|
+
session.published.set(key, { items: msg.params?.diagnostics ?? [], seq: session.seq });
|
|
854
|
+
const waiting = session.waiters.get(key);
|
|
855
|
+
if (waiting) { session.waiters.delete(key); for (const w of waiting) w(); }
|
|
856
|
+
},
|
|
857
|
+
onBroken: () => { /* pending calls already rejected; shutdown handles the rest */ },
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
child.on('exit', () => {
|
|
861
|
+
session.exited = true;
|
|
862
|
+
session.rpc.failAll(`${found.label} exited${stderrTail ? ` — ${stderrTail.trim().slice(0, 200)}` : ''}`);
|
|
863
|
+
for (const [, waiting] of session.waiters) for (const w of waiting) w();
|
|
864
|
+
session.waiters.clear();
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
liveSessions.add(session);
|
|
868
|
+
installLifecycleHooks();
|
|
869
|
+
|
|
870
|
+
const init = await session.rpc.request('initialize', {
|
|
871
|
+
processId: process.pid,
|
|
872
|
+
clientInfo: { name: 'acuvo-code', version: '0.2.0' },
|
|
873
|
+
rootUri: pathToFileURL(realRoot).href,
|
|
874
|
+
rootPath: realRoot,
|
|
875
|
+
workspaceFolders: [{ uri: pathToFileURL(realRoot).href, name: 'workspace' }],
|
|
876
|
+
initializationOptions: {},
|
|
877
|
+
capabilities: {
|
|
878
|
+
textDocument: {
|
|
879
|
+
synchronization: { dynamicRegistration: false, didSave: false, willSave: false },
|
|
880
|
+
definition: { dynamicRegistration: false, linkSupport: true },
|
|
881
|
+
references: { dynamicRegistration: false },
|
|
882
|
+
documentSymbol: { dynamicRegistration: false, hierarchicalDocumentSymbolSupport: true },
|
|
883
|
+
publishDiagnostics: { relatedInformation: false, versionSupport: false },
|
|
884
|
+
diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false },
|
|
885
|
+
},
|
|
886
|
+
// ⚠️ `configuration: false` on purpose — declaring it invites
|
|
887
|
+
// `workspace/configuration` round-trips we have nothing to say to.
|
|
888
|
+
workspace: { workspaceFolders: true, configuration: false },
|
|
889
|
+
window: { workDoneProgress: false },
|
|
890
|
+
general: { positionEncodings: ['utf-16'] },
|
|
891
|
+
},
|
|
892
|
+
}, handshakeTimeoutMs);
|
|
893
|
+
|
|
894
|
+
if (init?.error || died) {
|
|
895
|
+
await stopLanguageServer(session);
|
|
896
|
+
return {
|
|
897
|
+
ok: false,
|
|
898
|
+
// ⭐ stderr is included because a server's real complaint ("Cannot find
|
|
899
|
+
// module 'typescript'") arrives there and never in the RPC error.
|
|
900
|
+
error: `${found.label} did not initialize: ${init?.error?.message ?? died}${stderrTail ? ` — ${stderrTail.trim().slice(0, 300)}` : ''}`,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
session.capabilities = init?.result?.capabilities ?? {};
|
|
904
|
+
session.rpc.notify('initialized', {});
|
|
905
|
+
return session;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Stop it. Politely, then not.
|
|
910
|
+
*
|
|
911
|
+
* ⚠️ THE POLITE HALF IS NOT MANNERS. `shutdown` is what makes the wrapper stop
|
|
912
|
+
* ITS child; a straight tree-kill usually works too, but on Windows a taskkill
|
|
913
|
+
* that races the fork can miss a grandchild that had not been created yet.
|
|
914
|
+
* Graceful first, grace window, then the tree.
|
|
915
|
+
*
|
|
916
|
+
* ⚠️ AND THE PART THAT IS A TRUST, NOT A GUARANTEE: when the server DOES exit
|
|
917
|
+
* politely we do not tree-kill, because by then there is no tree to walk — on
|
|
918
|
+
* Windows `taskkill /T` needs the parent alive to enumerate its children. So the
|
|
919
|
+
* graceful path relies on the server reaping what it forked, which
|
|
920
|
+
* `typescript-language-server` does (it stops tsserver on `shutdown`; that is
|
|
921
|
+
* what `shutdown` is FOR). The ungraceful path does not trust anything, and
|
|
922
|
+
* `lsp.test.mjs` proves the grandchild dies there.
|
|
923
|
+
*/
|
|
924
|
+
export async function stopLanguageServer(session, { graceMs = SHUTDOWN_GRACE_MS } = {}) {
|
|
925
|
+
if (!session || session.stopped) return { ok: true, alreadyStopped: true };
|
|
926
|
+
session.stopped = true;
|
|
927
|
+
liveSessions.delete(session);
|
|
928
|
+
|
|
929
|
+
const exited = new Promise((res) => {
|
|
930
|
+
if (session.exited) return res(true);
|
|
931
|
+
session.child.once('exit', () => res(true));
|
|
932
|
+
const t = setTimeout(() => res(false), graceMs);
|
|
933
|
+
/** ?? REF'D: stdio is unref'd, so this bounded wait is the only anchor. See child-lifetime.mjs. */
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
try {
|
|
937
|
+
// Short timeout: a wedged server is precisely the one that will not answer.
|
|
938
|
+
await session.rpc.request('shutdown', null, Math.min(graceMs, 1_500));
|
|
939
|
+
session.rpc.notify('exit', null);
|
|
940
|
+
try { session.child.stdin.end(); } catch { /* already closed */ }
|
|
941
|
+
} catch { /* it is going to be killed either way */ }
|
|
942
|
+
|
|
943
|
+
const wentQuietly = await exited;
|
|
944
|
+
if (!wentQuietly) killProcessTree(session.child);
|
|
945
|
+
return { ok: true, graceful: wentQuietly };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Every server this process started. Called by the exit hooks, and by tests. */
|
|
949
|
+
export async function stopAllLanguageServers() {
|
|
950
|
+
const all = [...liveSessions];
|
|
951
|
+
await Promise.all(all.map((s) => stopLanguageServer(s)));
|
|
952
|
+
sessionCache.clear();
|
|
953
|
+
return all.length;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* ── THE SESSION CACHE ───────────────────────────────────────────────────────
|
|
958
|
+
* tsserver takes seconds to cold-start and then answers in milliseconds. Paying
|
|
959
|
+
* that once per tool call would make semantic navigation slower than grep, which
|
|
960
|
+
* is the one way to make a correct feature useless. Keyed by root+language, and
|
|
961
|
+
* every entry is in `liveSessions`, so the exit hooks still reach it.
|
|
962
|
+
*/
|
|
963
|
+
const sessionCache = new Map();
|
|
964
|
+
|
|
965
|
+
export async function getLanguageServer(root, language, opts = {}) {
|
|
966
|
+
const key = `${resolve(root)}::${language}`;
|
|
967
|
+
const existing = sessionCache.get(key);
|
|
968
|
+
if (existing) {
|
|
969
|
+
const settled = await existing;
|
|
970
|
+
if (settled.ok && !settled.stopped && !settled.exited) return settled;
|
|
971
|
+
sessionCache.delete(key);
|
|
972
|
+
}
|
|
973
|
+
const started = startLanguageServer(root, { ...opts, language });
|
|
974
|
+
sessionCache.set(key, started);
|
|
975
|
+
const settled = await started;
|
|
976
|
+
if (!settled.ok) sessionCache.delete(key);
|
|
977
|
+
return settled;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* ── DOCUMENT SYNC ───────────────────────────────────────────────────────────
|
|
982
|
+
* Read the file, open it if new, `didChange` it if the bytes moved.
|
|
983
|
+
*
|
|
984
|
+
* ⚠️ THE `didChange` IS THE WHOLE REASON THIS FUNCTION EXISTS. See header note
|
|
985
|
+
* (3): without it the second question about a file the model just rewrote is
|
|
986
|
+
* answered from the first version, confidently and wrongly.
|
|
987
|
+
*/
|
|
988
|
+
function syncDocument(session, resolved) {
|
|
989
|
+
let stat;
|
|
990
|
+
try { stat = statSync(resolved.absolute); } catch { return { ok: false, error: `no such file: ${resolved.relative}` }; }
|
|
991
|
+
if (stat.isDirectory()) return { ok: false, error: `${resolved.relative} is a directory — name a source file` };
|
|
992
|
+
if (stat.size > MAX_OPEN_DOC_BYTES) {
|
|
993
|
+
return { ok: false, error: `${resolved.relative} is ${stat.size} bytes, over the ${MAX_OPEN_DOC_BYTES}-byte limit for a document handed to a language server` };
|
|
994
|
+
}
|
|
995
|
+
let text;
|
|
996
|
+
try { text = readFileSync(resolved.absolute, 'utf8'); } catch (err) {
|
|
997
|
+
return { ok: false, error: `could not read ${resolved.relative}: ${err instanceof Error ? err.message : String(err)}` };
|
|
998
|
+
}
|
|
999
|
+
if (text.includes('\u0000')) return { ok: false, error: `${resolved.relative} looks binary — a language server cannot parse it` };
|
|
1000
|
+
|
|
1001
|
+
const languageId = languageIdForFile(resolved.relative);
|
|
1002
|
+
if (!languageId) return { ok: false, error: `no language server handles ${resolved.relative}` };
|
|
1003
|
+
|
|
1004
|
+
const uri = pathToFileURL(resolved.absolute).href;
|
|
1005
|
+
const known = session.docs.get(resolved.absolute);
|
|
1006
|
+
if (!known) {
|
|
1007
|
+
session.docs.set(resolved.absolute, { uri, version: 1, text, languageId });
|
|
1008
|
+
session.rpc.notify('textDocument/didOpen', {
|
|
1009
|
+
textDocument: { uri, languageId, version: 1, text },
|
|
1010
|
+
});
|
|
1011
|
+
return { ok: true, uri, text, changed: true, opened: true };
|
|
1012
|
+
}
|
|
1013
|
+
if (known.text !== text) {
|
|
1014
|
+
known.version += 1;
|
|
1015
|
+
known.text = text;
|
|
1016
|
+
session.rpc.notify('textDocument/didChange', {
|
|
1017
|
+
textDocument: { uri, version: known.version },
|
|
1018
|
+
// Full-text sync. Incremental would mean tracking edits we did not make.
|
|
1019
|
+
contentChanges: [{ text }],
|
|
1020
|
+
});
|
|
1021
|
+
return { ok: true, uri, text, changed: true, opened: false };
|
|
1022
|
+
}
|
|
1023
|
+
return { ok: true, uri, text, changed: false, opened: false };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** One line of a file, trimmed and clamped — the excerpt that makes a
|
|
1027
|
+
* `path:line` recognisable without paying for the file. */
|
|
1028
|
+
function excerptFrom(text, line1) {
|
|
1029
|
+
const lines = String(text).split(/\r?\n/);
|
|
1030
|
+
const raw = lines[line1 - 1] ?? '';
|
|
1031
|
+
const trimmed = raw.trim();
|
|
1032
|
+
return trimmed.length > EXCERPT_MAX_CHARS ? `${trimmed.slice(0, EXCERPT_MAX_CHARS)}…` : trimmed;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* A per-QUERY file cache for excerpts.
|
|
1037
|
+
*
|
|
1038
|
+
* ⚠️ IT MUST NOT OUTLIVE THE QUERY, and a module-level Map is the obvious
|
|
1039
|
+
* version of this that is wrong: 40 references frequently land in the same 3
|
|
1040
|
+
* files, so caching inside one call is most of the win — but this agent REWRITES
|
|
1041
|
+
* files between calls, and a cache that survived would quote the old line next
|
|
1042
|
+
* to the new line number. A stale excerpt is worse than none, because it reads
|
|
1043
|
+
* as evidence.
|
|
1044
|
+
*/
|
|
1045
|
+
function makeExcerptReader() {
|
|
1046
|
+
const cache = new Map();
|
|
1047
|
+
return (absolute, line1) => {
|
|
1048
|
+
let text = cache.get(absolute);
|
|
1049
|
+
if (text === undefined) {
|
|
1050
|
+
try {
|
|
1051
|
+
text = statSync(absolute).size <= MAX_OPEN_DOC_BYTES ? readFileSync(absolute, 'utf8') : '';
|
|
1052
|
+
} catch { text = ''; }
|
|
1053
|
+
cache.set(absolute, text);
|
|
1054
|
+
}
|
|
1055
|
+
return text ? excerptFrom(text, line1) : '';
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* ⚠️ THE OFF-BY-ONE THAT WOULD POISON EVERY ANSWER.
|
|
1061
|
+
*
|
|
1062
|
+
* LSP positions are ZERO-based. Everything a human or a model has in hand is
|
|
1063
|
+
* ONE-based: `read_file` prints 1-based line numbers, `search_text` reports
|
|
1064
|
+
* 1-based hits, editors count from 1, and a stack trace counts from 1. Taking
|
|
1065
|
+
* the model's number straight through would silently ask about the line ABOVE
|
|
1066
|
+
* the one it meant — which usually still resolves to something, so the answer
|
|
1067
|
+
* comes back looking fine and is wrong.
|
|
1068
|
+
*
|
|
1069
|
+
* So the public API of this file is 1-based, end to end, and the conversion
|
|
1070
|
+
* happens here and nowhere else.
|
|
1071
|
+
*/
|
|
1072
|
+
function toLspPosition(line, column) {
|
|
1073
|
+
const l = Number(line);
|
|
1074
|
+
const c = column === undefined || column === null ? 1 : Number(column);
|
|
1075
|
+
if (!Number.isInteger(l) || l < 1) return { ok: false, error: `line must be a whole number ≥ 1 (1-based, as read_file and search_text print them); got ${JSON.stringify(line)}` };
|
|
1076
|
+
if (!Number.isInteger(c) || c < 1) return { ok: false, error: `column must be a whole number ≥ 1 (1-based); got ${JSON.stringify(column)}` };
|
|
1077
|
+
return { ok: true, position: { line: l - 1, character: c - 1 } };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** LSP `Location | Location[] | LocationLink[] | null` → our flat shape. */
|
|
1081
|
+
function normalizeLocations(root, result) {
|
|
1082
|
+
const raw = result === null || result === undefined ? [] : (Array.isArray(result) ? result : [result]);
|
|
1083
|
+
const excerptForPath = makeExcerptReader();
|
|
1084
|
+
const out = [];
|
|
1085
|
+
for (const item of raw) {
|
|
1086
|
+
// A LocationLink names the target differently from a Location. Both are
|
|
1087
|
+
// legal answers to the same request, and a server picks based on the
|
|
1088
|
+
// `linkSupport` we advertised — so both have to be handled here.
|
|
1089
|
+
const uri = item?.uri ?? item?.targetUri;
|
|
1090
|
+
const range = item?.range ?? item?.targetSelectionRange ?? item?.targetRange;
|
|
1091
|
+
if (!uri || !range) continue;
|
|
1092
|
+
let absolute;
|
|
1093
|
+
try { absolute = fileURLToPath(uri); } catch { continue; }
|
|
1094
|
+
const line = (range.start?.line ?? 0) + 1;
|
|
1095
|
+
const column = (range.start?.character ?? 0) + 1;
|
|
1096
|
+
// Outside the workspace is legitimate here (a definition in node_modules or
|
|
1097
|
+
// in lib.dom.d.ts is often the correct answer), so it is REPORTED rather
|
|
1098
|
+
// than refused — but shown relative when it is inside, absolute when not,
|
|
1099
|
+
// so the model can tell at a glance whether it can open it.
|
|
1100
|
+
const rel = relative(root, absolute);
|
|
1101
|
+
const inside = rel !== '' && !rel.startsWith('..') && !/^[A-Za-z]:/.test(rel);
|
|
1102
|
+
out.push({
|
|
1103
|
+
path: inside ? rel.split(sep).join('/') : absolute,
|
|
1104
|
+
inWorkspace: inside,
|
|
1105
|
+
line,
|
|
1106
|
+
column,
|
|
1107
|
+
excerpt: excerptForPath(absolute, line),
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
return out;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function capped(list, max) {
|
|
1114
|
+
return { shown: list.slice(0, max), truncated: list.length > max };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* A resolved query target: workspace-relative path in, absolute path plus a
|
|
1119
|
+
* synced document out. ⚠️ Every entry point goes through here, so a model can
|
|
1120
|
+
* never point a language server at `../../.ssh/config`.
|
|
1121
|
+
*/
|
|
1122
|
+
function target(session, root, file) {
|
|
1123
|
+
const r = resolveInWorkspace(root, file, 'read');
|
|
1124
|
+
if (!r.ok) return { ok: false, error: r.reason };
|
|
1125
|
+
const synced = syncDocument(session, r);
|
|
1126
|
+
if (!synced.ok) return synced;
|
|
1127
|
+
return { ok: true, resolved: r, ...synced };
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* ── ⭐ GO TO DEFINITION ─────────────────────────────────────────────────────
|
|
1132
|
+
* `root` is the workspace; `file` is workspace-relative; `line`/`column` are
|
|
1133
|
+
* 1-BASED, matching everything else the model has seen this session.
|
|
1134
|
+
*/
|
|
1135
|
+
export async function definition(root, file, line, column, opts = {}) {
|
|
1136
|
+
const pos = toLspPosition(line, column);
|
|
1137
|
+
if (!pos.ok) return pos;
|
|
1138
|
+
const language = languageForFile(file);
|
|
1139
|
+
if (!language) return { ok: false, error: `no language server handles ${file} — this tool covers ${Object.keys(LANGUAGE_SERVERS).join(', ')} files. Use search_text instead.` };
|
|
1140
|
+
const session = opts.session ?? await getLanguageServer(root, language, opts);
|
|
1141
|
+
if (!session.ok) return session;
|
|
1142
|
+
|
|
1143
|
+
const t = target(session, root, file);
|
|
1144
|
+
if (!t.ok) return t;
|
|
1145
|
+
|
|
1146
|
+
const res = await session.rpc.request('textDocument/definition', {
|
|
1147
|
+
textDocument: { uri: t.uri },
|
|
1148
|
+
position: pos.position,
|
|
1149
|
+
}, opts.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
1150
|
+
if (res?.error) return { ok: false, error: `${session.label}: ${res.error.message}` };
|
|
1151
|
+
|
|
1152
|
+
const all = normalizeLocations(session.root, res?.result);
|
|
1153
|
+
const { shown, truncated } = capped(all, MAX_LOCATIONS);
|
|
1154
|
+
if (all.length === 0) {
|
|
1155
|
+
return {
|
|
1156
|
+
ok: true, kind: 'definition', path: t.resolved.relative, count: 0, shown: 0, truncated: false, locations: [],
|
|
1157
|
+
// ⚠️ "No definition" and "you pointed at whitespace" look identical to the
|
|
1158
|
+
// caller, and the second is by far the more common. Say so.
|
|
1159
|
+
note: `${session.label} found no definition at ${t.resolved.relative}:${line}:${column}. Check the position is on the symbol itself — the column is 1-based and counts characters, not tabs-as-spaces.`,
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
return { ok: true, kind: 'definition', path: t.resolved.relative, count: all.length, shown: shown.length, truncated, locations: shown };
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* ── ⭐ FIND REFERENCES — the verb grep cannot do ────────────────────────────
|
|
1167
|
+
* Every real use of the symbol, including renamed imports, excluding the string
|
|
1168
|
+
* that merely spells the same thing.
|
|
1169
|
+
*/
|
|
1170
|
+
export async function references(root, file, line, column, opts = {}) {
|
|
1171
|
+
const pos = toLspPosition(line, column);
|
|
1172
|
+
if (!pos.ok) return pos;
|
|
1173
|
+
const language = languageForFile(file);
|
|
1174
|
+
if (!language) return { ok: false, error: `no language server handles ${file} — this tool covers ${Object.keys(LANGUAGE_SERVERS).join(', ')} files. Use search_text instead.` };
|
|
1175
|
+
const session = opts.session ?? await getLanguageServer(root, language, opts);
|
|
1176
|
+
if (!session.ok) return session;
|
|
1177
|
+
|
|
1178
|
+
const t = target(session, root, file);
|
|
1179
|
+
if (!t.ok) return t;
|
|
1180
|
+
|
|
1181
|
+
const res = await session.rpc.request('textDocument/references', {
|
|
1182
|
+
textDocument: { uri: t.uri },
|
|
1183
|
+
position: pos.position,
|
|
1184
|
+
context: { includeDeclaration: opts.includeDeclaration !== false },
|
|
1185
|
+
}, opts.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
1186
|
+
if (res?.error) return { ok: false, error: `${session.label}: ${res.error.message}` };
|
|
1187
|
+
|
|
1188
|
+
const all = normalizeLocations(session.root, res?.result);
|
|
1189
|
+
const { shown, truncated } = capped(all, MAX_LOCATIONS);
|
|
1190
|
+
return {
|
|
1191
|
+
ok: true,
|
|
1192
|
+
kind: 'references',
|
|
1193
|
+
path: t.resolved.relative,
|
|
1194
|
+
count: all.length,
|
|
1195
|
+
shown: shown.length,
|
|
1196
|
+
truncated,
|
|
1197
|
+
locations: shown,
|
|
1198
|
+
// ⚠️ SAY THAT YOU TRUNCATED, AND SAY THE REAL TOTAL. A silently shortened
|
|
1199
|
+
// list reads as "these are all of them", and a model that believes it will
|
|
1200
|
+
// happily change a symbol used in 118 places after checking 25.
|
|
1201
|
+
note: truncated
|
|
1202
|
+
? `showing ${shown.length} of ${all.length} references — the rest were cut to stay inside the context budget. Do NOT treat this list as exhaustive; narrow the question or work through it file by file.`
|
|
1203
|
+
: null,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const SEVERITY = { 1: 'error', 2: 'warning', 3: 'information', 4: 'hint' };
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* ── ⭐ REAL TYPE ERRORS, WITHOUT RUNNING THE COMPILER ───────────────────────
|
|
1211
|
+
*
|
|
1212
|
+
* ⚠️ AND THE ONE PLACE THIS MODULE REFUSES TO GUESS. Diagnostics are mostly
|
|
1213
|
+
* PUSHED: the server publishes them when it is ready, on its own schedule. If it
|
|
1214
|
+
* has not published yet, the honest answer is "nothing was measured" — reporting
|
|
1215
|
+
* an empty list would be the module telling the model the file is clean, which
|
|
1216
|
+
* it will act on. That failure is worse than no feature at all, so it is an
|
|
1217
|
+
* error with a named alternative instead.
|
|
1218
|
+
*/
|
|
1219
|
+
export async function diagnostics(root, file, opts = {}) {
|
|
1220
|
+
const language = languageForFile(file);
|
|
1221
|
+
if (!language) return { ok: false, error: `no language server handles ${file} — this tool covers ${Object.keys(LANGUAGE_SERVERS).join(', ')} files.` };
|
|
1222
|
+
const session = opts.session ?? await getLanguageServer(root, language, opts);
|
|
1223
|
+
if (!session.ok) return session;
|
|
1224
|
+
|
|
1225
|
+
const t = target(session, root, file);
|
|
1226
|
+
if (!t.ok) return t;
|
|
1227
|
+
/**
|
|
1228
|
+
* ⚠️ READ THE "HAVE WE GOT ANYTHING?" FLAG *AFTER* THE SYNC, NOT BEFORE.
|
|
1229
|
+
* `target` writes `didOpen`/`didChange` synchronously; the reply cannot land
|
|
1230
|
+
* before this line, because a `data` event is never emitted inside our own
|
|
1231
|
+
* `write`. So anything already in `published` here is genuinely from before —
|
|
1232
|
+
* and that is the ONLY thing making the "was it invalidated?" test below
|
|
1233
|
+
* meaningful.
|
|
1234
|
+
*/
|
|
1235
|
+
const realKey = uriKey(t.uri);
|
|
1236
|
+
|
|
1237
|
+
let items = null;
|
|
1238
|
+
|
|
1239
|
+
// Pull diagnostics (LSP 3.17) when the server offers them: one request, one
|
|
1240
|
+
// answer, no waiting on a publish that may never come.
|
|
1241
|
+
if (session.capabilities?.diagnosticProvider) {
|
|
1242
|
+
const res = await session.rpc.request('textDocument/diagnostic', {
|
|
1243
|
+
textDocument: { uri: t.uri },
|
|
1244
|
+
}, opts.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
1245
|
+
if (!res?.error && res?.result?.kind === 'full') items = res.result.items ?? [];
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (items === null) {
|
|
1249
|
+
const waitMs = opts.diagnosticsTimeoutMs ?? DIAGNOSTICS_TIMEOUT_MS;
|
|
1250
|
+
// Wait when the document just moved (whatever is cached describes the old
|
|
1251
|
+
// bytes) or when nothing has ever been published for it.
|
|
1252
|
+
if (t.changed || !session.published.has(realKey)) {
|
|
1253
|
+
await waitForPublish(session, realKey, waitMs);
|
|
1254
|
+
// ⚠️ AND THEN WAIT A BEAT MORE. tsserver publishes an empty set the moment
|
|
1255
|
+
// a file opens and the real one right after; taking the first is how you
|
|
1256
|
+
// report a broken file as clean.
|
|
1257
|
+
await settle(session, realKey, opts.quietMs ?? DIAGNOSTICS_QUIET_MS);
|
|
1258
|
+
}
|
|
1259
|
+
const entry = session.published.get(realKey);
|
|
1260
|
+
if (!entry) {
|
|
1261
|
+
return {
|
|
1262
|
+
ok: false,
|
|
1263
|
+
error: `${session.label} published no diagnostics for ${t.resolved.relative} within ${Math.round(waitMs / 1000)}s. That is NOT the same as "no problems" — nothing was measured. Run the project's own type-check with run_command instead, and do not re-call this tool for this file.`,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
items = entry.items;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const ranked = [...items].sort((a, b) => (a.severity ?? 4) - (b.severity ?? 4) || (a.range?.start?.line ?? 0) - (b.range?.start?.line ?? 0));
|
|
1270
|
+
const counts = { error: 0, warning: 0, information: 0, hint: 0 };
|
|
1271
|
+
for (const d of ranked) counts[SEVERITY[d.severity ?? 1] ?? 'error'] += 1;
|
|
1272
|
+
const { shown, truncated } = capped(ranked, MAX_DIAGNOSTICS);
|
|
1273
|
+
|
|
1274
|
+
return {
|
|
1275
|
+
ok: true,
|
|
1276
|
+
kind: 'diagnostics',
|
|
1277
|
+
path: t.resolved.relative,
|
|
1278
|
+
counts,
|
|
1279
|
+
count: ranked.length,
|
|
1280
|
+
shown: shown.length,
|
|
1281
|
+
truncated,
|
|
1282
|
+
items: shown.map((d) => ({
|
|
1283
|
+
line: (d.range?.start?.line ?? 0) + 1,
|
|
1284
|
+
column: (d.range?.start?.character ?? 0) + 1,
|
|
1285
|
+
severity: SEVERITY[d.severity ?? 1] ?? 'error',
|
|
1286
|
+
code: d.code ?? null,
|
|
1287
|
+
message: String(d.message ?? '').replace(/\s+/g, ' ').slice(0, MAX_DIAGNOSTIC_CHARS),
|
|
1288
|
+
excerpt: excerptFrom(t.text, (d.range?.start?.line ?? 0) + 1),
|
|
1289
|
+
})),
|
|
1290
|
+
note: truncated ? `showing the ${shown.length} most severe of ${ranked.length} problems` : null,
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** Resolve on the next `publishDiagnostics` for this URI, or on the timeout —
|
|
1295
|
+
* never reject, because a server that says nothing is a fact the caller has to
|
|
1296
|
+
* report, not an exception it has to catch. */
|
|
1297
|
+
function waitForPublish(session, key, ms) {
|
|
1298
|
+
return new Promise((res) => {
|
|
1299
|
+
const list = session.waiters.get(key) ?? [];
|
|
1300
|
+
const done = () => { clearTimeout(timer); res(); };
|
|
1301
|
+
const timer = setTimeout(() => {
|
|
1302
|
+
const current = session.waiters.get(key);
|
|
1303
|
+
if (current) session.waiters.set(key, current.filter((f) => f !== done));
|
|
1304
|
+
res();
|
|
1305
|
+
}, ms);
|
|
1306
|
+
/** ?? REF'D: stdio is unref'd, so this bounded wait is the only anchor. See child-lifetime.mjs. */
|
|
1307
|
+
list.push(done);
|
|
1308
|
+
session.waiters.set(key, list);
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** Wait for the publish AFTER the publish, then stop. */
|
|
1313
|
+
function settle(session, key, quietMs) {
|
|
1314
|
+
if (quietMs <= 0) return Promise.resolve();
|
|
1315
|
+
return new Promise((res) => {
|
|
1316
|
+
const timer = setTimeout(() => {
|
|
1317
|
+
const current = session.waiters.get(key);
|
|
1318
|
+
if (current) session.waiters.set(key, current.filter((f) => f !== done));
|
|
1319
|
+
res();
|
|
1320
|
+
}, quietMs);
|
|
1321
|
+
/** ?? REF'D: stdio is unref'd, so this bounded wait is the only anchor. See child-lifetime.mjs. */
|
|
1322
|
+
const done = () => { clearTimeout(timer); res(); };
|
|
1323
|
+
const list = session.waiters.get(key) ?? [];
|
|
1324
|
+
list.push(done);
|
|
1325
|
+
session.waiters.set(key, list);
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** LSP SymbolKind is a number on the wire and useless to a model as one. */
|
|
1330
|
+
const SYMBOL_KINDS = {
|
|
1331
|
+
1: 'file', 2: 'module', 3: 'namespace', 4: 'package', 5: 'class', 6: 'method',
|
|
1332
|
+
7: 'property', 8: 'field', 9: 'constructor', 10: 'enum', 11: 'interface',
|
|
1333
|
+
12: 'function', 13: 'variable', 14: 'constant', 15: 'string', 16: 'number',
|
|
1334
|
+
17: 'boolean', 18: 'array', 19: 'object', 20: 'key', 21: 'null',
|
|
1335
|
+
22: 'enum-member', 23: 'struct', 24: 'event', 25: 'operator', 26: 'type-parameter',
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* ── ⭐ THE SHAPE OF A FILE, FOR A FRACTION OF READING IT ────────────────────
|
|
1340
|
+
* A 900-line module costs ~12k tokens to read and ~300 to outline. This is how
|
|
1341
|
+
* the agent decides WHERE to read.
|
|
1342
|
+
*/
|
|
1343
|
+
export async function documentSymbols(root, file, opts = {}) {
|
|
1344
|
+
const language = languageForFile(file);
|
|
1345
|
+
if (!language) return { ok: false, error: `no language server handles ${file} — this tool covers ${Object.keys(LANGUAGE_SERVERS).join(', ')} files.` };
|
|
1346
|
+
const session = opts.session ?? await getLanguageServer(root, language, opts);
|
|
1347
|
+
if (!session.ok) return session;
|
|
1348
|
+
|
|
1349
|
+
const t = target(session, root, file);
|
|
1350
|
+
if (!t.ok) return t;
|
|
1351
|
+
|
|
1352
|
+
const res = await session.rpc.request('textDocument/documentSymbol', {
|
|
1353
|
+
textDocument: { uri: t.uri },
|
|
1354
|
+
}, opts.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
1355
|
+
if (res?.error) return { ok: false, error: `${session.label}: ${res.error.message}` };
|
|
1356
|
+
|
|
1357
|
+
const flat = [];
|
|
1358
|
+
/**
|
|
1359
|
+
* ⚠️ TWO INCOMPATIBLE RESPONSE SHAPES FOR ONE REQUEST, and which you get
|
|
1360
|
+
* depends on the server: hierarchical `DocumentSymbol[]` (children, `range`)
|
|
1361
|
+
* or flat `SymbolInformation[]` (no children, `location.range`). Handling only
|
|
1362
|
+
* the first is the common bug — it works perfectly against tsserver and
|
|
1363
|
+
* returns an empty outline against half the other servers.
|
|
1364
|
+
*/
|
|
1365
|
+
const walk = (nodes, depth) => {
|
|
1366
|
+
for (const n of nodes ?? []) {
|
|
1367
|
+
if (flat.length >= MAX_SYMBOLS * 2) return;
|
|
1368
|
+
const range = n.range ?? n.location?.range;
|
|
1369
|
+
if (!n?.name || !range) continue;
|
|
1370
|
+
flat.push({
|
|
1371
|
+
name: String(n.name).slice(0, 120),
|
|
1372
|
+
kind: SYMBOL_KINDS[n.kind] ?? String(n.kind ?? 'symbol'),
|
|
1373
|
+
line: (range.start?.line ?? 0) + 1,
|
|
1374
|
+
depth,
|
|
1375
|
+
detail: n.detail ? String(n.detail).slice(0, 100) : null,
|
|
1376
|
+
});
|
|
1377
|
+
if (Array.isArray(n.children) && n.children.length) walk(n.children, depth + 1);
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
walk(res?.result, 0);
|
|
1381
|
+
flat.sort((a, b) => a.line - b.line || a.depth - b.depth);
|
|
1382
|
+
const { shown, truncated } = capped(flat, MAX_SYMBOLS);
|
|
1383
|
+
|
|
1384
|
+
return {
|
|
1385
|
+
ok: true,
|
|
1386
|
+
kind: 'symbols',
|
|
1387
|
+
path: t.resolved.relative,
|
|
1388
|
+
count: flat.length,
|
|
1389
|
+
shown: shown.length,
|
|
1390
|
+
truncated,
|
|
1391
|
+
symbols: shown,
|
|
1392
|
+
note: truncated ? `showing the first ${shown.length} of ${flat.length} symbols` : null,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Run one navigation verb and guarantee the server dies afterwards.
|
|
1398
|
+
*
|
|
1399
|
+
* ⭐ For a caller that wants the capability without owning the lifecycle — a
|
|
1400
|
+
* test, a one-shot script, anything that is not the long-lived agent loop.
|
|
1401
|
+
*/
|
|
1402
|
+
export async function withLanguageServer(root, language, fn, opts = {}) {
|
|
1403
|
+
const session = await startLanguageServer(root, { ...opts, language });
|
|
1404
|
+
if (!session.ok) return session;
|
|
1405
|
+
try {
|
|
1406
|
+
return await fn(session);
|
|
1407
|
+
} finally {
|
|
1408
|
+
await stopLanguageServer(session);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** Render for the model: compact, and leading with the fact that drives the
|
|
1413
|
+
* next move. Never the raw object — that is the token bill this file exists
|
|
1414
|
+
* to avoid. */
|
|
1415
|
+
export function formatLspForModel(result) {
|
|
1416
|
+
if (!result?.ok) return `lsp: ${result?.error ?? 'unknown failure'}`;
|
|
1417
|
+
const lines = [];
|
|
1418
|
+
if (result.kind === 'diagnostics') {
|
|
1419
|
+
const { error = 0, warning = 0 } = result.counts ?? {};
|
|
1420
|
+
lines.push(`${result.path}: ${error} error${error === 1 ? '' : 's'}, ${warning} warning${warning === 1 ? '' : 's'}`);
|
|
1421
|
+
for (const d of result.items) lines.push(` ${d.severity.padEnd(7)} ${result.path}:${d.line}:${d.column} ${d.message}${d.code ? ` [${d.code}]` : ''}`);
|
|
1422
|
+
} else if (result.kind === 'symbols') {
|
|
1423
|
+
lines.push(`${result.path}: ${result.count} symbol${result.count === 1 ? '' : 's'}`);
|
|
1424
|
+
for (const s of result.symbols) lines.push(` ${' '.repeat(s.depth)}${s.kind} ${s.name} (line ${s.line})`);
|
|
1425
|
+
} else {
|
|
1426
|
+
lines.push(`${result.kind}: ${result.count} result${result.count === 1 ? '' : 's'}`);
|
|
1427
|
+
for (const l of result.locations) lines.push(` ${l.path}:${l.line}:${l.column} ${l.excerpt}`);
|
|
1428
|
+
}
|
|
1429
|
+
if (result.note) lines.push(` ⚠️ ${result.note}`);
|
|
1430
|
+
return lines.join('\n');
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* ── THE TOOL SURFACE ────────────────────────────────────────────────────────
|
|
1435
|
+
* ⚠️ NOT REGISTERED HERE. `tools.mjs` is owned by another lane; the three-line
|
|
1436
|
+
* registration is in this module's report rather than applied, so two agents do
|
|
1437
|
+
* not write the same file.
|
|
1438
|
+
*
|
|
1439
|
+
* The names are chosen for a ROUTER, not for LSP purists: a model picks a tool
|
|
1440
|
+
* by reading its name and description, and `find_references` says what it does
|
|
1441
|
+
* where `textDocument/references` says where it came from.
|
|
1442
|
+
*/
|
|
1443
|
+
export function lspToolSchemas() {
|
|
1444
|
+
return [
|
|
1445
|
+
{
|
|
1446
|
+
type: 'function',
|
|
1447
|
+
function: {
|
|
1448
|
+
name: 'find_definition',
|
|
1449
|
+
description: [
|
|
1450
|
+
'Jump to where a symbol is actually DEFINED, using the project\'s language server —',
|
|
1451
|
+
'imports resolved, aliases followed, the real file even when it is in node_modules.',
|
|
1452
|
+
'Use this instead of guessing from search_text: grep finds every line that spells the',
|
|
1453
|
+
'name, this finds the one that declares it. Line and column are 1-based, exactly as',
|
|
1454
|
+
'read_file and search_text print them; point at the symbol itself.',
|
|
1455
|
+
].join(' '),
|
|
1456
|
+
parameters: {
|
|
1457
|
+
type: 'object',
|
|
1458
|
+
properties: {
|
|
1459
|
+
file: { type: 'string', description: 'Workspace-relative source file.' },
|
|
1460
|
+
line: { type: 'number', description: '1-based line the symbol appears on.' },
|
|
1461
|
+
column: { type: 'number', description: '1-based column of the symbol (default 1).' },
|
|
1462
|
+
},
|
|
1463
|
+
required: ['file', 'line'],
|
|
1464
|
+
},
|
|
1465
|
+
},
|
|
1466
|
+
},
|
|
1467
|
+
{
|
|
1468
|
+
type: 'function',
|
|
1469
|
+
function: {
|
|
1470
|
+
name: 'find_references',
|
|
1471
|
+
description: [
|
|
1472
|
+
'Every real use of a symbol across the whole project, from the language server.',
|
|
1473
|
+
'This is the check to run BEFORE changing or deleting anything shared — it catches',
|
|
1474
|
+
'call sites that search_text cannot, because it follows renamed imports and ignores',
|
|
1475
|
+
'the same word in comments and strings. Line and column are 1-based.',
|
|
1476
|
+
].join(' '),
|
|
1477
|
+
parameters: {
|
|
1478
|
+
type: 'object',
|
|
1479
|
+
properties: {
|
|
1480
|
+
file: { type: 'string', description: 'Workspace-relative source file.' },
|
|
1481
|
+
line: { type: 'number', description: '1-based line the symbol appears on.' },
|
|
1482
|
+
column: { type: 'number', description: '1-based column of the symbol (default 1).' },
|
|
1483
|
+
},
|
|
1484
|
+
required: ['file', 'line'],
|
|
1485
|
+
},
|
|
1486
|
+
},
|
|
1487
|
+
},
|
|
1488
|
+
{
|
|
1489
|
+
type: 'function',
|
|
1490
|
+
function: {
|
|
1491
|
+
name: 'check_types',
|
|
1492
|
+
description: [
|
|
1493
|
+
'Real type and syntax errors for one file, from the language server, in about a second —',
|
|
1494
|
+
'no build, no test run. Call it after editing a file to see whether the edit compiles',
|
|
1495
|
+
'before spending a round on run_command. Reports the file as it is ON DISK right now.',
|
|
1496
|
+
].join(' '),
|
|
1497
|
+
parameters: {
|
|
1498
|
+
type: 'object',
|
|
1499
|
+
properties: { file: { type: 'string', description: 'Workspace-relative source file.' } },
|
|
1500
|
+
required: ['file'],
|
|
1501
|
+
},
|
|
1502
|
+
},
|
|
1503
|
+
},
|
|
1504
|
+
{
|
|
1505
|
+
type: 'function',
|
|
1506
|
+
function: {
|
|
1507
|
+
name: 'list_symbols',
|
|
1508
|
+
description: [
|
|
1509
|
+
'Outline a file: every class, function, method and exported constant with its line number.',
|
|
1510
|
+
'Use this before read_file on anything large — it costs a fraction of the tokens and tells',
|
|
1511
|
+
'you which lines are worth reading.',
|
|
1512
|
+
].join(' '),
|
|
1513
|
+
parameters: {
|
|
1514
|
+
type: 'object',
|
|
1515
|
+
properties: { file: { type: 'string', description: 'Workspace-relative source file.' } },
|
|
1516
|
+
required: ['file'],
|
|
1517
|
+
},
|
|
1518
|
+
},
|
|
1519
|
+
},
|
|
1520
|
+
];
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* ── ⚠️ THE WRONG-ARGUMENT MESSAGE THAT NAMED THE WRONG PROBLEM ──────────────
|
|
1525
|
+
*
|
|
1526
|
+
* These four take `file` + `line` + `column`. A caller that sends `path` +
|
|
1527
|
+
* `symbol` — the shape every OTHER search tool in this package uses, so it is
|
|
1528
|
+
* the natural mistake, not a careless one — used to reach `toLspPosition` with
|
|
1529
|
+
* `line: undefined` and get back *"line must be a whole number ≥ 1 … got
|
|
1530
|
+
* undefined"*. That message is true and useless: it complains about the argument
|
|
1531
|
+
* that is MISSING while saying nothing about the two that were SENT, so the
|
|
1532
|
+
* obvious repair is to invent a line number, and the second call fails too.
|
|
1533
|
+
*
|
|
1534
|
+
* ⭐ A wrong-argument error should name the RIGHT argument. These tables are the
|
|
1535
|
+
* keys actually observed or plausibly reached for; each maps to what to say.
|
|
1536
|
+
*/
|
|
1537
|
+
const FILE_ALIASES = ['path', 'filename', 'filePath', 'file_path', 'filepath', 'uri', 'document', 'source'];
|
|
1538
|
+
const LINE_ALIASES = ['lineNumber', 'line_number', 'row', 'lineNo'];
|
|
1539
|
+
/** These do not map to anything — they mean the caller wanted a different tool. */
|
|
1540
|
+
const SYMBOL_ALIASES = ['symbol', 'symbolName', 'identifier', 'name', 'query', 'term'];
|
|
1541
|
+
|
|
1542
|
+
/** First key present in `args` from `keys`, or null. `undefined` is absent. */
|
|
1543
|
+
function firstPresent(args, keys) {
|
|
1544
|
+
for (const k of keys) if (args?.[k] !== undefined && args[k] !== null) return k;
|
|
1545
|
+
return null;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/**
|
|
1549
|
+
* Refuse an unusable call in a way that costs one round instead of three.
|
|
1550
|
+
*
|
|
1551
|
+
* ⚠️ IT ONLY FIRES WHEN THE REAL ARGUMENT IS ABSENT. `{ file, path }` together
|
|
1552
|
+
* is not an error — `file` wins silently, because a guard that rejects a call
|
|
1553
|
+
* carrying everything it needs is worse than no guard. And it polices only the
|
|
1554
|
+
* REQUIRED arguments: `column` is optional with a default, so an alias there is
|
|
1555
|
+
* forgiven below rather than refused.
|
|
1556
|
+
*/
|
|
1557
|
+
export function checkLspArgs(name, args = {}) {
|
|
1558
|
+
const wantsPosition = name === 'find_definition' || name === 'find_references';
|
|
1559
|
+
const a = args ?? {};
|
|
1560
|
+
|
|
1561
|
+
if (typeof a.file !== 'string' || a.file === '') {
|
|
1562
|
+
const alias = firstPresent(a, FILE_ALIASES);
|
|
1563
|
+
if (alias) {
|
|
1564
|
+
return { ok: false, error: `${name} takes "file", not "${alias}". Re-send it as {"file": ${JSON.stringify(String(a[alias]))}${wantsPosition ? ', "line": <1-based line>, "column": <1-based column>' : ''}} — a workspace-relative path.` };
|
|
1565
|
+
}
|
|
1566
|
+
return { ok: false, error: `${name} needs "file": a workspace-relative source path${wantsPosition ? ', plus a 1-based "line" and "column" pointing at the symbol' : ''}.` };
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
if (wantsPosition && a.line === undefined) {
|
|
1570
|
+
const sym = firstPresent(a, SYMBOL_ALIASES);
|
|
1571
|
+
if (sym) {
|
|
1572
|
+
// ⭐ The important case: the caller knows the NAME and wants the position.
|
|
1573
|
+
// Saying "line is required" tells them to guess. Naming the tool that
|
|
1574
|
+
// produces a line number tells them what to do next.
|
|
1575
|
+
return {
|
|
1576
|
+
ok: false,
|
|
1577
|
+
error: `${name} is position-based, not name-based: it takes "file" + "line" + "column", and there is no "${sym}" argument. It cannot look up ${JSON.stringify(String(a[sym]))} by name. Do this instead — call search_text for ${JSON.stringify(String(a[sym]))} to get a file and a 1-based line:column, then call ${name} with those. Or call list_symbols on "${a.file}", which returns every symbol in it with its line number.`,
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
const alias = firstPresent(a, LINE_ALIASES);
|
|
1581
|
+
if (alias) {
|
|
1582
|
+
return { ok: false, error: `${name} takes "line", not "${alias}". Re-send it as {"file": ${JSON.stringify(a.file)}, "line": ${JSON.stringify(a[alias])}, "column": <1-based column>}.` };
|
|
1583
|
+
}
|
|
1584
|
+
return { ok: false, error: `${name} needs "line": the 1-based line the symbol appears on, exactly as read_file and search_text print it. Call list_symbols on "${a.file}" if you do not know it yet.` };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
return { ok: true };
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** The dispatcher `tools.mjs` would call. One entry point, so the registration
|
|
1591
|
+
* is three lines and cannot drift from the schemas above. */
|
|
1592
|
+
export async function runLspTool(root, name, args = {}, opts = {}) {
|
|
1593
|
+
if (!LSP_TOOL_NAMES.includes(name)) return { ok: false, error: `"${name}" is not an lsp tool` };
|
|
1594
|
+
const check = checkLspArgs(name, args);
|
|
1595
|
+
if (!check.ok) return check;
|
|
1596
|
+
/**
|
|
1597
|
+
* ⭐ REQUIRED ARGUMENTS TEACH, OPTIONAL ARGUMENTS FORGIVE. `column` defaults
|
|
1598
|
+
* to 1, so accepting `col`/`character` costs nothing and cannot mislead —
|
|
1599
|
+
* whereas refusing the call over an optional argument would spend a round to
|
|
1600
|
+
* fix something that was never going to be wrong.
|
|
1601
|
+
*/
|
|
1602
|
+
const column = args.column ?? args.col ?? args.character ?? 1;
|
|
1603
|
+
switch (name) {
|
|
1604
|
+
case 'find_definition': return definition(root, args.file, args.line, column, opts);
|
|
1605
|
+
case 'find_references': return references(root, args.file, args.line, column, opts);
|
|
1606
|
+
case 'check_types': return diagnostics(root, args.file, opts);
|
|
1607
|
+
case 'list_symbols': return documentSymbols(root, args.file, opts);
|
|
1608
|
+
/* c8 ignore next */
|
|
1609
|
+
default: return { ok: false, error: `"${name}" is not an lsp tool` };
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
export const LSP_TOOL_NAMES = ['find_definition', 'find_references', 'check_types', 'list_symbols'];
|