@sean.holung/minicode 0.3.1 → 0.3.3

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 (107) hide show
  1. package/README.md +52 -42
  2. package/dist/scripts/run-benchmarks.js +147 -0
  3. package/dist/src/agent/config.js +149 -40
  4. package/dist/src/agent/editable-config.js +314 -0
  5. package/dist/src/analysis/structural-analysis.js +379 -0
  6. package/dist/src/benchmark/evaluator.js +79 -0
  7. package/dist/src/benchmark/index.js +4 -0
  8. package/dist/src/benchmark/reporter.js +177 -0
  9. package/dist/src/benchmark/runner.js +100 -0
  10. package/dist/src/benchmark/task-loader.js +78 -0
  11. package/dist/src/benchmark/types.js +5 -0
  12. package/dist/src/cli/args.js +10 -0
  13. package/dist/src/cli/config-slash-command.js +135 -0
  14. package/dist/src/cli/plugin-install.js +69 -0
  15. package/dist/src/index.js +76 -6
  16. package/dist/src/indexer/cache.js +6 -4
  17. package/dist/src/indexer/code-map.js +41 -13
  18. package/dist/src/indexer/plugins/typescript.js +70 -23
  19. package/dist/src/indexer/project-index.js +175 -36
  20. package/dist/src/indexer/symbol-names.js +92 -0
  21. package/dist/src/model-utils.js +18 -0
  22. package/dist/src/serve/agent-bridge.js +203 -24
  23. package/dist/src/serve/mcp-server.js +405 -0
  24. package/dist/src/serve/server.js +165 -10
  25. package/dist/src/serve/websocket.js +8 -0
  26. package/dist/src/shared/graph-styles.js +119 -0
  27. package/dist/src/tools/find-path.js +75 -0
  28. package/dist/src/tools/find-references.js +7 -2
  29. package/dist/src/tools/get-dependencies.js +3 -2
  30. package/dist/src/tools/read-symbol.js +12 -5
  31. package/dist/src/tools/registry.js +3 -1
  32. package/dist/src/tools/search-code-map.js +4 -2
  33. package/dist/src/ui/app.js +1 -1
  34. package/dist/src/ui/cli-ink.js +79 -4
  35. package/dist/src/ui/components/header-bar.js +6 -2
  36. package/dist/src/ui/state/ui-store.js +5 -0
  37. package/dist/src/web/app.js +1124 -176
  38. package/dist/src/web/index.html +113 -3
  39. package/dist/src/web/style.css +973 -55
  40. package/dist/tests/agent.test.js +31 -0
  41. package/dist/tests/analysis-helpers.test.js +89 -0
  42. package/dist/tests/analysis-ui.test.js +29 -0
  43. package/dist/tests/benchmark-harness.test.js +527 -0
  44. package/dist/tests/config-api.test.js +143 -0
  45. package/dist/tests/config-integration.test.js +751 -0
  46. package/dist/tests/config-slash-command.test.js +106 -0
  47. package/dist/tests/config.test.js +42 -1
  48. package/dist/tests/context-indicator.test.js +220 -0
  49. package/dist/tests/editable-config.test.js +109 -0
  50. package/dist/tests/find-path.test.js +183 -0
  51. package/dist/tests/focus-tracker.test.js +62 -0
  52. package/dist/tests/graph-onboarding.test.js +55 -0
  53. package/dist/tests/graph-styles.test.js +65 -0
  54. package/dist/tests/indexer.test.js +137 -0
  55. package/dist/tests/mcp-and-plugin.test.js +186 -0
  56. package/dist/tests/model-client-openai.test.js +29 -0
  57. package/dist/tests/model-selection.test.js +136 -0
  58. package/dist/tests/model-utils.test.js +22 -0
  59. package/dist/tests/reasoning-effort.test.js +264 -0
  60. package/dist/tests/run-benchmarks.test.js +161 -0
  61. package/dist/tests/search-code-map.test.js +18 -0
  62. package/dist/tests/serve.integration.test.js +218 -2
  63. package/dist/tests/session-ui.test.js +21 -0
  64. package/dist/tests/session.test.js +50 -0
  65. package/dist/tests/settings-ui.test.js +30 -0
  66. package/dist/tests/structural-analysis.test.js +218 -0
  67. package/node_modules/@minicode/agent-sdk/README.md +80 -51
  68. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts +16 -5
  69. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  70. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +51 -33
  71. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  72. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +14 -0
  73. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
  74. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +3 -2
  75. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
  76. package/node_modules/@minicode/agent-sdk/dist/src/index.js +2 -0
  77. package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
  78. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts +35 -0
  79. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts.map +1 -0
  80. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js +64 -0
  81. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js.map +1 -0
  82. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +7 -0
  83. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
  84. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +5 -1
  85. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  86. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +83 -11
  87. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  88. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts +1 -0
  89. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts.map +1 -1
  90. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js +8 -1
  91. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js.map +1 -1
  92. package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts.map +1 -1
  93. package/node_modules/@minicode/agent-sdk/dist/src/session/session.js +4 -1
  94. package/node_modules/@minicode/agent-sdk/dist/src/session/session.js.map +1 -1
  95. package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js +3 -1
  96. package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js.map +1 -1
  97. package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js +8 -2
  98. package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js.map +1 -1
  99. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +9 -5
  101. package/plugin/.claude-plugin/plugin.json +12 -0
  102. package/plugin/.mcp.json +8 -0
  103. package/plugin/CLAUDE.md +26 -0
  104. package/plugin/skills/analyze/SKILL.md +12 -0
  105. package/plugin/skills/focus/SKILL.md +20 -0
  106. package/plugin/skills/graph/SKILL.md +13 -0
  107. package/plugin/skills/symbols/SKILL.md +13 -0
