howtofixcli 0.7.7 → 0.7.9

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 (152) 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-63ZCQDXH.js +6 -0
  5. package/dist/chunk-6FUWMLWO.js +1 -0
  6. package/dist/chunk-7CHDNTH6.js +1 -0
  7. package/dist/chunk-ACOMNDUL.js +185 -0
  8. package/dist/chunk-AEVPET7X.js +2 -0
  9. package/dist/chunk-AGGIYXN7.js +2 -0
  10. package/dist/chunk-BHVBO4SC.js +1 -0
  11. package/dist/chunk-FPXVDP32.js +8 -0
  12. package/dist/chunk-GIUYIKH3.js +109 -0
  13. package/dist/chunk-GQCAJ56I.js +5 -0
  14. package/dist/chunk-GXB7RZHH.js +1 -0
  15. package/dist/chunk-IGEY3AWF.js +1 -0
  16. package/dist/chunk-ILDDDLRC.js +1 -0
  17. package/dist/chunk-JFYVRWJI.js +2 -0
  18. package/dist/chunk-MVLCGU3G.js +1 -0
  19. package/dist/chunk-N345KWWK.js +1 -0
  20. package/dist/chunk-NBQFPCRT.js +1 -0
  21. package/dist/chunk-OIPSLIZE.js +5 -0
  22. package/dist/chunk-ORJ2QZBG.js +3 -0
  23. package/dist/chunk-PCALBZOO.js +2 -0
  24. package/dist/chunk-PDNBGUYP.js +2 -0
  25. package/dist/chunk-S656RE4A.js +1 -0
  26. package/dist/chunk-TSEKPGK4.js +1 -0
  27. package/dist/chunk-U6CI6KXN.js +34 -0
  28. package/dist/chunk-UTYFLLSM.js +1 -0
  29. package/dist/chunk-V3R4FYTJ.js +1 -0
  30. package/dist/chunk-V656XVXV.js +8 -0
  31. package/dist/chunk-WKUJ7CFO.js +1 -0
  32. package/dist/chunk-WOT6VMZA.js +1 -0
  33. package/dist/chunk-WUPU6LTD.js +4 -0
  34. package/dist/chunk-WX3V5P7U.js +5 -0
  35. package/dist/chunk-ZKZ6WCI3.js +2 -0
  36. package/dist/main.js +94 -536
  37. package/package.json +15 -62
  38. package/dist/account-credential.js +0 -136
  39. package/dist/account-credential.test.js +0 -38
  40. package/dist/activity.js +0 -124
  41. package/dist/agent-defs.js +0 -237
  42. package/dist/agent-limiter.js +0 -68
  43. package/dist/agents.js +0 -554
  44. package/dist/anim.js +0 -361
  45. package/dist/args.js +0 -228
  46. package/dist/at-file.js +0 -28
  47. package/dist/brand-extension.js +0 -87
  48. package/dist/brand.js +0 -76
  49. package/dist/browser.js +0 -58
  50. package/dist/cache-report.js +0 -139
  51. package/dist/checkpoint.js +0 -437
  52. package/dist/claims.js +0 -167
  53. package/dist/cli-auth.js +0 -142
  54. package/dist/cli-checkin.js +0 -116
  55. package/dist/cli-credentials.js +0 -254
  56. package/dist/clipboard.js +0 -149
  57. package/dist/collapse.js +0 -129
  58. package/dist/commands.js +0 -301
  59. package/dist/compaction-guard.js +0 -81
  60. package/dist/context-extension.js +0 -17
  61. package/dist/dcp/commands.js +0 -172
  62. package/dist/dcp/compress-state.js +0 -139
  63. package/dist/dcp/compress.js +0 -329
  64. package/dist/dcp/dcp-extension.js +0 -288
  65. package/dist/dcp/dedup.js +0 -93
  66. package/dist/dcp/ids.js +0 -189
  67. package/dist/dcp/index.js +0 -25
  68. package/dist/dcp/prompts.js +0 -68
  69. package/dist/dcp/protected-content.js +0 -221
  70. package/dist/dcp/protected.js +0 -161
  71. package/dist/dcp/prune.js +0 -418
  72. package/dist/dcp/state.js +0 -176
  73. package/dist/device-id.js +0 -90
  74. package/dist/device-id.test.js +0 -23
  75. package/dist/errors.js +0 -63
  76. package/dist/extensions.js +0 -48
  77. package/dist/fatal.js +0 -35
  78. package/dist/feed.js +0 -498
  79. package/dist/fs-atomic.js +0 -54
  80. package/dist/gateway-errors.js +0 -90
  81. package/dist/git.js +0 -230
  82. package/dist/goal-extension.js +0 -88
  83. package/dist/goal.js +0 -222
  84. package/dist/hooks-extension.js +0 -176
  85. package/dist/hooks.js +0 -443
  86. package/dist/howtofix-account.js +0 -715
  87. package/dist/hud-line.js +0 -153
  88. package/dist/hwid.js +0 -130
  89. package/dist/interrupt.js +0 -60
  90. package/dist/json-events.js +0 -50
  91. package/dist/level-style.js +0 -112
  92. package/dist/loop-guard.js +0 -95
  93. package/dist/lsp-tools.js +0 -32
  94. package/dist/lsp.js +0 -347
  95. package/dist/mcp-config.js +0 -85
  96. package/dist/mcp-lifecycle.js +0 -147
  97. package/dist/model-catalog.js +0 -217
  98. package/dist/model-resolve.js +0 -65
  99. package/dist/notes.js +0 -78
  100. package/dist/oauth-login.js +0 -568
  101. package/dist/paths.js +0 -61
  102. package/dist/permissions.js +0 -298
  103. package/dist/plan-mode.js +0 -363
  104. package/dist/preset-command.js +0 -227
  105. package/dist/preset-extension.js +0 -35
  106. package/dist/preset-mcp.js +0 -292
  107. package/dist/presets.js +0 -727
  108. package/dist/prompt-files.js +0 -103
  109. package/dist/prompt-text.js +0 -41
  110. package/dist/proxy.js +0 -137
  111. package/dist/quality/ast.js +0 -196
  112. package/dist/quality/cognitive.js +0 -83
  113. package/dist/quality/cyclomatic.js +0 -30
  114. package/dist/quality/halstead.js +0 -83
  115. package/dist/quality/index.js +0 -18
  116. package/dist/quality/length.js +0 -3
  117. package/dist/quality/maintainability.js +0 -15
  118. package/dist/quality/nesting.js +0 -57
  119. package/dist/quality/report.js +0 -803
  120. package/dist/quality/smells.js +0 -67
  121. package/dist/quality-tool.js +0 -63
  122. package/dist/read-only-commands.js +0 -597
  123. package/dist/repo-map.js +0 -292
  124. package/dist/rtk.js +0 -193
  125. package/dist/sandbox.js +0 -105
  126. package/dist/schema.js +0 -214
  127. package/dist/session-lifecycle.js +0 -19
  128. package/dist/session-retention.js +0 -111
  129. package/dist/session.js +0 -446
  130. package/dist/sessions.js +0 -100
  131. package/dist/settings.js +0 -148
  132. package/dist/skills.js +0 -35
  133. package/dist/slop-tool.js +0 -96
  134. package/dist/spend-cap.js +0 -43
  135. package/dist/subagent.js +0 -171
  136. package/dist/tar.js +0 -146
  137. package/dist/theme.js +0 -79
  138. package/dist/thinking.js +0 -45
  139. package/dist/todo.js +0 -114
  140. package/dist/tool-loadout.js +0 -97
  141. package/dist/tool-render.js +0 -769
  142. package/dist/tools.js +0 -176
  143. package/dist/ui.js +0 -4751
  144. package/dist/update.js +0 -533
  145. package/dist/usage-segment.js +0 -69
  146. package/dist/web-tools.js +0 -46
  147. package/dist/web.js +0 -265
  148. package/dist/welcome.js +0 -179
  149. package/dist/workflow-engine.js +0 -246
  150. package/dist/workflow-progress.js +0 -446
  151. package/dist/workflow-view.js +0 -195
  152. package/dist/workflow.js +0 -349
