howtofixcli 0.7.13 → 0.7.15

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 (161) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-6FUWMLWO.js +1 -0
  5. package/dist/chunk-7CHDNTH6.js +1 -0
  6. package/dist/chunk-7DTQKDO4.js +2 -0
  7. package/dist/chunk-AGGIYXN7.js +2 -0
  8. package/dist/chunk-AUDMRGWF.js +189 -0
  9. package/dist/chunk-CPJKKHGH.js +1 -0
  10. package/dist/chunk-DAPXGSGD.js +12 -0
  11. package/dist/chunk-DOD7MUGX.js +1 -0
  12. package/dist/chunk-DV2BBBBJ.js +2 -0
  13. package/dist/chunk-EIBOS5KO.js +5 -0
  14. package/dist/chunk-EYWOVCSS.js +6 -0
  15. package/dist/chunk-FPXVDP32.js +8 -0
  16. package/dist/chunk-GXB7RZHH.js +1 -0
  17. package/dist/chunk-HT3MFLAV.js +34 -0
  18. package/dist/chunk-ILDDDLRC.js +1 -0
  19. package/dist/chunk-JFYVRWJI.js +2 -0
  20. package/dist/chunk-JMKK26AT.js +2 -0
  21. package/dist/chunk-LAHVHRCS.js +2 -0
  22. package/dist/chunk-LOOFOBTS.js +1 -0
  23. package/dist/chunk-LSJC73DG.js +1 -0
  24. package/dist/chunk-MOKUBKE7.js +1 -0
  25. package/dist/chunk-N345KWWK.js +1 -0
  26. package/dist/chunk-ONZLNFQ2.js +3 -0
  27. package/dist/chunk-ORJ2QZBG.js +3 -0
  28. package/dist/chunk-PCALBZOO.js +2 -0
  29. package/dist/chunk-SX5VE6XA.js +110 -0
  30. package/dist/chunk-TSEKPGK4.js +1 -0
  31. package/dist/chunk-UNK5HRQR.js +1 -0
  32. package/dist/chunk-UTYFLLSM.js +1 -0
  33. package/dist/chunk-V2RPZMLK.js +1 -0
  34. package/dist/chunk-V3R4FYTJ.js +1 -0
  35. package/dist/chunk-V656XVXV.js +8 -0
  36. package/dist/chunk-VLMQSD25.js +1 -0
  37. package/dist/chunk-WKUJ7CFO.js +1 -0
  38. package/dist/chunk-WOT6VMZA.js +1 -0
  39. package/dist/chunk-WUPU6LTD.js +4 -0
  40. package/dist/chunk-WX3V5P7U.js +5 -0
  41. package/dist/chunk-Y7YM5R75.js +5 -0
  42. package/dist/chunk-YW7NORH3.js +2 -0
  43. package/dist/main.js +99 -540
  44. package/package.json +17 -64
  45. package/dist/account-credential.js +0 -136
  46. package/dist/account-credential.test.js +0 -38
  47. package/dist/activity.js +0 -124
  48. package/dist/agent-defs.js +0 -237
  49. package/dist/agent-limiter.js +0 -68
  50. package/dist/agents.js +0 -554
  51. package/dist/anim.js +0 -361
  52. package/dist/args.js +0 -228
  53. package/dist/at-file.js +0 -28
  54. package/dist/brand-extension.js +0 -87
  55. package/dist/brand.js +0 -76
  56. package/dist/browser.js +0 -58
  57. package/dist/cache-report.js +0 -139
  58. package/dist/checkpoint.js +0 -437
  59. package/dist/claims.js +0 -167
  60. package/dist/cli-auth.js +0 -142
  61. package/dist/cli-checkin.js +0 -120
  62. package/dist/cli-credentials.js +0 -254
  63. package/dist/clipboard.js +0 -149
  64. package/dist/collapse.js +0 -129
  65. package/dist/commands.js +0 -294
  66. package/dist/compaction-guard.js +0 -81
  67. package/dist/context-extension.js +0 -17
  68. package/dist/dcp/commands.js +0 -172
  69. package/dist/dcp/compress-state.js +0 -139
  70. package/dist/dcp/compress.js +0 -329
  71. package/dist/dcp/dcp-extension.js +0 -295
  72. package/dist/dcp/dedup.js +0 -93
  73. package/dist/dcp/ids.js +0 -189
  74. package/dist/dcp/index.js +0 -25
  75. package/dist/dcp/middle-prune.js +0 -76
  76. package/dist/dcp/prompts.js +0 -68
  77. package/dist/dcp/protected-content.js +0 -221
  78. package/dist/dcp/protected.js +0 -161
  79. package/dist/dcp/prune.js +0 -418
  80. package/dist/dcp/state.js +0 -176
  81. package/dist/device-id.js +0 -90
  82. package/dist/device-id.test.js +0 -23
  83. package/dist/errors.js +0 -63
  84. package/dist/extensions.js +0 -48
  85. package/dist/fatal.js +0 -35
  86. package/dist/feed.js +0 -498
  87. package/dist/fs-atomic.js +0 -54
  88. package/dist/gateway-errors.js +0 -90
  89. package/dist/git.js +0 -230
  90. package/dist/goal-extension.js +0 -88
  91. package/dist/goal.js +0 -222
  92. package/dist/hooks-extension.js +0 -176
  93. package/dist/hooks.js +0 -443
  94. package/dist/howtofix-account.js +0 -735
  95. package/dist/hud-line.js +0 -153
  96. package/dist/hwid.js +0 -130
  97. package/dist/interrupt.js +0 -60
  98. package/dist/json-events.js +0 -50
  99. package/dist/level-style.js +0 -112
  100. package/dist/loop-guard.js +0 -95
  101. package/dist/lsp-tools.js +0 -32
  102. package/dist/lsp.js +0 -429
  103. package/dist/mcp-config.js +0 -85
  104. package/dist/mcp-lifecycle.js +0 -147
  105. package/dist/model-catalog.js +0 -217
  106. package/dist/model-facts.js +0 -35
  107. package/dist/model-resolve.js +0 -65
  108. package/dist/notes.js +0 -78
  109. package/dist/oauth-login.js +0 -568
  110. package/dist/paths.js +0 -61
  111. package/dist/permissions.js +0 -298
  112. package/dist/plan-mode.js +0 -363
  113. package/dist/preset-command.js +0 -227
  114. package/dist/preset-extension.js +0 -35
  115. package/dist/preset-mcp.js +0 -292
  116. package/dist/presets.js +0 -727
  117. package/dist/prompt-files.js +0 -103
  118. package/dist/prompt-text.js +0 -41
  119. package/dist/proxy.js +0 -137
  120. package/dist/quality/ast.js +0 -196
  121. package/dist/quality/cognitive.js +0 -83
  122. package/dist/quality/cyclomatic.js +0 -30
  123. package/dist/quality/halstead.js +0 -83
  124. package/dist/quality/index.js +0 -18
  125. package/dist/quality/length.js +0 -3
  126. package/dist/quality/maintainability.js +0 -15
  127. package/dist/quality/nesting.js +0 -57
  128. package/dist/quality/report.js +0 -803
  129. package/dist/quality/smells.js +0 -67
  130. package/dist/quality-tool.js +0 -63
  131. package/dist/read-only-commands.js +0 -597
  132. package/dist/repo-map.js +0 -292
  133. package/dist/rtk.js +0 -193
  134. package/dist/sandbox.js +0 -105
  135. package/dist/schema.js +0 -214
  136. package/dist/session-lifecycle.js +0 -19
  137. package/dist/session-retention.js +0 -111
  138. package/dist/session.js +0 -447
  139. package/dist/sessions.js +0 -100
  140. package/dist/settings.js +0 -148
  141. package/dist/skills.js +0 -35
  142. package/dist/slop-tool.js +0 -96
  143. package/dist/spend-cap.js +0 -43
  144. package/dist/subagent.js +0 -171
  145. package/dist/tar.js +0 -146
  146. package/dist/theme.js +0 -79
  147. package/dist/thinking.js +0 -45
  148. package/dist/todo.js +0 -114
  149. package/dist/tool-loadout.js +0 -97
  150. package/dist/tool-render.js +0 -769
  151. package/dist/tools.js +0 -176
  152. package/dist/ui.js +0 -4778
  153. package/dist/update.js +0 -551
  154. package/dist/usage-segment.js +0 -69
  155. package/dist/web-tools.js +0 -46
  156. package/dist/web.js +0 -265
  157. package/dist/welcome.js +0 -179
  158. package/dist/workflow-engine.js +0 -246
  159. package/dist/workflow-progress.js +0 -446
  160. package/dist/workflow-view.js +0 -195
  161. package/dist/workflow.js +0 -349