@@ -0,0 +1,405 @@
1
+ /**
2
+ * MCP (Model Context Protocol) server for minicode.
3
+ *
4
+ * Exposes minicode's symbol-aware tools and code map as an MCP server,
5
+ * allowing external agents (e.g. Claude Code) to use minicode's code
6
+ * intelligence while the web UI visualizes tool activity in real time.
7
+ */
8
+ import { randomUUID } from "node:crypto";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
11
+ import { z } from "zod";
12
+ import { getSymbolDisplayName } from "../indexer/symbol-names.js";
13
+ /** Active transports keyed by session ID. */
14
+ const transports = new Map();
15
+ /**
16
+ * Create and configure an McpServer with minicode's tools and resources.
17
+ */
18
+ function createMcpServer(bridge, emit) {
19
+ const server = new McpServer({ name: "minicode", version: "0.2.0" }, { capabilities: { tools: {}, resources: {} } });
20
+ // ── Helper: wrap tool execution with WebSocket event broadcast ──
21
+ function wrapToolCall(toolName, input, fn) {
22
+ const start = Date.now();
23
+ emit({ type: "tool_call_start", name: toolName, input });
24
+ return fn().then((result) => {
25
+ emit({
26
+ type: "tool_call_end",
27
+ name: toolName,
28
+ input,
29
+ result: typeof result === "string" ? result : JSON.stringify(result),
30
+ elapsedMs: Date.now() - start,
31
+ });
32
+ return result;
33
+ }, (error) => {
34
+ emit({
35
+ type: "tool_call_end",
36
+ name: toolName,
37
+ input,
38
+ result: `Error: ${error instanceof Error ? error.message : String(error)}`,
39
+ elapsedMs: Date.now() - start,
40
+ });
41
+ throw error;
42
+ });
43
+ }
44
+ // ── Tools ──
45
+ server.tool("read_symbol", "Read a specific function, class, interface, or type by name from the AST index. Returns source code, signature, dependencies, references, and annotations in one call — much more targeted than reading an entire file. PREFERRED over read_file for .ts/.tsx/.js/.jsx when you know the symbol name.", { name: z.string().describe("The symbol name or qualified name (e.g. 'Session' or 'Session.trim')") }, async ({ name }) => {
46
+ return wrapToolCall("read_symbol", { name }, async () => {
47
+ const sym = bridge.getSymbol(name);
48
+ if (!sym) {
49
+ return { content: [{ type: "text", text: `Symbol "${name}" not found in the project index.` }], isError: true };
50
+ }
51
+ const deps = bridge.getDependencies(name, 1);
52
+ const refs = bridge.getReferences(name);
53
+ const lines = [
54
+ `## ${sym.kind}: ${getSymbolDisplayName(sym)}`,
55
+ `File: ${sym.filePath}:${sym.startLine}`,
56
+ `Signature: ${sym.signature}`,
57
+ "",
58
+ ];
59
+ if (sym.docComment) {
60
+ lines.push(sym.docComment, "");
61
+ }
62
+ // Read source from file
63
+ const { readFileSync } = await import("node:fs");
64
+ try {
65
+ const fullPath = (await import("node:path")).resolve(bridge.getConfig().workspaceRoot, sym.filePath);
66
+ const fileContent = readFileSync(fullPath, "utf-8");
67
+ const fileLines = fileContent.split("\n");
68
+ const source = fileLines.slice(sym.startLine - 1, sym.endLine).join("\n");
69
+ lines.push("```", source, "```", "");
70
+ }
71
+ catch {
72
+ lines.push("(source unavailable)", "");
73
+ }
74
+ if (deps && deps.length > 0) {
75
+ lines.push("Dependencies:", ...deps.map((d) => ` - ${d.name} (${d.kind})`), "");
76
+ }
77
+ if (refs && refs.length > 0) {
78
+ lines.push("Referenced by:", ...refs.map((r) => ` - ${r.name ?? r.from} (${r.kind})`), "");
79
+ }
80
+ // Append annotations if present
81
+ const annotations = bridge.getAnnotationsForSymbol(sym.qualifiedName);
82
+ if (annotations.length > 0) {
83
+ lines.push(`[User annotation: ${annotations.join("; ")}]`);
84
+ }
85
+ return { content: [{ type: "text", text: lines.join("\n") }] };
86
+ });
87
+ });
88
+ server.tool("find_references", "Find all symbols that call, import, or reference a given symbol. Essential for understanding impact before making changes.", { name: z.string().describe("The symbol name to find references for") }, async ({ name }) => {
89
+ return wrapToolCall("find_references", { name }, async () => {
90
+ const refs = bridge.getReferences(name);
91
+ if (!refs || refs.length === 0) {
92
+ return { content: [{ type: "text", text: `No references found for "${name}".` }] };
93
+ }
94
+ const lines = [`References to "${name}":`, ...refs.map((r) => ` - ${r.name ?? r.from} (${r.kind})`)];
95
+ const annotations = bridge.getAnnotationsForSymbol(name);
96
+ if (annotations.length > 0) {
97
+ lines.push("", `[User annotation: ${annotations.join("; ")}]`);
98
+ }
99
+ return { content: [{ type: "text", text: lines.join("\n") }] };
100
+ });
101
+ });
102
+ server.tool("get_dependencies", "Get the dependency cone of a symbol — everything it calls, imports, extends, or references. Essential for understanding implementation and data flow.", {
103
+ name: z.string().describe("The symbol name to get dependencies for"),
104
+ depth: z.number().optional().default(2).describe("How many levels deep to traverse (default: 2)"),
105
+ }, async ({ name, depth }) => {
106
+ return wrapToolCall("get_dependencies", { name, depth }, async () => {
107
+ const deps = bridge.getDependencies(name, depth);
108
+ if (!deps || deps.length === 0) {
109
+ return { content: [{ type: "text", text: `No dependencies found for "${name}".` }] };
110
+ }
111
+ const lines = [
112
+ `Dependencies of "${name}" (depth=${depth}):`,
113
+ ...deps.map((d) => ` - ${d.qualifiedName} (${d.kind}) — ${d.filePath}`),
114
+ ];
115
+ const annotations = bridge.getAnnotationsForSymbol(name);
116
+ if (annotations.length > 0) {
117
+ lines.push("", `[User annotation: ${annotations.join("; ")}]`);
118
+ }
119
+ return { content: [{ type: "text", text: lines.join("\n") }] };
120
+ });
121
+ });
122
+ server.tool("search_code_map", "Search the project's AST-indexed symbols by name or substring. Returns matching function, class, interface, and type definitions with their file locations and signatures. PREFERRED over generic file search when looking for code symbols.", {
123
+ query: z.string().describe("Search query — matches against symbol names"),
124
+ kind: z.string().optional().describe("Filter by kind: function, class, interface, type, variable, method"),
125
+ }, async ({ query, kind }) => {
126
+ return wrapToolCall("search_code_map", { query, kind }, async () => {
127
+ const symbols = bridge.getSymbols();
128
+ const queryLower = query.toLowerCase();
129
+ let matches = symbols.filter((s) => {
130
+ const nameMatch = s.name.toLowerCase().includes(queryLower) ||
131
+ s.qualifiedName.toLowerCase().includes(queryLower);
132
+ return nameMatch;
133
+ });
134
+ if (kind) {
135
+ matches = matches.filter((s) => s.kind === kind);
136
+ }
137
+ matches = matches.slice(0, 30);
138
+ if (matches.length === 0) {
139
+ return { content: [{ type: "text", text: `No symbols matching "${query}"${kind ? ` (kind: ${kind})` : ""}.` }] };
140
+ }
141
+ const lines = [
142
+ `Found ${matches.length} symbol(s) matching "${query}":`,
143
+ ...matches.map((s) => ` - ${s.name} (${s.kind}) — ${s.filePath}:${s.startLine}${s.signature ? `\n ${s.signature}` : ""}`),
144
+ ];
145
+ return { content: [{ type: "text", text: lines.join("\n") }] };
146
+ });
147
+ });
148
+ server.tool("find_path", "Find the shortest dependency path between two symbols, or trace a symbol back to an entry point. Useful for understanding how code connects.", {
149
+ from: z.string().describe("Source symbol name"),
150
+ to: z.string().optional().describe("Target symbol name. If omitted, traces back to the nearest entry point."),
151
+ }, async ({ from, to }) => {
152
+ return wrapToolCall("find_path", { from, to }, async () => {
153
+ if (!bridge.hasIndex()) {
154
+ return { content: [{ type: "text", text: "No project index available." }], isError: true };
155
+ }
156
+ // Use the bridge's project index via getSymbol to verify symbols exist
157
+ const fromSym = bridge.getSymbol(from);
158
+ if (!fromSym) {
159
+ return { content: [{ type: "text", text: `Symbol "${from}" not found.` }], isError: true };
160
+ }
161
+ if (to) {
162
+ const toSym = bridge.getSymbol(to);
163
+ if (!toSym) {
164
+ return { content: [{ type: "text", text: `Symbol "${to}" not found.` }], isError: true };
165
+ }
166
+ }
167
+ // Delegate to the find_path tool via the tool registry
168
+ // We need the actual ProjectIndex for this, so access it through the bridge's getGraph
169
+ const graph = bridge.getGraph();
170
+ if (!graph) {
171
+ return { content: [{ type: "text", text: "No project graph available." }], isError: true };
172
+ }
173
+ // Build adjacency for BFS
174
+ const adj = new Map();
175
+ for (const edge of graph.edges) {
176
+ let list = adj.get(edge.from);
177
+ if (!list) {
178
+ list = [];
179
+ adj.set(edge.from, list);
180
+ }
181
+ list.push(edge.to);
182
+ // Reverse direction too for path finding
183
+ let rList = adj.get(edge.to);
184
+ if (!rList) {
185
+ rList = [];
186
+ adj.set(edge.to, rList);
187
+ }
188
+ rList.push(edge.from);
189
+ }
190
+ const startId = fromSym.qualifiedName;
191
+ if (to) {
192
+ const toSym = bridge.getSymbol(to);
193
+ const endId = toSym.qualifiedName;
194
+ // BFS shortest path
195
+ const visited = new Set([startId]);
196
+ const parent = new Map();
197
+ const queue = [startId];
198
+ while (queue.length > 0) {
199
+ const current = queue.shift();
200
+ if (current === endId)
201
+ break;
202
+ for (const neighbor of adj.get(current) || []) {
203
+ if (!visited.has(neighbor)) {
204
+ visited.add(neighbor);
205
+ parent.set(neighbor, current);
206
+ queue.push(neighbor);
207
+ }
208
+ }
209
+ }
210
+ if (!parent.has(endId) && startId !== endId) {
211
+ return { content: [{ type: "text", text: `No path found between "${from}" and "${to}".` }] };
212
+ }
213
+ const path = [];
214
+ let node = endId;
215
+ while (node !== startId) {
216
+ path.unshift(node);
217
+ node = parent.get(node);
218
+ }
219
+ path.unshift(startId);
220
+ return { content: [{ type: "text", text: `Path from "${from}" to "${to}" (${path.length} steps):\n${path.map((p, i) => ` ${i + 1}. ${p}`).join("\n")}` }] };
221
+ }
222
+ else {
223
+ // Trace to entry point (exported symbols with no incoming edges)
224
+ const hasIncoming = new Set();
225
+ for (const edge of graph.edges) {
226
+ hasIncoming.add(edge.to);
227
+ }
228
+ const entryPoints = new Set(graph.nodes.filter((n) => n.exported && !hasIncoming.has(n.id)).map((n) => n.id));
229
+ // BFS from start, looking for any entry point
230
+ const visited = new Set([startId]);
231
+ const parent = new Map();
232
+ const queue = [startId];
233
+ let foundEntry = null;
234
+ while (queue.length > 0) {
235
+ const current = queue.shift();
236
+ if (entryPoints.has(current) && current !== startId) {
237
+ foundEntry = current;
238
+ break;
239
+ }
240
+ for (const neighbor of adj.get(current) || []) {
241
+ if (!visited.has(neighbor)) {
242
+ visited.add(neighbor);
243
+ parent.set(neighbor, current);
244
+ queue.push(neighbor);
245
+ }
246
+ }
247
+ }
248
+ if (!foundEntry) {
249
+ return { content: [{ type: "text", text: `No entry point reachable from "${from}".` }] };
250
+ }
251
+ const path = [];
252
+ let node = foundEntry;
253
+ while (node !== startId) {
254
+ path.unshift(node);
255
+ node = parent.get(node);
256
+ }
257
+ path.unshift(startId);
258
+ return { content: [{ type: "text", text: `Path from "${from}" to entry point "${foundEntry}" (${path.length} steps):\n${path.map((p, i) => ` ${i + 1}. ${p}`).join("\n")}` }] };
259
+ }
260
+ });
261
+ });
262
+ // ── Annotation tools ──
263
+ server.tool("add_annotation", "Attach a note to a symbol in the project index. Annotations are injected into tool results when interacting with annotated code, and appear in the web UI graph. Use this to leave instructions or context for future interactions.", {
264
+ symbol: z.string().describe("The symbol name to annotate"),
265
+ text: z.string().max(500).describe("The annotation text (max 500 chars)"),
266
+ }, async ({ symbol, text }) => {
267
+ return wrapToolCall("add_annotation", { symbol, text }, async () => {
268
+ const success = bridge.addAnnotation(symbol, text);
269
+ if (!success) {
270
+ return { content: [{ type: "text", text: `Could not annotate "${symbol}" — symbol not found in the project index.` }], isError: true };
271
+ }
272
+ return { content: [{ type: "text", text: `Annotation added to "${symbol}": ${text}` }] };
273
+ });
274
+ });
275
+ server.tool("list_annotations", "List all annotations attached to symbols. Annotations are user-provided notes that guide how code should be handled.", {
276
+ symbol: z.string().optional().describe("If provided, list annotations for this symbol only. Otherwise list all."),
277
+ }, async ({ symbol }) => {
278
+ return wrapToolCall("list_annotations", { symbol }, async () => {
279
+ if (symbol) {
280
+ const notes = bridge.getAnnotationsForSymbol(symbol);
281
+ if (notes.length === 0) {
282
+ return { content: [{ type: "text", text: `No annotations for "${symbol}".` }] };
283
+ }
284
+ return { content: [{ type: "text", text: `Annotations for "${symbol}":\n${notes.map((n, i) => ` ${i + 1}. ${n}`).join("\n")}` }] };
285
+ }
286
+ const all = bridge.getAnnotations();
287
+ const entries = Object.entries(all);
288
+ if (entries.length === 0) {
289
+ return { content: [{ type: "text", text: "No annotations in this session." }] };
290
+ }
291
+ const lines = entries.flatMap(([name, notes]) => [
292
+ `${name}:`,
293
+ ...notes.map((n, i) => ` ${i + 1}. ${n}`),
294
+ ]);
295
+ return { content: [{ type: "text", text: lines.join("\n") }] };
296
+ });
297
+ });
298
+ // ── Resources ──
299
+ server.resource("code-map", "minicode://code-map", { description: "Compact project skeleton showing all indexed symbols with their signatures, ranked by importance. Provides a high-level overview of the codebase structure." }, async () => {
300
+ const codeMap = bridge.getCodeMap();
301
+ if (!codeMap) {
302
+ return { contents: [{ uri: "minicode://code-map", text: "No project index available.", mimeType: "text/plain" }] };
303
+ }
304
+ return { contents: [{ uri: "minicode://code-map", text: codeMap.text, mimeType: "text/plain" }] };
305
+ });
306
+ server.resource("structural-analysis", "minicode://structural-analysis", { description: "Structural analysis of the project: dependency cycles, fan-in/fan-out hotspots, and file coupling findings." }, async () => {
307
+ const analysis = bridge.getStructuralAnalysis();
308
+ if (!analysis) {
309
+ return { contents: [{ uri: "minicode://structural-analysis", text: "No project index available.", mimeType: "text/plain" }] };
310
+ }
311
+ const lines = [
312
+ `# Structural Analysis`,
313
+ `Symbols: ${analysis.summary.symbolCount} | Files: ${analysis.summary.fileCount} | Findings: ${analysis.summary.findingCount}`,
314
+ "",
315
+ ];
316
+ for (const finding of analysis.findings) {
317
+ lines.push(`## [${finding.severity.toUpperCase()}] ${finding.title}`, finding.summary, `Type: ${finding.type} | Symbols: ${finding.symbols.join(", ")}`, ...finding.rationale.map((r) => ` - ${r}`), "");
318
+ }
319
+ return { contents: [{ uri: "minicode://structural-analysis", text: lines.join("\n"), mimeType: "text/plain" }] };
320
+ });
321
+ return server;
322
+ }
323
+ /**
324
+ * Handle an incoming HTTP request on the /mcp path.
325
+ * Manages stateful sessions via session ID headers.
326
+ */
327
+ export async function handleMcpRequest(req, res, bridge, emit) {
328
+ const sessionId = req.headers["mcp-session-id"];
329
+ if (req.method === "POST") {
330
+ // Read request body
331
+ const body = await new Promise((resolve) => {
332
+ let data = "";
333
+ req.on("data", (chunk) => { data += chunk.toString(); });
334
+ req.on("end", () => resolve(data));
335
+ });
336
+ let parsed;
337
+ try {
338
+ parsed = JSON.parse(body);
339
+ }
340
+ catch {
341
+ res.writeHead(400, { "Content-Type": "application/json" });
342
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error: invalid JSON in request body" }, id: null }));
343
+ return;
344
+ }
345
+ // Check if this is an initialization request (method: "initialize")
346
+ const parsedObj = parsed;
347
+ const isInit = Array.isArray(parsedObj)
348
+ ? parsedObj.some((m) => m.method === "initialize")
349
+ : parsedObj.method === "initialize";
350
+ if (isInit) {
351
+ // Create new transport and server for this session
352
+ const transport = new StreamableHTTPServerTransport({
353
+ sessionIdGenerator: () => randomUUID(),
354
+ });
355
+ const server = createMcpServer(bridge, emit);
356
+ await server.connect(transport);
357
+ // Store transport by session ID once we know it
358
+ transport.onclose = () => {
359
+ if (transport.sessionId) {
360
+ transports.delete(transport.sessionId);
361
+ }
362
+ };
363
+ await transport.handleRequest(req, res, parsed);
364
+ // After handling, the transport has a session ID
365
+ if (transport.sessionId) {
366
+ transports.set(transport.sessionId, transport);
367
+ }
368
+ return;
369
+ }
370
+ // Existing session
371
+ if (!sessionId || !transports.has(sessionId)) {
372
+ res.writeHead(400, { "Content-Type": "application/json" });
373
+ res.end(JSON.stringify({ error: "Invalid or missing session ID" }));
374
+ return;
375
+ }
376
+ const transport = transports.get(sessionId);
377
+ await transport.handleRequest(req, res, parsed);
378
+ return;
379
+ }
380
+ if (req.method === "GET") {
381
+ // SSE stream for server-initiated messages
382
+ if (!sessionId || !transports.has(sessionId)) {
383
+ res.writeHead(400, { "Content-Type": "application/json" });
384
+ res.end(JSON.stringify({ error: "Invalid or missing session ID" }));
385
+ return;
386
+ }
387
+ const transport = transports.get(sessionId);
388
+ await transport.handleRequest(req, res);
389
+ return;
390
+ }
391
+ if (req.method === "DELETE") {
392
+ // Session termination
393
+ if (sessionId && transports.has(sessionId)) {
394
+ const transport = transports.get(sessionId);
395
+ await transport.handleRequest(req, res);
396
+ transports.delete(sessionId);
397
+ return;
398
+ }
399
+ res.writeHead(404);
400
+ res.end();
401
+ return;
402
+ }
403
+ res.writeHead(405, { "Content-Type": "application/json" });
404
+ res.end(JSON.stringify({ error: "Method not allowed" }));
405
+ }
@@ -1,3 +1,4 @@
1
+ import process from "node:process";
1
2
  import { createServer } from "node:http";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import path from "node:path";