package/dist/lsp.js DELETED
@@ -1,347 +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
- export function decodeMessages(buffer) {
22
- const messages = [];
23
- let offset = 0;
24
- while (offset < buffer.length) {
25
- const headerEnd = buffer.indexOf("\r\n\r\n", offset);
26
- if (headerEnd === -1)
27
- break;
28
- const header = buffer.subarray(offset, headerEnd).toString("utf8");
29
- const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
30
- if (!lengthMatch)
31
- break;
32
- const length = Number(lengthMatch[1]);
33
- const bodyStart = headerEnd + 4;
34
- const bodyEnd = bodyStart + length;
35
- if (bodyEnd > buffer.length)
36
- break;
37
- messages.push(JSON.parse(buffer.subarray(bodyStart, bodyEnd).toString("utf8")));
38
- offset = bodyEnd;
39
- }
40
- return { messages, rest: buffer.subarray(offset) };
41
- }
42
- export class LspClient {
43
- io;
44
- nextId = 1;
45
- pending = new Map();
46
- buffer = Buffer.alloc(0);
47
- opened = new Set();
48
- diagnosticsByUri = new Map();
49
- diagnosticsWaiters = new Map();
50
- constructor(io) {
51
- this.io = io;
52
- io.stdout.on("data", (chunk) => {
53
- this.buffer = Buffer.concat([this.buffer, chunk]);
54
- const { messages, rest } = decodeMessages(this.buffer);
55
- this.buffer = rest;
56
- for (const message of messages)
57
- this.dispatch(message);
58
- });
59
- }
60
- dispatch(message) {
61
- if (message.method === "textDocument/publishDiagnostics") {
62
- const params = message.params;
63
- if (params?.uri) {
64
- const list = Array.isArray(params.diagnostics) ? params.diagnostics : [];
65
- this.diagnosticsByUri.set(params.uri, list);
66
- const waiters = this.diagnosticsWaiters.get(params.uri);
67
- if (waiters) {
68
- this.diagnosticsWaiters.delete(params.uri);
69
- for (const waiter of waiters)
70
- waiter(list);
71
- }
72
- }
73
- return;
74
- }
75
- if (message.id === undefined)
76
- return; // other notifications ignored in v1
77
- const waiter = this.pending.get(Number(message.id));
78
- if (!waiter)
79
- return;
80
- this.pending.delete(Number(message.id));
81
- if (message.error)
82
- waiter.reject(new Error(message.error.message));
83
- else
84
- waiter.resolve(message.result);
85
- }
86
- request(method, params, timeoutMs = 10_000) {
87
- const id = this.nextId++;
88
- const payload = encodeMessage({ jsonrpc: "2.0", id, method, params });
89
- const { promise, resolve, reject } = Promise.withResolvers();
90
- const timer = setTimeout(() => {
91
- this.pending.delete(id);
92
- reject(new Error(`lsp ${method} timed out`));
93
- }, timeoutMs);
94
- this.pending.set(id, {
95
- resolve: (value) => {
96
- clearTimeout(timer);
97
- resolve(value);
98
- },
99
- reject: (error) => {
100
- clearTimeout(timer);
101
- reject(error);
102
- },
103
- });
104
- this.io.stdin.write(payload);
105
- return promise;
106
- }
107
- notify(method, params) {
108
- this.io.stdin.write(encodeMessage({ jsonrpc: "2.0", method, params }));
109
- }
110
- async initialize() {
111
- await this.request("initialize", {
112
- processId: null,
113
- rootUri: pathToFileURL(this.io.root).toString(),
114
- capabilities: { textDocument: { hover: {}, definition: {}, references: {}, documentSymbol: {}, publishDiagnostics: {} } },
115
- });
116
- this.notify("initialized", {});
117
- }
118
- // Positions are 0-based on the wire; callers that take editor-style 1-based lines subtract one before calling.
119
- async hover(file, line, character) {
120
- const result = (await this.request("textDocument/hover", {
121
- textDocument: { uri: pathToFileURL(file).toString() },
122
- position: { line, character },
123
- }));
124
- return result?.contents?.value;
125
- }
126
- async definition(file, line, character) {
127
- const result = (await this.request("textDocument/definition", {
128
- textDocument: { uri: pathToFileURL(file).toString() },
129
- position: { line, character },
130
- }));
131
- if (Array.isArray(result))
132
- return result[0];
133
- return result ?? undefined;
134
- }
135
- async references(file, line, character) {
136
- const result = await this.request("textDocument/references", {
137
- textDocument: { uri: pathToFileURL(file).toString() },
138
- position: { line, character },
139
- context: { includeDeclaration: true },
140
- });
141
- return Array.isArray(result) ? result : [];
142
- }
143
- async documentSymbols(file) {
144
- const result = await this.request("textDocument/documentSymbol", {
145
- textDocument: { uri: pathToFileURL(file).toString() },
146
- });
147
- return Array.isArray(result) ? result : [];
148
- }
149
- /**
150
- * Stored `publishDiagnostics` for the file, waiting up to `timeoutMs` for the
151
- * first publish. A server that never publishes gets `[]`, never a hang.
152
- */
153
- diagnostics(file, timeoutMs = 2_000) {
154
- const uri = pathToFileURL(file).toString();
155
- const stored = this.diagnosticsByUri.get(uri);
156
- if (stored)
157
- return Promise.resolve(stored);
158
- const { promise, resolve } = Promise.withResolvers();
159
- const timer = setTimeout(() => {
160
- const waiters = this.diagnosticsWaiters.get(uri);
161
- if (waiters) {
162
- this.diagnosticsWaiters.delete(uri);
163
- for (const waiter of waiters)
164
- waiter(this.diagnosticsByUri.get(uri) ?? []);
165
- }
166
- }, timeoutMs);
167
- const waiters = this.diagnosticsWaiters.get(uri) ?? [];
168
- waiters.push((value) => {
169
- clearTimeout(timer);
170
- resolve(value);
171
- });
172
- this.diagnosticsWaiters.set(uri, waiters);
173
- return promise;
174
- }
175
- async ensureOpen(file, text, languageId) {
176
- const uri = pathToFileURL(file).toString();
177
- if (this.opened.has(uri))
178
- return;
179
- this.notify("textDocument/didOpen", {
180
- textDocument: { uri, languageId, version: 1, text },
181
- });
182
- this.opened.add(uri);
183
- }
184
- dispose() {
185
- for (const [id, waiter] of this.pending) {
186
- waiter.reject(new Error("lsp client disposed"));
187
- this.pending.delete(id);
188
- }
189
- // Flush diagnostic waiters so a dispose mid-query answers [] instead of
190
- // leaving the timer to fire into a dead client.
191
- for (const [uri, waiters] of this.diagnosticsWaiters) {
192
- const stored = this.diagnosticsByUri.get(uri) ?? [];
193
- this.diagnosticsWaiters.delete(uri);
194
- for (const waiter of waiters)
195
- waiter(stored);
196
- }
197
- }
198
- }
199
- export const SERVERS = [
200
- {
201
- command: "typescript-language-server",
202
- args: ["--stdio"],
203
- languageId: "typescript",
204
- match: (file) => /\.(ts|tsx|js|jsx|mts|cts)$/.test(file),
205
- },
206
- { command: "rust-analyzer", args: [], languageId: "rust", match: (file) => file.endsWith(".rs") },
207
- { command: "gopls", args: [], languageId: "go", match: (file) => file.endsWith(".go") },
208
- { command: "pylsp", args: [], languageId: "python", match: (file) => file.endsWith(".py") },
209
- ];
210
- export function specFor(file) {
211
- return SERVERS.find((spec) => spec.match(file));
212
- }
213
- /** Process-wide, keyed by server command (see the header): every session and
214
- * subagent shares one server per language. */
215
- const clients = new Map();
216
- async function clientFor(spec, root) {
217
- const existing = clients.get(spec.command);
218
- if (existing) {
219
- await existing.ready;
220
- return existing.client;
221
- }
222
- // cwd is the session's project root, not the file's directory: gopls and
223
- // rust-analyzer want the module root, and `rootUri` is set from it too.
224
- const child = spawn(spec.command, spec.args, { cwd: root, stdio: ["pipe", "pipe", "ignore"] });
225
- const stdin = child.stdin;
226
- const stdout = child.stdout;
227
- if (!stdin || !stdout) {
228
- child.kill();
229
- throw new Error(`${spec.command}: stdio pipes unavailable`);
230
- }
231
- const client = new LspClient({ stdin, stdout, root });
232
- // A spawn that fails after the PATH check (EACCES, a stale symlink) must not
233
- // crash the process on an unhandled 'error' event or leave a corpse pooled.
234
- child.on("error", () => {
235
- const pooled = clients.get(spec.command);
236
- if (pooled?.child === child)
237
- clients.delete(spec.command);
238
- pooled?.client.dispose();
239
- });
240
- const ready = client.initialize().catch((error) => {
241
- clients.delete(spec.command);
242
- child.kill();
243
- throw error;
244
- });
245
- clients.set(spec.command, { client, child, ready });
246
- await ready;
247
- return client;
248
- }
249
- /**
250
- * Kill every pooled server. Called from the main session's `dispose`
251
- * (`openSession` in `session.ts`) and on process `exit` — subagent sessions
252
- * share the pool, so only the process-wide teardown kills it (see the header).
253
- */
254
- export function disposeAllLsp() {
255
- for (const [command, pooled] of clients) {
256
- clients.delete(command);
257
- pooled.client.dispose();
258
- pooled.child.kill();
259
- }
260
- }
261
- process.on("exit", disposeAllLsp);
262
- const SEVERITY_NAME = { 1: "error", 2: "warning", 3: "info", 4: "hint" };
263
- function locationText(location) {
264
- let file = location.uri;
265
- try {
266
- file = fileURLToPath(location.uri);
267
- }
268
- catch {
269
- // Not a file:// uri — a workspace or virtual scheme; show it as it came.
270
- }
271
- const start = location.range?.start;
272
- return start ? `${file}:${start.line + 1}:${start.character + 1}` : file;
273
- }
274
- function symbolRows(symbols, depth = 0) {
275
- const rows = [];
276
- for (const symbol of symbols) {
277
- const line = (symbol.selectionRange ?? symbol.range)?.start.line;
278
- rows.push(`${" ".repeat(depth)}${symbol.name}${line === undefined ? "" : ` :${line + 1}`}`);
279
- if (symbol.children)
280
- rows.push(...symbolRows(symbol.children, depth + 1));
281
- }
282
- return rows;
283
- }
284
- /**
285
- * Run one language-server query. Every failure mode — no server for this file,
286
- * a missing binary, no line, an unreadable file, a dead child — comes back as
287
- * `{ ok: false, error }`; the tool contract is results, never throws.
288
- */
289
- export async function askLsp(query) {
290
- try {
291
- const spec = specFor(query.file);
292
- if (!spec)
293
- return { ok: false, error: `no language server configured for ${query.file}` };
294
- const needsLine = query.action === "definition" || query.action === "references" || query.action === "hover";
295
- if (needsLine && (query.line === undefined || !Number.isInteger(query.line) || query.line < 1)) {
296
- return { ok: false, error: `line required (1-based) for ${query.action}` };
297
- }
298
- // PATH presence is checked by scanning, not by spawning: a missing binary
299
- // has to become a tool result, and a probe child would cost a fork per call.
300
- const onPath = spec.command.includes("/") ? existsSync(spec.command) : (process.env.PATH ?? "").split(delimiter).some((dir) => existsSync(join(dir, spec.command)));
301
- if (!onPath) {
302
- return { ok: false, error: `${spec.command} is not on PATH. Install it to use lsp on ${query.file}.` };
303
- }
304
- const file = isAbsolute(query.file) ? query.file : join(query.root, query.file);
305
- const text = readFileSync(file, "utf8");
306
- const client = await clientFor(spec, query.root);
307
- await client.ensureOpen(file, text, spec.languageId);
308
- // The tool takes 1-based lines (editor habit); LSP positions on the wire
309
- // are 0-based — this is that conversion.
310
- const wireLine = (query.line ?? 1) - 1;
311
- let character = 0;
312
- if (query.symbol) {
313
- const at = (text.split("\n")[wireLine] ?? "").indexOf(query.symbol);
314
- if (at >= 0)
315
- character = at;
316
- }
317
- switch (query.action) {
318
- case "definition": {
319
- const location = await client.definition(file, wireLine, character);
320
- if (!location)
321
- return { ok: true, text: "no definition found", count: 0 };
322
- return { ok: true, text: locationText(location), count: 1 };
323
- }
324
- case "references": {
325
- const locations = await client.references(file, wireLine, character);
326
- const lines = locations.map(locationText);
327
- return { ok: true, text: lines.length > 0 ? lines.join("\n") : "no references found", count: lines.length };
328
- }
329
- case "hover": {
330
- const hover = (await client.hover(file, wireLine, character))?.trim();
331
- return { ok: true, text: hover ? hover : "no hover at that position" };
332
- }
333
- case "symbols": {
334
- const rows = symbolRows(await client.documentSymbols(file));
335
- return { ok: true, text: rows.length > 0 ? rows.join("\n") : "no symbols in this file", count: rows.length };
336
- }
337
- case "diagnostics": {
338
- const diagnostics = await client.diagnostics(file);
339
- const rows = diagnostics.map((d) => `${d.range.start.line + 1}:${d.range.start.character + 1} ${SEVERITY_NAME[d.severity ?? 3] ?? "info"} ${d.message}`);
340
- return { ok: true, text: rows.length > 0 ? rows.join("\n") : "no diagnostics", count: rows.length };
341
- }
342
- }
343
- }
344
- catch (error) {
345
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
346
- }
347
- }
@@ -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
- }