package/dist/lsp.js DELETED
@@ -1,429 +0,0 @@
1
- /**
2
- * LSP over stdio: `Content-Length` framing, a stream-driven client, the server
3
- * table, and `askLsp`.
4
- *
5
- * Client lifecycle is process-wide: one client per server command, shared by
6
- * every session and subagent — two agents on one TS project must not spawn two
7
- * `typescript-language-server`s. Clients are killed when the main session
8
- * disposes (`disposeAllLsp` from `openSession` in `session.ts`) and on process
9
- * `exit`, which is registered here. Because the pool is keyed by command, the
10
- * first spawn's root wins for the process; HowToFix runs one project per
11
- * process, so that is the common case by design.
12
- */
13
- import { spawn } from "node:child_process";
14
- import { existsSync, readFileSync } from "node:fs";
15
- import { delimiter, isAbsolute, join } from "node:path";
16
- import { fileURLToPath, pathToFileURL } from "node:url";
17
- export function encodeMessage(message) {
18
- const body = JSON.stringify(message);
19
- return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
20
- }
21
- /**
22
- * The largest frame a language server may send.
23
- *
24
- * A server that announces a body this big is not sending a message, it is
25
- * desynchronised or hostile. Waiting for the rest of it would grow the buffer
26
- * until the process dies, so the frame is refused instead.
27
- */
28
- export const MAX_FRAME_BYTES = 32 * 1024 * 1024;
29
- export function decodeMessages(buffer) {
30
- const messages = [];
31
- let offset = 0;
32
- while (offset < buffer.length) {
33
- const headerEnd = buffer.indexOf("\r\n\r\n", offset);
34
- if (headerEnd === -1)
35
- break;
36
- const header = buffer.subarray(offset, headerEnd).toString("utf8");
37
- const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
38
- if (!lengthMatch)
39
- break;
40
- const length = Number(lengthMatch[1]);
41
- if (!Number.isSafeInteger(length) || length < 0 || length > MAX_FRAME_BYTES) {
42
- throw new Error(`lsp frame of ${lengthMatch[1]} bytes refused`);
43
- }
44
- const bodyStart = headerEnd + 4;
45
- const bodyEnd = bodyStart + length;
46
- if (bodyEnd > buffer.length)
47
- break;
48
- const body = buffer.subarray(bodyStart, bodyEnd).toString("utf8");
49
- // One malformed frame is skipped, not fatal: a language server that emits a
50
- // bad message must not take the CLI down in the middle of a session.
51
- try {
52
- const parsed = JSON.parse(body);
53
- if (parsed && typeof parsed === "object")
54
- messages.push(parsed);
55
- }
56
- catch {
57
- // dropped on purpose; the stream stays aligned because the length was read
58
- }
59
- offset = bodyEnd;
60
- }
61
- return { messages, rest: buffer.subarray(offset) };
62
- }
63
- export class LspClient {
64
- io;
65
- nextId = 1;
66
- pending = new Map();
67
- buffer = Buffer.alloc(0);
68
- broken = null;
69
- opened = new Set();
70
- diagnosticsByUri = new Map();
71
- diagnosticsWaiters = new Map();
72
- constructor(io) {
73
- this.io = io;
74
- io.stdout.on("data", (chunk) => {
75
- if (this.broken)
76
- return;
77
- this.buffer = Buffer.concat([this.buffer, chunk]);
78
- let decoded;
79
- try {
80
- decoded = decodeMessages(this.buffer);
81
- }
82
- catch (error) {
83
- // A stream we cannot trust is dropped whole. Keeping it would let a
84
- // desynchronised server grow the buffer without bound, and every
85
- // request queued behind it would wait out its timeout for nothing.
86
- this.buffer = Buffer.alloc(0);
87
- this.fail(error instanceof Error ? error : new Error(String(error)));
88
- return;
89
- }
90
- this.buffer = decoded.rest;
91
- for (const message of decoded.messages)
92
- this.dispatch(message);
93
- });
94
- // A server that dies leaves the pipe behind: the next write raises EPIPE on
95
- // the stream, and with no listener that becomes an uncaught exception and
96
- // takes the CLI down. Failing the client turns it into a normal error.
97
- io.stdin.on("error", (error) => {
98
- this.fail(error instanceof Error ? error : new Error(String(error)));
99
- });
100
- }
101
- /** Mark the stream unusable and answer every queued request with the reason. */
102
- fail(error) {
103
- if (this.broken)
104
- return;
105
- this.broken = error;
106
- for (const [id, waiter] of this.pending) {
107
- this.pending.delete(id);
108
- waiter.reject(error);
109
- }
110
- }
111
- dispatch(message) {
112
- if (message.method === "textDocument/publishDiagnostics") {
113
- const params = message.params;
114
- if (params?.uri) {
115
- const list = Array.isArray(params.diagnostics) ? params.diagnostics : [];
116
- this.diagnosticsByUri.set(params.uri, list);
117
- const waiters = this.diagnosticsWaiters.get(params.uri);
118
- if (waiters) {
119
- this.diagnosticsWaiters.delete(params.uri);
120
- for (const waiter of waiters)
121
- waiter(list);
122
- }
123
- }
124
- return;
125
- }
126
- if (message.id === undefined)
127
- return; // other notifications ignored in v1
128
- const waiter = this.pending.get(Number(message.id));
129
- if (!waiter)
130
- return;
131
- this.pending.delete(Number(message.id));
132
- if (message.error)
133
- waiter.reject(new Error(message.error.message));
134
- else
135
- waiter.resolve(message.result);
136
- }
137
- request(method, params, timeoutMs = 10_000) {
138
- if (this.broken)
139
- return Promise.reject(this.broken);
140
- const id = this.nextId++;
141
- const payload = encodeMessage({ jsonrpc: "2.0", id, method, params });
142
- const { promise, resolve, reject } = Promise.withResolvers();
143
- const timer = setTimeout(() => {
144
- this.pending.delete(id);
145
- reject(new Error(`lsp ${method} timed out`));
146
- }, timeoutMs);
147
- this.pending.set(id, {
148
- resolve: (value) => {
149
- clearTimeout(timer);
150
- resolve(value);
151
- },
152
- reject: (error) => {
153
- clearTimeout(timer);
154
- reject(error);
155
- },
156
- });
157
- try {
158
- this.io.stdin.write(payload);
159
- }
160
- catch (error) {
161
- // A pipe that is already gone throws here instead of emitting; the caller
162
- // must see a failure, not a request that waits out its timeout.
163
- const reason = error instanceof Error ? error : new Error(String(error));
164
- this.pending.delete(id);
165
- clearTimeout(timer);
166
- reject(reason);
167
- this.fail(reason);
168
- }
169
- return promise;
170
- }
171
- notify(method, params) {
172
- if (this.broken)
173
- return;
174
- try {
175
- this.io.stdin.write(encodeMessage({ jsonrpc: "2.0", method, params }));
176
- }
177
- catch (error) {
178
- // Notifications have no caller waiting, so a dead pipe would surface as an
179
- // uncaught exception from a fire-and-forget write. Record it instead.
180
- this.fail(error instanceof Error ? error : new Error(String(error)));
181
- }
182
- }
183
- async initialize() {
184
- await this.request("initialize", {
185
- processId: null,
186
- rootUri: pathToFileURL(this.io.root).toString(),
187
- capabilities: { textDocument: { hover: {}, definition: {}, references: {}, documentSymbol: {}, publishDiagnostics: {} } },
188
- });
189
- this.notify("initialized", {});
190
- }
191
- // Positions are 0-based on the wire; callers that take editor-style 1-based lines subtract one before calling.
192
- async hover(file, line, character) {
193
- const result = (await this.request("textDocument/hover", {
194
- textDocument: { uri: pathToFileURL(file).toString() },
195
- position: { line, character },
196
- }));
197
- return result?.contents?.value;
198
- }
199
- async definition(file, line, character) {
200
- const result = (await this.request("textDocument/definition", {
201
- textDocument: { uri: pathToFileURL(file).toString() },
202
- position: { line, character },
203
- }));
204
- if (Array.isArray(result))
205
- return result[0];
206
- return result ?? undefined;
207
- }
208
- async references(file, line, character) {
209
- const result = await this.request("textDocument/references", {
210
- textDocument: { uri: pathToFileURL(file).toString() },
211
- position: { line, character },
212
- context: { includeDeclaration: true },
213
- });
214
- return Array.isArray(result) ? result : [];
215
- }
216
- async documentSymbols(file) {
217
- const result = await this.request("textDocument/documentSymbol", {
218
- textDocument: { uri: pathToFileURL(file).toString() },
219
- });
220
- return Array.isArray(result) ? result : [];
221
- }
222
- /**
223
- * Stored `publishDiagnostics` for the file, waiting up to `timeoutMs` for the
224
- * first publish. A server that never publishes gets `[]`, never a hang.
225
- */
226
- diagnostics(file, timeoutMs = 2_000) {
227
- const uri = pathToFileURL(file).toString();
228
- const stored = this.diagnosticsByUri.get(uri);
229
- if (stored)
230
- return Promise.resolve(stored);
231
- const { promise, resolve } = Promise.withResolvers();
232
- const timer = setTimeout(() => {
233
- const waiters = this.diagnosticsWaiters.get(uri);
234
- if (waiters) {
235
- this.diagnosticsWaiters.delete(uri);
236
- for (const waiter of waiters)
237
- waiter(this.diagnosticsByUri.get(uri) ?? []);
238
- }
239
- }, timeoutMs);
240
- const waiters = this.diagnosticsWaiters.get(uri) ?? [];
241
- waiters.push((value) => {
242
- clearTimeout(timer);
243
- resolve(value);
244
- });
245
- this.diagnosticsWaiters.set(uri, waiters);
246
- return promise;
247
- }
248
- async ensureOpen(file, text, languageId) {
249
- const uri = pathToFileURL(file).toString();
250
- if (this.opened.has(uri))
251
- return;
252
- this.notify("textDocument/didOpen", {
253
- textDocument: { uri, languageId, version: 1, text },
254
- });
255
- this.opened.add(uri);
256
- }
257
- dispose() {
258
- for (const [id, waiter] of this.pending) {
259
- waiter.reject(new Error("lsp client disposed"));
260
- this.pending.delete(id);
261
- }
262
- // Flush diagnostic waiters so a dispose mid-query answers [] instead of
263
- // leaving the timer to fire into a dead client.
264
- for (const [uri, waiters] of this.diagnosticsWaiters) {
265
- const stored = this.diagnosticsByUri.get(uri) ?? [];
266
- this.diagnosticsWaiters.delete(uri);
267
- for (const waiter of waiters)
268
- waiter(stored);
269
- }
270
- }
271
- }
272
- export const SERVERS = [
273
- {
274
- command: "typescript-language-server",
275
- args: ["--stdio"],
276
- languageId: "typescript",
277
- match: (file) => /\.(ts|tsx|js|jsx|mts|cts)$/.test(file),
278
- },
279
- { command: "rust-analyzer", args: [], languageId: "rust", match: (file) => file.endsWith(".rs") },
280
- { command: "gopls", args: [], languageId: "go", match: (file) => file.endsWith(".go") },
281
- { command: "pylsp", args: [], languageId: "python", match: (file) => file.endsWith(".py") },
282
- ];
283
- export function specFor(file) {
284
- return SERVERS.find((spec) => spec.match(file));
285
- }
286
- /** Process-wide, keyed by server command (see the header): every session and
287
- * subagent shares one server per language. */
288
- const clients = new Map();
289
- async function clientFor(spec, root) {
290
- const existing = clients.get(spec.command);
291
- if (existing) {
292
- await existing.ready;
293
- return existing.client;
294
- }
295
- // cwd is the session's project root, not the file's directory: gopls and
296
- // rust-analyzer want the module root, and `rootUri` is set from it too.
297
- const child = spawn(spec.command, spec.args, { cwd: root, stdio: ["pipe", "pipe", "ignore"] });
298
- const stdin = child.stdin;
299
- const stdout = child.stdout;
300
- if (!stdin || !stdout) {
301
- child.kill();
302
- throw new Error(`${spec.command}: stdio pipes unavailable`);
303
- }
304
- const client = new LspClient({ stdin, stdout, root });
305
- // A spawn that fails after the PATH check (EACCES, a stale symlink) must not
306
- // crash the process on an unhandled 'error' event or leave a corpse pooled.
307
- child.on("error", () => {
308
- const pooled = clients.get(spec.command);
309
- if (pooled?.child === child)
310
- clients.delete(spec.command);
311
- pooled?.client.dispose();
312
- });
313
- // A server that dies on its own has to leave the pool, or every later query
314
- // for this language talks to a client whose pipes are gone and waits out a
315
- // timeout before failing.
316
- child.on("exit", () => {
317
- const pooled = clients.get(spec.command);
318
- if (pooled?.child === child)
319
- clients.delete(spec.command);
320
- client.dispose();
321
- });
322
- const ready = client.initialize().catch((error) => {
323
- clients.delete(spec.command);
324
- child.kill();
325
- throw error;
326
- });
327
- clients.set(spec.command, { client, child, ready });
328
- await ready;
329
- return client;
330
- }
331
- /**
332
- * Kill every pooled server. Called from the main session's `dispose`
333
- * (`openSession` in `session.ts`) and on process `exit` — subagent sessions
334
- * share the pool, so only the process-wide teardown kills it (see the header).
335
- */
336
- export function disposeAllLsp() {
337
- for (const [command, pooled] of clients) {
338
- clients.delete(command);
339
- pooled.client.dispose();
340
- pooled.child.kill();
341
- }
342
- }
343
- process.on("exit", disposeAllLsp);
344
- const SEVERITY_NAME = { 1: "error", 2: "warning", 3: "info", 4: "hint" };
345
- function locationText(location) {
346
- let file = location.uri;
347
- try {
348
- file = fileURLToPath(location.uri);
349
- }
350
- catch {
351
- // Not a file:// uri — a workspace or virtual scheme; show it as it came.
352
- }
353
- const start = location.range?.start;
354
- return start ? `${file}:${start.line + 1}:${start.character + 1}` : file;
355
- }
356
- function symbolRows(symbols, depth = 0) {
357
- const rows = [];
358
- for (const symbol of symbols) {
359
- const line = (symbol.selectionRange ?? symbol.range)?.start.line;
360
- rows.push(`${" ".repeat(depth)}${symbol.name}${line === undefined ? "" : ` :${line + 1}`}`);
361
- if (symbol.children)
362
- rows.push(...symbolRows(symbol.children, depth + 1));
363
- }
364
- return rows;
365
- }
366
- /**
367
- * Run one language-server query. Every failure mode — no server for this file,
368
- * a missing binary, no line, an unreadable file, a dead child — comes back as
369
- * `{ ok: false, error }`; the tool contract is results, never throws.
370
- */
371
- export async function askLsp(query) {
372
- try {
373
- const spec = specFor(query.file);
374
- if (!spec)
375
- return { ok: false, error: `no language server configured for ${query.file}` };
376
- const needsLine = query.action === "definition" || query.action === "references" || query.action === "hover";
377
- if (needsLine && (query.line === undefined || !Number.isInteger(query.line) || query.line < 1)) {
378
- return { ok: false, error: `line required (1-based) for ${query.action}` };
379
- }
380
- // PATH presence is checked by scanning, not by spawning: a missing binary
381
- // has to become a tool result, and a probe child would cost a fork per call.
382
- const onPath = spec.command.includes("/") ? existsSync(spec.command) : (process.env.PATH ?? "").split(delimiter).some((dir) => existsSync(join(dir, spec.command)));
383
- if (!onPath) {
384
- return { ok: false, error: `${spec.command} is not on PATH. Install it to use lsp on ${query.file}.` };
385
- }
386
- const file = isAbsolute(query.file) ? query.file : join(query.root, query.file);
387
- const text = readFileSync(file, "utf8");
388
- const client = await clientFor(spec, query.root);
389
- await client.ensureOpen(file, text, spec.languageId);
390
- // The tool takes 1-based lines (editor habit); LSP positions on the wire
391
- // are 0-based — this is that conversion.
392
- const wireLine = (query.line ?? 1) - 1;
393
- let character = 0;
394
- if (query.symbol) {
395
- const at = (text.split("\n")[wireLine] ?? "").indexOf(query.symbol);
396
- if (at >= 0)
397
- character = at;
398
- }
399
- switch (query.action) {
400
- case "definition": {
401
- const location = await client.definition(file, wireLine, character);
402
- if (!location)
403
- return { ok: true, text: "no definition found", count: 0 };
404
- return { ok: true, text: locationText(location), count: 1 };
405
- }
406
- case "references": {
407
- const locations = await client.references(file, wireLine, character);
408
- const lines = locations.map(locationText);
409
- return { ok: true, text: lines.length > 0 ? lines.join("\n") : "no references found", count: lines.length };
410
- }
411
- case "hover": {
412
- const hover = (await client.hover(file, wireLine, character))?.trim();
413
- return { ok: true, text: hover ? hover : "no hover at that position" };
414
- }
415
- case "symbols": {
416
- const rows = symbolRows(await client.documentSymbols(file));
417
- return { ok: true, text: rows.length > 0 ? rows.join("\n") : "no symbols in this file", count: rows.length };
418
- }
419
- case "diagnostics": {
420
- const diagnostics = await client.diagnostics(file);
421
- const rows = diagnostics.map((d) => `${d.range.start.line + 1}:${d.range.start.character + 1} ${SEVERITY_NAME[d.severity ?? 3] ?? "info"} ${d.message}`);
422
- return { ok: true, text: rows.length > 0 ? rows.join("\n") : "no diagnostics", count: rows.length };
423
- }
424
- }
425
- }
426
- catch (error) {
427
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
428
- }
429
- }
@@ -1,85 +0,0 @@
1
- /**
2
- * The MCP configuration the session should load, read at the moment the SDK asks.
3
- *
4
- * The SDK's MCP extension reads `mcp.json` exactly once, on `session_start`. A
5
- * server removed from the menu afterwards stays configured as far as that
6
- * extension is concerned, so nothing closes its transport and the process keeps
7
- * running for the life of the CLI — the leak the preset menu could not see,
8
- * because the menu only ever edited the file.
9
- *
10
- * The extension's teardown is correct and already exists: it closes every server
11
- * whose config changed when `loadConfig` returns a set that no longer names it.
12
- * What it needed was a loader that re-reads the file each time it is called, so
13
- * that a change made mid-session is visible to it. That is all this is.
14
- *
15
- * The shape mirrors what the SDK's own loader produces, because the extension
16
- * consumes it directly: `servers`, `errors`, and the project path. Entries keep
17
- * whatever keys the file holds, since the SDK reads `command`, `args`, `env`,
18
- * `url`, `enabled`, and `exposure` from them.
19
- */
20
- import { existsSync, readFileSync } from "node:fs";
21
- import { AGENT_DIR } from "./paths.js";
22
- import { mcpConfigPath } from "./preset-mcp.js";
23
- /** The project's own `mcp.json`, which takes precedence when the project is trusted. */
24
- export function projectMcpConfigPath(cwd) {
25
- return `${cwd}/.howtofix/mcp.json`;
26
- }
27
- /**
28
- * Read one `mcp.json`. A missing file is an empty config; an unparseable one is
29
- * an error, never a crash — a broken file must not stop the session from
30
- * starting, and the user needs to be told which file is broken.
31
- */
32
- function readOne(path, errors) {
33
- if (!existsSync(path))
34
- return {};
35
- try {
36
- const parsed = JSON.parse(readFileSync(path, "utf8"));
37
- const servers = parsed?.mcpServers;
38
- if (!servers || typeof servers !== "object" || Array.isArray(servers))
39
- return {};
40
- return servers;
41
- }
42
- catch (error) {
43
- errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
44
- return {};
45
- }
46
- }
47
- /**
48
- * Load global and (when trusted) project MCP configuration.
49
- *
50
- * Re-read on every call. The extension calls this on each `session_start`, and
51
- * the session's own reload path re-emits it, so this is what turns "the file
52
- * changed" into "the running session changed".
53
- */
54
- /**
55
- * The global `mcp.json` as the menu needs it: names and specs, no merging.
56
- *
57
- * The menu shows what a removal will act on, so it must read the same file the
58
- * loader reads. A second reader here would be a second opinion about a config
59
- * file, and the two would disagree the first time either changed.
60
- */
61
- export function readGlobalMcpServers(agentDir) {
62
- const errors = [];
63
- return readOne(mcpConfigPath(agentDir ?? AGENT_DIR), errors);
64
- }
65
- export function loadMcpConfiguration(options) {
66
- const errors = [];
67
- const globalServers = readOne(mcpConfigPath(options.agentDir ?? AGENT_DIR), errors);
68
- const projectPath = projectMcpConfigPath(options.cwd);
69
- const projectConfig = options.projectTrusted && existsSync(projectPath) ? projectPath : undefined;
70
- const projectServers = projectConfig ? readOne(projectConfig, errors) : {};
71
- // The project overrides the global entry of the same name, which is the same
72
- // precedence the SDK applies and the reason `/mcp` saves project overrides to
73
- // this file.
74
- const globalPath = mcpConfigPath(options.agentDir ?? AGENT_DIR);
75
- const merged = new Map();
76
- for (const [name, spec] of Object.entries(globalServers)) {
77
- merged.set(name, { name, config: spec, source: globalPath, scope: "global" });
78
- }
79
- for (const [name, spec] of Object.entries(projectServers)) {
80
- // A project entry of the same name overrides the global one, and takes over
81
- // as the file `/mcp` writes to.
82
- merged.set(name, { name, config: spec, source: projectConfig, scope: "project" });
83
- }
84
- return { servers: [...merged.values()], errors, projectConfig };
85
- }
@@ -1,147 +0,0 @@
1
- /**
2
- * One lifecycle seam for MCP configuration changes.
3
- *
4
- * Editing `mcp.json` and making the running session match it are two different
5
- * things, and only the first was ever done. Removing a server from the preset
6
- * menu wrote the file and stopped: the live transport, its child process, and the
7
- * `mcp__<server>__<tool>` registrations stayed in the session until the CLI
8
- * exited, so a server the user had just removed was still reachable by the agent.
9
- * Installing had the mirror-image problem — the reload was a `void` call, so two
10
- * quick presses could overlap a config write with a session reload and land in
11
- * either order.
12
- *
13
- * This module owns the second half. Every path that changes MCP configuration
14
- * funnels through `reconcile`, which:
15
- *
16
- * - treats the on-disk configuration as the desired state and the active server
17
- * set as the actual state, then moves one toward the other;
18
- * - runs single-flight: a second request joins the one in progress rather than
19
- * starting another, and the *latest* desired state is what gets applied, so a
20
- * change made during a run is not lost;
21
- * - never lets a failure escape as an unhandled rejection, because every caller
22
- * reaches it from a `void` inside a picker handler.
23
- *
24
- * It deliberately does not know how the SDK starts or stops a server. The caller
25
- * supplies that through `apply`, which keeps this testable without a live
26
- * transport and keeps the SDK's MCP surface in one place.
27
- */
28
- /** Raised names that could not be released. Reported, never thrown onward. */
29
- function describeError(error) {
30
- return error instanceof Error ? error.message : String(error);
31
- }
32
- export class McpLifecycle {
33
- /** Names currently active in the session. */
34
- active;
35
- apply;
36
- report;
37
- /** The newest desired set seen while a reconciliation was running. */
38
- pending;
39
- /** The in-flight reconciliation, if any. Callers join this instead of starting one. */
40
- running;
41
- constructor(options) {
42
- this.active = new Set(options.initial ?? []);
43
- this.apply = options.apply;
44
- this.report = options.report ?? ((message) => console.error(message));
45
- }
46
- /** Names the session currently holds. A copy, so a caller cannot mutate the state. */
47
- activeServers() {
48
- return [...this.active];
49
- }
50
- /**
51
- * Move the active set toward `desired`.
52
- *
53
- * Concurrent callers join the run in progress; the desired set is replaced
54
- * before joining, so the last request wins and no edit is dropped. That is the
55
- * difference between coalescing and dropping: an install that arrives while a
56
- * removal is running is applied by the time the shared promise settles, not
57
- * discarded because the slot was taken.
58
- */
59
- reconcile(desired) {
60
- this.pending = new Set(desired);
61
- if (this.running)
62
- return this.running;
63
- this.running = this.drain().finally(() => {
64
- this.running = undefined;
65
- });
66
- return this.running;
67
- }
68
- /**
69
- * Apply desired sets until the newest one has been applied.
70
- *
71
- * A single pass would leave the last request unapplied whenever a change
72
- * arrived mid-run, which is exactly the case this exists for.
73
- */
74
- async drain() {
75
- const total = { added: [], removed: [], unchanged: [], problems: [] };
76
- while (this.pending !== undefined) {
77
- const desired = this.pending;
78
- this.pending = undefined;
79
- const pass = await this.applyOnce(desired);
80
- total.added.push(...pass.added);
81
- total.removed.push(...pass.removed);
82
- total.unchanged.push(...pass.unchanged);
83
- total.problems.push(...pass.problems);
84
- }
85
- // Names can appear in both an add and a later remove within one reconcile
86
- // call; report each name once, with the final state deciding which list.
87
- const finalActive = this.active;
88
- total.added = total.added.filter((name) => finalActive.has(name));
89
- total.removed = [...new Set(total.removed.filter((name) => !finalActive.has(name)))];
90
- total.added = [...new Set(total.added)];
91
- total.unchanged = [...new Set(total.unchanged.filter((name) => finalActive.has(name)))];
92
- return total;
93
- }
94
- async applyOnce(desired) {
95
- const added = [...desired].filter((name) => !this.active.has(name));
96
- const removed = [...this.active].filter((name) => !desired.has(name));
97
- const unchanged = [...desired].filter((name) => this.active.has(name));
98
- if (added.length === 0 && removed.length === 0) {
99
- return { added: [], removed: [], unchanged, problems: [] };
100
- }
101
- try {
102
- await this.apply(added, removed);
103
- }
104
- catch (error) {
105
- // The session may be partly reconciled. Report which half failed rather
106
- // than claiming the configuration and the session agree.
107
- const message = `mcp reconciliation failed: ${describeError(error)}`;
108
- this.report(message);
109
- return { added: [], removed: [], unchanged: [], problems: [message] };
110
- }
111
- // Recorded only after `apply` resolves: a teardown that threw has not
112
- // released the server, so the active set must still name it or the next
113
- // reconcile would skip the release it still owes.
114
- for (const name of added)
115
- this.active.add(name);
116
- for (const name of removed)
117
- this.active.delete(name);
118
- return { added, removed, unchanged, problems: [] };
119
- }
120
- /**
121
- * Release everything this session owns.
122
- *
123
- * Called when the session is replaced or the CLI exits. Without it, repeated
124
- * `/new` and `/resume` accumulate one set of server processes per session,
125
- * which is the leak the picker path could not see because it never disposes a
126
- * session at all.
127
- */
128
- async dispose() {
129
- // A reconcile still in flight owns the transports; wait for it so we do not
130
- // dispose a set it is in the middle of mutating.
131
- if (this.running)
132
- await this.running.catch(() => undefined);
133
- const names = [...this.active];
134
- if (names.length === 0)
135
- return { added: [], removed: [], unchanged: [], problems: [] };
136
- try {
137
- await this.apply([], names);
138
- this.active.clear();
139
- return { added: [], removed: names, unchanged: [], problems: [] };
140
- }
141
- catch (error) {
142
- const message = `mcp teardown failed: ${describeError(error)}`;
143
- this.report(message);
144
- return { added: [], removed: [], unchanged: [], problems: [message] };
145
- }
146
- }
147
- }