@@ -5,7 +6,10 @@ import { fileURLToPath } from "node:url";
5
6
  import { AgentBridge } from "./agent-bridge.js";
6
7
  import { createWebSocketServer } from "./websocket.js";
7
8
  import { handleChatCompletions, handleModels } from "./openai-compat.js";
8
- import { formatConfigForDisplay } from "../agent/config.js";
9
+ import { formatConfigForDisplay, getConfigMissing } from "../agent/config.js";
10
+ import { applyPersistedConfigUpdates, buildStructuredConfigPayload } from "../agent/editable-config.js";
11
+ import { sortModelsAlphabetically } from "../model-utils.js";
12
+ import { handleMcpRequest } from "./mcp-server.js";
9
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
14
  // Resolve web dir: always serve from dist/src/web (built by scripts/build-web.mjs)
11
15
  // In dev (tsx): __dirname = src/serve → go up to project root, then dist/src/web
@@ -52,14 +56,24 @@ async function serveStatic(res, urlPath) {
52
56
  res.end("Not Found");
53
57
  }
54
58
  }
59
+ async function buildWebSettingsPayload(config, minicodeHome) {
60
+ return buildStructuredConfigPayload(config, minicodeHome);
61
+ }
55
62
  /** Create the HTTP request handler. Exported for testing. */
