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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. 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'];