56
- export function createRequestHandler(bridge) {
63
+ export function createRequestHandler(bridge, emit, options = {}) {
57
64
  const config = bridge.getConfig();
65
+ const emitFn = emit ?? (() => { });
66
+ const minicodeHome = options.minicodeHome;
58
67
  return (req, res) => {
59
68
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
60
69
  const method = req.method ?? "GET";
61
70
  const pathname = url.pathname;
62
71
  const handle = async () => {
72
+ // MCP (Model Context Protocol) endpoint
73
+ if (pathname === "/mcp") {
74
+ await handleMcpRequest(req, res, bridge, emitFn);
75
+ return;
76
+ }
63
77
  // OpenAI-compatible routes
64
78
  if (pathname === "/v1/models" && method === "GET") {
65
79
  handleModels(req, res);
@@ -71,21 +85,85 @@ export function createRequestHandler(bridge) {
71
85
  }
72
86
  // Minicode REST API
73
87
  if (pathname === "/api/status" && method === "GET") {
88
+ const missing = getConfigMissing(config);
74
89
  sendJson(res, 200, {
75
90
  status: bridge.isBusy() ? "busy" : "ready",
76
91
  workspace: config.workspaceRoot,
77
92
  model: config.model,
78
93
  provider: config.modelProvider,
94
+ needsSetup: missing.length > 0,
95
+ missing,
79
96
  });
80
97
  return;
81
98
  }
99
+ if (pathname === "/api/models" && method === "GET") {
100
+ const models = sortModelsAlphabetically(await bridge.listModels());
101
+ sendJson(res, 200, { models, activeModel: config.model });
102
+ return;
103
+ }
104
+ if (pathname === "/api/model" && method === "POST") {
105
+ const body = JSON.parse(await readBody(req));
106
+ if (!body.model || typeof body.model !== "string") {
107
+ sendJson(res, 400, { error: "model is required" });
108
+ return;
109
+ }
110
+ bridge.switchModel(body.model);
111
+ sendJson(res, 200, { model: body.model });
112
+ return;
113
+ }
114
+ if (pathname === "/api/context" && method === "GET") {
115
+ if (!bridge.isReady()) {
116
+ sendJson(res, 200, { contextTokens: 0, maxContextTokens: 0 });
117
+ return;
118
+ }
119
+ const status = bridge.getAgent().getContextStatus();
120
+ sendJson(res, 200, status);
121
+ return;
122
+ }
82
123
  if (pathname === "/api/config" && method === "GET") {
83
- sendJson(res, 200, { config: formatConfigForDisplay(config) });
124
+ const structured = await buildWebSettingsPayload(config, minicodeHome);
125
+ sendJson(res, 200, {
126
+ config: formatConfigForDisplay(config),
127
+ settings: structured,
128
+ restartRequired: true,
129
+ secretsUiSupported: false,
130
+ });
131
+ return;
132
+ }
133
+ if (pathname === "/api/config" && method === "POST") {
134
+ const body = JSON.parse(await readBody(req));
135
+ if (!body.updates || typeof body.updates !== "object") {
136
+ sendJson(res, 400, { error: "updates object is required" });
137
+ return;
138
+ }
139
+ try {
140
+ const result = await applyPersistedConfigUpdates({
141
+ updates: body.updates,
142
+ ...(minicodeHome ? { minicodeHome } : {}),
143
+ });
144
+ const structured = await buildWebSettingsPayload(config, minicodeHome);
145
+ sendJson(res, 200, {
146
+ ok: true,
147
+ scope: "global",
148
+ path: result.path,
149
+ saved: result.saved,
150
+ restartRequired: true,
151
+ message: "Persisted config updated. Restart minicode to apply changes to new sessions.",
152
+ settings: structured,
153
+ });
154
+ }
155
+ catch (error) {
156
+ const message = error instanceof Error ? error.message : "Failed to update config";
157
+ sendJson(res, 400, { error: message });
158
+ }
84
159
  return;
85
160
  }
86
161
  if (pathname === "/api/sessions" && method === "GET") {
87
162
  const sessions = await bridge.listSess();
88
- sendJson(res, 200, { sessions });
163
+ sendJson(res, 200, {
164
+ sessions,
165
+ currentSessionId: bridge.getCurrentSessionId(),
166
+ });
89
167
  return;
90
168
  }
91
169
  if (pathname === "/api/sessions/save" && method === "POST") {
@@ -175,6 +253,47 @@ export function createRequestHandler(bridge) {
175
253
  sendJson(res, 200, result);
176
254
  return;
177
255
  }
256
+ if (pathname === "/api/analysis" && method === "GET") {
257
+ const result = bridge.getStructuralAnalysis();
258
+ if (!result) {
259
+ sendJson(res, 404, { error: "No project index available" });
260
+ return;
261
+ }
262
+ sendJson(res, 200, result);
263
+ return;
264
+ }
265
+ if (pathname === "/api/analysis/explain" && method === "POST") {
266
+ const body = JSON.parse(await readBody(req));
267
+ if (!body.findingId || typeof body.findingId !== "string") {
268
+ sendJson(res, 400, { error: "findingId is required" });
269
+ return;
270
+ }
271
+ res.writeHead(200, {
272
+ "Content-Type": "text/event-stream",
273
+ "Cache-Control": "no-cache",
274
+ Connection: "keep-alive",
275
+ });
276
+ const abortController = new AbortController();
277
+ req.on("close", () => abortController.abort());
278
+ try {
279
+ await bridge.explainStructuralFinding(body.findingId, (event) => {
280
+ if (!res.writableEnded) {
281
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
282
+ }
283
+ }, abortController.signal);
284
+ }
285
+ catch (error) {
286
+ if (!res.writableEnded) {
287
+ const msg = error instanceof Error ? error.message : "Unknown error";
288
+ res.write(`data: ${JSON.stringify({ type: "error", message: msg })}\n\n`);
289
+ }
290
+ }
291
+ if (!res.writableEnded) {
292
+ res.write("data: [DONE]\n\n");
293
+ res.end();
294
+ }
295
+ return;
296
+ }
178
297
  if (pathname === "/api/focus" && method === "GET") {
179
298
  sendJson(res, 200, { pinned: bridge.getPinnedSymbols() });
180
299
  return;
@@ -307,6 +426,36 @@ export function createRequestHandler(bridge) {
307
426
  });
308
427
  };
309
428
  }
429
+ /** Force-shutdown timeout in ms. If graceful close hasn't finished, exit anyway. */
430
+ const SHUTDOWN_TIMEOUT_MS = 3_000;
431
+ /**
432
+ * Forcefully shut down the serve process. Terminates all WebSocket clients,
433
+ * destroys open HTTP sockets, and stops the server. Exported for testing.
434
+ */
435
+ export function shutdownServe(server, wss, openSockets, exit = (code) => process.exit(code)) {
436
+ console.log("\nShutting down...");
437
+ // 1. Terminate every connected WebSocket client immediately.
438
+ // wss.close() only stops accepting *new* connections — existing clients
439
+ // stay open, which keeps the HTTP server alive (the root cause of #39).
440
+ for (const client of wss.clients) {
441
+ client.terminate();
442
+ }
443
+ wss.close();
444
+ // 2. Destroy all open TCP sockets so server.close() can finish.
445
+ for (const socket of openSockets) {
446
+ socket.destroy();
447
+ }
448
+ openSockets.clear();
449
+ // 3. Stop accepting new connections and exit once drained.
450
+ server.close(() => {
451
+ exit(0);
452
+ });
453
+ // 4. Safety net: if something still holds the event loop, force-exit.
454
+ setTimeout(() => {
455
+ console.error("Shutdown timed out — forcing exit.");
456
+ exit(1);
457
+ }, SHUTDOWN_TIMEOUT_MS).unref();
458
+ }
310
459
  export async function runServe(verbose, port) {
311
460
  console.log("Initializing agent...");
312
461
  // Set up broadcast plumbing
@@ -314,7 +463,7 @@ export async function runServe(verbose, port) {
314
463
  const bridge = new AgentBridge((msg) => broadcastFn(msg), verbose);
315
464
  await bridge.init();
316
465
  const config = bridge.getConfig();
317
- const handler = createRequestHandler(bridge);
466
+ const handler = createRequestHandler(bridge, (msg) => broadcastFn(msg));
318
467
  const server = createServer(handler);
319
468
  // WebSocket server — captures the real broadcast function
320
469
  const wss = createWebSocketServer(server, bridge);
@@ -328,13 +477,18 @@ export async function runServe(verbose, port) {
328
477
  }
329
478
  }
330
479
  };
480
+ // Track open sockets so we can destroy them on shutdown
481
+ const openSockets = new Set();
482
+ server.on("connection", (socket) => {
483
+ openSockets.add(socket);
484
+ socket.on("close", () => openSockets.delete(socket));
485
+ });
486
+ // Start file watcher for automatic reindexing
487
+ bridge.startFileWatcher();
331
488
  // Graceful shutdown
332
489
  process.on("SIGINT", () => {
333
- console.log("\nShutting down...");
334
- wss.close();
335
- server.close(() => {
336
- process.exit(0);
337
- });
490
+ bridge.stopFileWatcher();
491
+ shutdownServe(server, wss, openSockets);
338
492
  });
339
493
  server.listen(port, "127.0.0.1", () => {
340
494
  console.log(`\nminicode serve`);
@@ -342,6 +496,7 @@ export async function runServe(verbose, port) {
342
496
  console.log(` Model: ${config.model} (${config.modelProvider})`);
343
497
  console.log(` Web UI: http://localhost:${port}`);
344
498
  console.log(` OpenAI: http://localhost:${port}/v1`);
499
+ console.log(` MCP: http://localhost:${port}/mcp`);
345
500
  console.log(`\nPress Ctrl+C to stop.\n`);
346
501
  });
347
502
  // Keep alive
@@ -22,6 +22,14 @@ export function createWebSocketServer(httpServer, bridge) {
22
22
  else if (msg.type === "cancel") {
23
23
  bridge.cancel();
24
24
  }
25
+ else if (msg.type === "switch_model") {
26
+ bridge.switchModel(msg.model);
27
+ const changed = { type: "model_changed", model: msg.model };
28
+ // Broadcast to all clients
29
+ for (const client of wss.clients) {
30
+ client.send(JSON.stringify(changed));
31
+ }
32
+ }
25
33
  });
26
34
  });
27
35
  return wss;