min-agent 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
package/dist/serve.js CHANGED
@@ -3,51 +3,20 @@
3
3
  * Run: min-agent serve [--host 127.0.0.1] [--port 8787]
4
4
  */
5
5
  import { createServer } from "http";
6
- import { readFileSync } from "fs";
7
- import path from "path";
8
- import { fileURLToPath } from "url";
9
6
  import { timingSafeEqual } from "crypto";
10
- import { initMcp, shutdownMcp, getMcpStatus, syncMcpConnections, loadMcpConfigEntries } from "./mcp.js";
11
- import { discoverSkills, getAllSkills, getSkill, getStaleDisabledSkills, setSkillEnabled } from "./skills.js";
7
+ import { initMcp, shutdownMcp } from "./mcp.js";
8
+ import { discoverSkills } from "./skills.js";
12
9
  import { loadInstructions } from "./instructions.js";
13
- import { loadConfig, fetchModels, isConfigured, getActiveProvider } from "./config.js";
10
+ import { isConfigured } from "./config.js";
14
11
  import { setAutoApprove } from "./confirm.js";
15
- import { runOnce, runOnceWithSystem, buildUserContent } from "./agent.js";
16
- import { loadSession, saveSession, listSessions, deleteSession, renameSession, sanitizeForStorage } from "./sessions.js";
17
- import { loadMemories, addMemory, deleteMemory, searchMemories } from "./memory.js";
18
- import { scanProject, buildCodeSystemPrompt } from "./code-mode.js";
19
- import { compactMessages, TokenTracker } from "./compaction.js";
20
- import { resolveModel } from "./provider.js";
21
- import { getContextWindow } from "./context-window.js";
22
- import { getModelPrice, estimateCost } from "./pricing.js";
23
- const MAX_BODY_BYTES = 2 * 1024 * 1024;
24
- const BODY_TIMEOUT_MS = 30_000;
25
- const MAX_TOOL_RESULT_SSE_CHARS = 48_000;
26
- const MAX_CONCURRENT_CHATS = 8;
27
- let activeChats = 0;
28
- function packageVersion() {
29
- try {
30
- const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
31
- const j = JSON.parse(readFileSync(pkgPath, "utf-8"));
32
- return j.version ?? "0.0.0";
33
- }
34
- catch { }
35
- return "0.0.0";
36
- }
37
- function corsEnabled() {
38
- const v = process.env.MIN_AGENT_SERVE_CORS;
39
- return v === "1" || v === "true";
40
- }
41
- function corsHeaders() {
42
- if (corsEnabled()) {
43
- return {
44
- "Access-Control-Allow-Origin": "*",
45
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
46
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
47
- };
48
- }
49
- return {};
50
- }
12
+ import { createApprovalGate } from "./http-approvals.js";
13
+ import { packageVersion, corsEnabled, corsHeaders, sendJson, } from "./serve/common.js";
14
+ import { handleChatRoutes } from "./serve/routes-chat.js";
15
+ import { handleSessionRoutes } from "./serve/routes-sessions.js";
16
+ import { handleMemoryRoutes } from "./serve/routes-memory.js";
17
+ import { handleMcpRoutes } from "./serve/routes-mcp.js";
18
+ import { handleSkillRoutes } from "./serve/routes-skills.js";
19
+ import { handleMetaRoutes } from "./serve/routes-meta.js";
51
20
  /**
52
21
  * With CORS enabled and no auth token, arbitrary web pages could drive the
53
22
  * agent. Allow only same-machine origins (localhost) in that configuration;
@@ -80,192 +49,6 @@ function authOk(req) {
80
49
  const expected = Buffer.from(token);
81
50
  return provided.length === expected.length && timingSafeEqual(provided, expected);
82
51
  }
83
- function skillPayload(s) {
84
- return {
85
- name: s.name,
86
- description: s.description,
87
- location: s.location,
88
- enabled: s.enabled,
89
- ...(s.disabledScope ? { disabled_scope: s.disabledScope } : {}),
90
- ...(s.version ? { version: s.version } : {}),
91
- ...(s.allowedTools.length ? { allowed_tools: s.allowedTools } : {}),
92
- ...(s.requiredBins.length ? { requires_bins: s.requiredBins } : {}),
93
- };
94
- }
95
- function sendJson(res, status, body) {
96
- const headers = {
97
- "Content-Type": "application/json; charset=utf-8",
98
- ...corsHeaders(),
99
- };
100
- res.writeHead(status, headers);
101
- res.end(JSON.stringify(body));
102
- }
103
- /** Decode a URL path segment without letting malformed escapes become a 500. */
104
- function decodePathSegment(raw) {
105
- try {
106
- return decodeURIComponent(raw);
107
- }
108
- catch {
109
- return null;
110
- }
111
- }
112
- function readBody(req) {
113
- return new Promise((resolve, reject) => {
114
- const chunks = [];
115
- let total = 0;
116
- const timer = setTimeout(() => {
117
- req.destroy();
118
- reject(new Error("body_timeout"));
119
- }, BODY_TIMEOUT_MS);
120
- req.on("data", (c) => {
121
- total += c.length;
122
- if (total > MAX_BODY_BYTES) {
123
- clearTimeout(timer);
124
- req.removeAllListeners("data");
125
- req.pause();
126
- reject(new Error("body_too_large"));
127
- return;
128
- }
129
- chunks.push(c);
130
- });
131
- req.on("end", () => {
132
- clearTimeout(timer);
133
- resolve(Buffer.concat(chunks).toString("utf-8"));
134
- });
135
- req.on("error", (e) => {
136
- clearTimeout(timer);
137
- reject(e);
138
- });
139
- });
140
- }
141
- async function readJsonBody(req) {
142
- let raw;
143
- try {
144
- raw = await readBody(req);
145
- }
146
- catch (e) {
147
- if (e?.message === "body_too_large") {
148
- return { ok: false, status: 413, error: "payload_too_large" };
149
- }
150
- if (e?.message === "body_timeout") {
151
- return { ok: false, status: 408, error: "payload_timeout" };
152
- }
153
- throw e;
154
- }
155
- try {
156
- return { ok: true, value: JSON.parse(raw) };
157
- }
158
- catch {
159
- return { ok: false, status: 400, error: "invalid_json" };
160
- }
161
- }
162
- function sendJsonError(res, e) {
163
- if (e.status === 413) {
164
- // The request body was abandoned mid-stream; drop keep-alive so the
165
- // connection cannot be reused for a second request.
166
- res.shouldKeepAlive = false;
167
- }
168
- sendJson(res, e.status, e.status === 413 ? { error: e.error, max_bytes: MAX_BODY_BYTES } : { error: e.error });
169
- }
170
- function sseWrite(res, obj) {
171
- try {
172
- if (res.writableEnded)
173
- return;
174
- res.write(`data: ${JSON.stringify(obj)}\n\n`);
175
- }
176
- catch {
177
- /* client gone */
178
- }
179
- }
180
- function truncateForJson(v, max) {
181
- if (typeof v === "string" && v.length > max)
182
- return v.slice(0, max) + `\n… [truncated ${v.length - max} chars]`;
183
- return v;
184
- }
185
- function sseHeaders(cors) {
186
- return {
187
- "Content-Type": "text/event-stream; charset=utf-8",
188
- "Cache-Control": "no-cache, no-transform",
189
- Connection: "keep-alive",
190
- "X-Accel-Buffering": "no",
191
- ...cors,
192
- };
193
- }
194
- function createSseCallbacks(res, messages, opts = {}) {
195
- return {
196
- onAssistantDisplayDelta(delta) {
197
- sseWrite(res, { type: "assistant", text: delta });
198
- },
199
- onThinkingDelta(delta) {
200
- sseWrite(res, { type: "thinking", text: delta });
201
- },
202
- onToolCall(name, input) {
203
- sseWrite(res, { type: "tool_call", name, input: truncateForJson(input, MAX_TOOL_RESULT_SSE_CHARS) });
204
- },
205
- onToolResult(name, output) {
206
- sseWrite(res, { type: "tool_result", name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) });
207
- },
208
- onCompaction(line) {
209
- sseWrite(res, { type: "compaction", line });
210
- },
211
- onStreamError(message) {
212
- sseWrite(res, { type: "error", message });
213
- },
214
- onRunFinish(info) {
215
- let saved;
216
- if (opts.sessionId && messages.length > 0) {
217
- try {
218
- saved = saveSession(messages, opts.sessionId);
219
- }
220
- catch {
221
- saved = undefined;
222
- }
223
- }
224
- sseWrite(res, {
225
- type: "done",
226
- step_count: info.stepCount,
227
- usage: info.usage,
228
- has_error: info.hasError,
229
- aborted: info.aborted,
230
- max_steps_reached: info.maxStepsReached,
231
- budget_exceeded: info.budgetExceeded ?? false,
232
- session_id: saved,
233
- messages: sanitizeForStorage(messages),
234
- ...(opts.codeMode ? { mode: "code", project: opts.project } : {}),
235
- });
236
- if (!res.writableEnded)
237
- res.end();
238
- },
239
- };
240
- }
241
- function createJsonCallbacks() {
242
- const toolCalls = [];
243
- const toolResults = [];
244
- let finishInfo = null;
245
- const callbacks = {
246
- onToolCall(name, input) {
247
- toolCalls.push({ name, input: truncateForJson(input, MAX_TOOL_RESULT_SSE_CHARS) });
248
- },
249
- onToolResult(name, output) {
250
- toolResults.push({ name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) });
251
- },
252
- onRunFinish(info) {
253
- finishInfo = info;
254
- },
255
- };
256
- return { callbacks, toolCalls, toolResults, finishInfo: () => finishInfo };
257
- }
258
- function normalizeMessages(body) {
259
- if (body.messages && Array.isArray(body.messages)) {
260
- if (body.messages.length === 0)
261
- return { ok: false, error: "messages must be non-empty" };
262
- return { ok: true, messages: [...body.messages] };
263
- }
264
- if (typeof body.message === "string" && body.message.length > 0) {
265
- return { ok: true, messages: [{ role: "user", content: body.message }] };
266
- }
267
- return { ok: false, error: "Provide `message` (string) or non-empty `messages` array" };
268
- }
269
52
  export async function runServe(opts = {}) {
270
53
  if (!isConfigured()) {
271
54
  console.error("Not configured. Run: min-agent setup");
@@ -279,139 +62,26 @@ export async function runServe(opts = {}) {
279
62
  "Set the token (Authorization: Bearer <token>) or bind to 127.0.0.1.");
280
63
  process.exit(1);
281
64
  }
282
- console.error("\x1b[33m⚠ min-agent serve: confirmations are auto-approved for this process (same as -y). Dangerous shell commands will run without prompts.\x1b[0m");
65
+ console.error("\x1b[33m⚠ min-agent serve: confirmations are auto-approved by default (same as -y). Set auto_approve: false with stream: true to require callbacks.\x1b[0m");
283
66
  setAutoApprove(true);
284
67
  console.error("\x1b[90m⟳ Initializing MCP, skills, instructions…\x1b[0m");
285
68
  await initMcp();
286
69
  discoverSkills();
287
- let instructions = await loadInstructions();
288
- const version = packageVersion();
289
- /** Shared handler for /v1/chat and /v1/code (streaming + JSON). */
290
- async function handleChatRequest(req, res, codeMode) {
291
- const c = corsHeaders();
292
- if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
293
- sendJson(res, 415, { error: "unsupported_media_type", detail: "Use Content-Type: application/json" });
294
- return;
295
- }
296
- if (activeChats >= MAX_CONCURRENT_CHATS) {
297
- sendJson(res, 429, { error: "too_many_concurrent_requests", detail: `Max ${MAX_CONCURRENT_CHATS} concurrent chats` });
298
- return;
299
- }
300
- activeChats++;
301
- try {
302
- const parsed = await readJsonBody(req);
303
- if (!parsed.ok) {
304
- sendJsonError(res, parsed);
305
- return;
306
- }
307
- const body = parsed.value;
308
- const modelId = typeof body.model === "string" ? body.model : undefined;
309
- const stream = body.stream === true;
310
- const sessionId = typeof body.session_id === "string" ? body.session_id : undefined;
311
- let messages;
312
- if (sessionId) {
313
- const session = loadSession(sessionId);
314
- if (!session) {
315
- sendJson(res, 404, { error: "session_not_found", session_id: sessionId });
316
- return;
317
- }
318
- if (typeof body.message !== "string" || !body.message.trim()) {
319
- sendJson(res, 400, {
320
- error: "session_requires_message",
321
- detail: "With `session_id`, send a non-empty `message` for the new user turn",
322
- });
323
- return;
324
- }
325
- messages = [...session.messages];
326
- const content = body.images && body.images.length > 0 ? await buildUserContent(body.message, body.images) : body.message;
327
- messages.push({ role: "user", content });
328
- }
329
- else {
330
- const norm = normalizeMessages(body);
331
- if (!norm.ok) {
332
- sendJson(res, 400, { error: "invalid_body", detail: norm.error });
333
- return;
334
- }
335
- messages = norm.messages;
336
- if (body.images && body.images.length > 0) {
337
- const last = messages[messages.length - 1];
338
- if (!last || last.role !== "user" || typeof body.message !== "string") {
339
- sendJson(res, 400, {
340
- error: "images_require_message",
341
- detail: "With `images`, send a top-level `message` string for the user turn",
342
- });
343
- return;
344
- }
345
- messages[messages.length - 1] = {
346
- role: "user",
347
- content: await buildUserContent(body.message, body.images),
348
- };
349
- }
350
- }
351
- const abort = new AbortController();
352
- res.on("close", () => abort.abort());
353
- const tracker = new TokenTracker();
354
- const runOptions = {
355
- temperature: typeof body.temperature === "number" ? body.temperature : undefined,
356
- maxTokens: typeof body.maxTokens === "number" ? body.maxTokens : undefined,
357
- topP: typeof body.topP === "number" ? body.topP : undefined,
358
- providerName: typeof body.provider === "string" ? body.provider : undefined,
359
- };
360
- let codeSystemPrompt;
361
- let project;
362
- if (codeMode) {
363
- project = scanProject();
364
- codeSystemPrompt = buildCodeSystemPrompt(project, instructions);
365
- }
366
- const run = (callbacks) => codeMode
367
- ? runOnceWithSystem(messages, codeSystemPrompt, modelId, abort.signal, callbacks, tracker, runOptions)
368
- : runOnce(messages, instructions, modelId, abort.signal, callbacks, tracker, runOptions);
369
- if (stream) {
370
- res.writeHead(200, sseHeaders(c));
371
- res.flushHeaders?.();
372
- const callbacks = createSseCallbacks(res, messages, { sessionId, codeMode, project });
373
- try {
374
- await run(callbacks);
375
- }
376
- catch (err) {
377
- if (!res.writableEnded) {
378
- sseWrite(res, { type: "fatal", message: err?.message ?? String(err) });
379
- res.end();
380
- }
381
- }
382
- return;
383
- }
384
- const { callbacks, toolCalls, toolResults, finishInfo } = createJsonCallbacks();
385
- await run(callbacks);
386
- const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
387
- let savedSession;
388
- if (sessionId && messages.length > 0) {
389
- try {
390
- savedSession = saveSession(messages, sessionId);
391
- }
392
- catch {
393
- savedSession = undefined;
394
- }
395
- }
396
- const fi = finishInfo();
397
- sendJson(res, 200, {
398
- messages: sanitizeForStorage(messages),
399
- assistant: lastAssistant ?? null,
400
- tool_calls: toolCalls,
401
- tool_results: toolResults,
402
- session_id: savedSession,
403
- step_count: fi?.stepCount ?? 0,
404
- usage: fi?.usage ?? null,
405
- has_error: fi?.hasError ?? false,
406
- aborted: fi?.aborted ?? false,
407
- max_steps_reached: fi?.maxStepsReached ?? false,
408
- ...(codeMode ? { mode: "code", project } : {}),
409
- });
410
- }
411
- finally {
412
- activeChats--;
413
- }
414
- }
70
+ const ctx = {
71
+ host,
72
+ version: packageVersion(),
73
+ instructions: await loadInstructions(),
74
+ approvalGate: createApprovalGate(),
75
+ activeChats: 0,
76
+ };
77
+ const routes = [
78
+ handleMetaRoutes,
79
+ handleChatRoutes,
80
+ handleSessionRoutes,
81
+ handleMemoryRoutes,
82
+ handleMcpRoutes,
83
+ handleSkillRoutes,
84
+ ];
415
85
  const server = createServer(async (req, res) => {
416
86
  const c = corsHeaders();
417
87
  if (req.method === "OPTIONS") {
@@ -420,7 +90,10 @@ export async function runServe(opts = {}) {
420
90
  return;
421
91
  }
422
92
  if (!authOk(req)) {
423
- sendJson(res, 401, { error: "unauthorized", detail: "Set Authorization: Bearer <MIN_AGENT_SERVE_TOKEN> when MIN_AGENT_SERVE_TOKEN is set" });
93
+ sendJson(res, 401, {
94
+ error: "unauthorized",
95
+ detail: "Set Authorization: Bearer <MIN_AGENT_SERVE_TOKEN> when MIN_AGENT_SERVE_TOKEN is set",
96
+ });
424
97
  return;
425
98
  }
426
99
  if (corsEnabled() && !originAllowed(req)) {
@@ -433,532 +106,9 @@ export async function runServe(opts = {}) {
433
106
  const url = new URL(req.url ?? "/", `http://${host}`);
434
107
  const pathname = url.pathname.replace(/\/$/, "") || "/";
435
108
  try {
436
- if (req.method === "GET" && pathname === "/health") {
437
- sendJson(res, 200, { ok: true, service: "min-agent", version });
438
- return;
439
- }
440
- if (req.method === "GET" && pathname === "/v1/meta") {
441
- sendJson(res, 200, {
442
- version,
443
- cwd: process.cwd(),
444
- instructions_chars: instructions.join("\n").length,
445
- });
446
- return;
447
- }
448
- if (req.method === "GET" && pathname === "/v1/models") {
449
- const config = loadConfig();
450
- const provider = getActiveProvider(config);
451
- const base = provider?.baseURL;
452
- const key = provider?.apiKey;
453
- if (!base || !key) {
454
- sendJson(res, 500, { error: "provider_not_configured" });
455
- return;
456
- }
457
- const models = await fetchModels(base, key);
458
- sendJson(res, 200, {
459
- default_model: provider?.defaultModel ?? null,
460
- models,
461
- });
462
- return;
463
- }
464
- if (req.method === "POST" && pathname === "/v1/chat/reload-instructions") {
465
- instructions = await loadInstructions();
466
- sendJson(res, 200, { ok: true, instructions_chars: instructions.join("\n").length });
467
- return;
468
- }
469
- if (req.method === "POST" && (pathname === "/v1/chat" || pathname === "/v1/code")) {
470
- await handleChatRequest(req, res, pathname === "/v1/code");
471
- return;
472
- }
473
- // ─── Sessions ──────────────────────────────────────────────────────
474
- if (req.method === "GET" && pathname === "/v1/sessions") {
475
- const sessions = listSessions();
476
- sendJson(res, 200, { sessions });
477
- return;
478
- }
479
- if (req.method === "DELETE" && pathname.startsWith("/v1/sessions/")) {
480
- const id = decodePathSegment(pathname.slice("/v1/sessions/".length));
481
- if (!id) {
482
- sendJson(res, 400, { error: "missing_session_id" });
483
- return;
484
- }
485
- const ok = deleteSession(id);
486
- if (!ok) {
487
- sendJson(res, 404, { error: "session_not_found", session_id: id });
488
- return;
489
- }
490
- sendJson(res, 200, { ok: true, deleted: id });
491
- return;
492
- }
493
- if (req.method === "GET" && pathname.startsWith("/v1/sessions/")) {
494
- const id = decodePathSegment(pathname.slice("/v1/sessions/".length));
495
- if (!id) {
496
- sendJson(res, 400, { error: "missing_session_id" });
497
- return;
498
- }
499
- const session = loadSession(id);
500
- if (!session) {
501
- sendJson(res, 404, { error: "session_not_found", session_id: id });
502
- return;
503
- }
504
- sendJson(res, 200, { session });
505
- return;
506
- }
507
- if (req.method === "POST" && pathname.startsWith("/v1/sessions/") && pathname.endsWith("/rename")) {
508
- const id = decodePathSegment(pathname.slice("/v1/sessions/".length, -"/rename".length));
509
- if (!id) {
510
- sendJson(res, 400, { error: "missing_session_id" });
511
- return;
512
- }
513
- const parsed = await readJsonBody(req);
514
- if (!parsed.ok) {
515
- sendJsonError(res, parsed);
516
- return;
517
- }
518
- const title = parsed.value.title?.trim();
519
- if (!title) {
520
- sendJson(res, 400, { error: "missing_title" });
521
- return;
522
- }
523
- if (!renameSession(id, title)) {
524
- sendJson(res, 404, { error: "session_not_found", session_id: id });
525
- return;
526
- }
527
- sendJson(res, 200, { ok: true, session_id: id, title });
528
- return;
529
- }
530
- // ─── Memory ────────────────────────────────────────────────────────
531
- if (req.method === "GET" && pathname === "/v1/memory") {
532
- const memories = loadMemories();
533
- sendJson(res, 200, { memories });
534
- return;
535
- }
536
- if (req.method === "POST" && pathname === "/v1/memory") {
537
- const parsed = await readJsonBody(req);
538
- if (!parsed.ok) {
539
- sendJsonError(res, parsed);
540
- return;
541
- }
542
- const body = parsed.value;
543
- if (!body.content) {
544
- sendJson(res, 400, { error: "missing_content" });
545
- return;
546
- }
547
- const memory = addMemory(body.content, body.tags ?? []);
548
- sendJson(res, 201, { ok: true, memory });
549
- return;
550
- }
551
- if (req.method === "GET" && pathname === "/v1/memory/search") {
552
- const query = url.searchParams.get("q") ?? "";
553
- if (!query) {
554
- sendJson(res, 400, { error: "missing_query_param_q" });
555
- return;
556
- }
557
- const results = searchMemories(query);
558
- sendJson(res, 200, { results });
559
- return;
560
- }
561
- if (req.method === "DELETE" && pathname.startsWith("/v1/memory/")) {
562
- const idx = parseInt(pathname.slice("/v1/memory/".length));
563
- if (isNaN(idx)) {
564
- sendJson(res, 400, { error: "invalid_index" });
565
- return;
566
- }
567
- const ok = deleteMemory(idx - 1);
568
- if (!ok) {
569
- sendJson(res, 404, { error: "memory_not_found", index: idx });
570
- return;
571
- }
572
- sendJson(res, 200, { ok: true, deleted: idx });
573
- return;
574
- }
575
- // ─── MCP ───────────────────────────────────────────────────────────
576
- if (req.method === "GET" && pathname === "/v1/mcp") {
577
- const status = getMcpStatus();
578
- sendJson(res, 200, { servers: status });
579
- return;
580
- }
581
- // ─── Skills ────────────────────────────────────────────────────────
582
- if (req.method === "GET" && pathname === "/v1/skills") {
583
- const all = getAllSkills();
584
- sendJson(res, 200, {
585
- count: all.length,
586
- enabled_count: all.filter((s) => s.enabled).length,
587
- stale_disabled: getStaleDisabledSkills(),
588
- skills: all.map(skillPayload),
589
- });
590
- return;
591
- }
592
- if (req.method === "POST" && pathname === "/v1/skills/reload") {
593
- discoverSkills({ silent: true });
594
- const all = getAllSkills();
595
- sendJson(res, 200, {
596
- ok: true,
597
- count: all.length,
598
- enabled_count: all.filter((s) => s.enabled).length,
599
- stale_disabled: getStaleDisabledSkills(),
600
- skills: all.map(skillPayload),
601
- });
602
- return;
603
- }
604
- const skillDetail = pathname.match(/^\/v1\/skills\/([^/]+)$/);
605
- if (req.method === "GET" && skillDetail) {
606
- const name = decodePathSegment(skillDetail[1]);
607
- if (!name) {
608
- sendJson(res, 400, { error: "invalid_skill_name" });
609
- return;
610
- }
611
- const skill = getSkill(name);
612
- if (!skill) {
613
- sendJson(res, 404, { error: "skill_not_found", name, available: getAllSkills().map((s) => s.name) });
614
- return;
615
- }
616
- sendJson(res, 200, { skill: { ...skillPayload(skill), content: skill.content } });
617
- return;
618
- }
619
- const skillToggle = pathname.match(/^\/v1\/skills\/([^/]+)\/(enable|disable)$/);
620
- if (req.method === "POST" && skillToggle) {
621
- const name = decodePathSegment(skillToggle[1]);
622
- if (!name) {
623
- sendJson(res, 400, { error: "invalid_skill_name" });
624
- return;
625
- }
626
- const enabled = skillToggle[2] === "enable";
627
- if (!getSkill(name)) {
628
- sendJson(res, 404, { error: "skill_not_found", name, available: getAllSkills().map((s) => s.name) });
629
- return;
630
- }
631
- const parsed = await readJsonBody(req);
632
- const scope = parsed.ok ? parsed.value.scope : undefined;
633
- if (scope !== undefined && scope !== "global" && scope !== "project") {
634
- sendJson(res, 400, { error: "invalid_scope", allowed: ["global", "project"] });
635
- return;
636
- }
637
- const [result] = setSkillEnabled([name], enabled, scope ?? "global");
638
- sendJson(res, 200, {
639
- ok: true,
640
- name,
641
- enabled: result.enabled,
642
- scope: scope ?? "global",
643
- ...(result.blockedBy ? { blocked_by: result.blockedBy } : {}),
644
- });
645
- return;
646
- }
647
- // ─── Rules ─────────────────────────────────────────────────────────
648
- if (req.method === "GET" && pathname === "/v1/rules") {
649
- sendJson(res, 200, {
650
- count: instructions.length,
651
- rules: instructions.map((inst) => {
652
- const firstLine = inst.split("\n")[0] ?? "";
653
- return { source: firstLine.replace("Instructions from: ", ""), chars: inst.length };
654
- }),
655
- });
656
- return;
657
- }
658
- // ─── Project (code mode scan) ──────────────────────────────────────
659
- if (req.method === "GET" && pathname === "/v1/project") {
660
- const project = scanProject();
661
- sendJson(res, 200, { project });
662
- return;
663
- }
664
- // ─── Code mode chat ────────────────────────────────────────────────
665
- // ─── MCP management ────────────────────────────────────────────────
666
- if (req.method === "POST" && pathname === "/v1/paste") {
667
- if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
668
- sendJson(res, 415, { error: "unsupported_media_type" });
669
- return;
670
- }
671
- if (activeChats >= MAX_CONCURRENT_CHATS) {
672
- sendJson(res, 429, { error: "too_many_concurrent_requests", detail: `Max ${MAX_CONCURRENT_CHATS} concurrent chats` });
673
- return;
674
- }
675
- const parsed = await readJsonBody(req);
676
- if (!parsed.ok) {
677
- sendJsonError(res, parsed);
678
- return;
679
- }
680
- const body = parsed.value;
681
- if (!body.image_base64) {
682
- sendJson(res, 400, { error: "missing_image_base64" });
683
- return;
684
- }
685
- const imageBuffer = Buffer.from(body.image_base64, "base64");
686
- const mimeType = body.mime_type ?? "image/png";
687
- const text = body.message ?? "What's in this image?";
688
- const modelId = body.model;
689
- const sessionId = body.session_id;
690
- const stream = body.stream === true;
691
- const codeMode = body.code === true;
692
- let messages = [];
693
- if (sessionId) {
694
- const session = loadSession(sessionId);
695
- if (!session) {
696
- sendJson(res, 404, { error: "session_not_found", session_id: sessionId });
697
- return;
698
- }
699
- messages = [...session.messages];
700
- }
701
- activeChats++;
702
- try {
703
- const content = [
704
- { type: "text", text },
705
- { type: "image", image: imageBuffer, mimeType },
706
- ];
707
- messages.push({ role: "user", content });
708
- const abort = new AbortController();
709
- res.on("close", () => abort.abort());
710
- const tracker = new TokenTracker();
711
- const runOptions = {
712
- temperature: typeof body.temperature === "number" ? body.temperature : undefined,
713
- maxTokens: typeof body.maxTokens === "number" ? body.maxTokens : undefined,
714
- topP: typeof body.topP === "number" ? body.topP : undefined,
715
- providerName: typeof body.provider === "string" ? body.provider : undefined,
716
- };
717
- if (stream) {
718
- res.writeHead(200, sseHeaders(c));
719
- res.flushHeaders?.();
720
- const callbacks = createSseCallbacks(res, messages, { sessionId });
721
- try {
722
- if (codeMode) {
723
- const project = scanProject();
724
- const system = buildCodeSystemPrompt(project, instructions);
725
- await runOnceWithSystem(messages, system, modelId, abort.signal, callbacks, tracker, runOptions);
726
- }
727
- else {
728
- await runOnce(messages, instructions, modelId, abort.signal, callbacks, tracker, runOptions);
729
- }
730
- }
731
- catch (err) {
732
- if (!res.writableEnded) {
733
- sseWrite(res, { type: "fatal", message: err?.message ?? String(err) });
734
- res.end();
735
- }
736
- }
737
- }
738
- else {
739
- const { callbacks, toolCalls, toolResults, finishInfo } = createJsonCallbacks();
740
- let project;
741
- if (codeMode) {
742
- project = scanProject();
743
- const system = buildCodeSystemPrompt(project, instructions);
744
- await runOnceWithSystem(messages, system, modelId, abort.signal, callbacks, tracker, runOptions);
745
- }
746
- else {
747
- await runOnce(messages, instructions, modelId, abort.signal, callbacks, tracker, runOptions);
748
- }
749
- const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
750
- let saved;
751
- if (sessionId) {
752
- try {
753
- saved = saveSession(messages, sessionId);
754
- }
755
- catch { }
756
- }
757
- const fi = finishInfo();
758
- sendJson(res, 200, {
759
- messages: sanitizeForStorage(messages),
760
- assistant: lastAssistant ?? null,
761
- tool_calls: toolCalls,
762
- tool_results: toolResults,
763
- session_id: saved,
764
- step_count: fi?.stepCount ?? 0,
765
- usage: fi?.usage ?? null,
766
- has_error: fi?.hasError ?? false,
767
- aborted: fi?.aborted ?? false,
768
- max_steps_reached: fi?.maxStepsReached ?? false,
769
- ...(codeMode ? { mode: "code", project } : {}),
770
- });
771
- }
772
- }
773
- finally {
774
- activeChats--;
775
- }
776
- return;
777
- }
778
- // ─── MCP management (continued) ───────────────────────────────────
779
- if (req.method === "POST" && pathname === "/v1/mcp") {
780
- const parsed = await readJsonBody(req);
781
- if (!parsed.ok) {
782
- sendJsonError(res, parsed);
783
- return;
784
- }
785
- const body = parsed.value;
786
- if (!body.name) {
787
- sendJson(res, 400, { error: "missing_name" });
788
- return;
789
- }
790
- if (body.remoteTransport && !["streamable-http", "sse", "auto"].includes(body.remoteTransport)) {
791
- sendJson(res, 400, { error: "invalid_remote_transport", allowed: ["streamable-http", "sse", "auto"] });
792
- return;
793
- }
794
- const scope = body.project ? "project" : "global";
795
- const { loadMcpConfigFile, saveMcpConfig } = await import("./mcp.js");
796
- const config = loadMcpConfigFile(scope);
797
- const common = {
798
- ...(body.environment ? { environment: body.environment } : {}),
799
- ...(body.headers ? { headers: body.headers } : {}),
800
- ...(body.remoteTransport ? { remoteTransport: body.remoteTransport } : {}),
801
- ...(body.timeout !== undefined ? { timeout: body.timeout } : {}),
802
- ...(body.connectTimeout !== undefined ? { connectTimeout: body.connectTimeout } : {}),
803
- ...(body.callTimeout !== undefined ? { callTimeout: body.callTimeout } : {}),
804
- enabled: body.enabled !== false,
805
- };
806
- if (body.url) {
807
- config.mcpServers[body.name] = { url: body.url, ...(body.token ? { token: body.token } : {}), ...common };
808
- }
809
- else if (typeof body.command === "string" && body.command.length > 0) {
810
- config.mcpServers[body.name] = { command: body.command, ...(body.args ? { args: body.args } : {}), ...common };
811
- }
812
- else if (Array.isArray(body.command) && body.command.length > 0) {
813
- config.mcpServers[body.name] = { command: body.command, ...common };
814
- }
815
- else {
816
- sendJson(res, 400, { error: "provide command or url" });
817
- return;
818
- }
819
- saveMcpConfig(config, scope);
820
- await syncMcpConnections();
821
- const status = getMcpStatus()[body.name];
822
- sendJson(res, 201, {
823
- ok: true,
824
- name: body.name,
825
- scope,
826
- connected: status?.connected ?? false,
827
- ...(status?.error ? { error: status.error } : {}),
828
- ...(status?.tools?.length ? { tools: status.tools } : {}),
829
- });
830
- return;
831
- }
832
- if (req.method === "DELETE" && pathname.startsWith("/v1/mcp/")) {
833
- const name = decodePathSegment(pathname.slice("/v1/mcp/".length));
834
- if (!name) {
835
- sendJson(res, 400, { error: "missing_name" });
836
- return;
837
- }
838
- const scope = url.searchParams.get("project") === "1" ? "project" : "global";
839
- const { loadMcpConfigFile, saveMcpConfig } = await import("./mcp.js");
840
- const config = loadMcpConfigFile(scope);
841
- if (!config.mcpServers[name]) {
842
- sendJson(res, 404, { error: "mcp_not_found", name, scope, available: Object.keys(config.mcpServers) });
843
- return;
844
- }
845
- delete config.mcpServers[name];
846
- saveMcpConfig(config, scope);
847
- await syncMcpConnections();
848
- sendJson(res, 200, { ok: true, deleted: name, scope });
849
- return;
850
- }
851
- const mcpToggle = pathname.match(/^\/v1\/mcp\/([^/]+)\/(enable|disable)$/);
852
- if (req.method === "POST" && mcpToggle) {
853
- const name = decodePathSegment(mcpToggle[1]);
854
- if (!name) {
855
- sendJson(res, 400, { error: "missing_name" });
856
- return;
857
- }
858
- const enabled = mcpToggle[2] === "enable";
859
- const parsed = await readJsonBody(req);
860
- const scope = parsed.ok && parsed.value.scope === "project" ? "project" : "global";
861
- const { loadMcpConfigFile, saveMcpConfig } = await import("./mcp.js");
862
- const config = loadMcpConfigFile(scope);
863
- if (!config.mcpServers[name]) {
864
- const known = loadMcpConfigEntries().some((e) => e.name === name);
865
- sendJson(res, 404, {
866
- error: "mcp_not_found",
867
- name,
868
- scope,
869
- ...(known ? { hint: `server exists in another scope; pass the matching scope` } : { available: Object.keys(config.mcpServers) }),
870
- });
871
- return;
872
- }
873
- config.mcpServers[name].enabled = enabled;
874
- saveMcpConfig(config, scope);
875
- await syncMcpConnections();
876
- sendJson(res, 200, { ok: true, name, enabled, scope, connected: getMcpStatus()[name]?.connected ?? false });
877
- return;
878
- }
879
- // ─── Context / Compact ─────────────────────────────────────────────
880
- if (req.method === "GET" && pathname === "/v1/context") {
881
- const config = loadConfig();
882
- const provider = getActiveProvider(config);
883
- const ctxWindow = await getContextWindow(provider?.defaultModel);
884
- sendJson(res, 200, {
885
- context_window: ctxWindow,
886
- model: provider?.defaultModel ?? null,
887
- });
888
- return;
889
- }
890
- if (req.method === "GET" && pathname === "/v1/cost") {
891
- const config = loadConfig();
892
- const provider = getActiveProvider(config);
893
- const modelId = url.searchParams.get("model") ?? provider?.defaultModel ?? null;
894
- const inputRaw = url.searchParams.get("input");
895
- const outputRaw = url.searchParams.get("output");
896
- const input = inputRaw ? parseInt(inputRaw, 10) : 0;
897
- const output = outputRaw ? parseInt(outputRaw, 10) : 0;
898
- if (Number.isNaN(input) || Number.isNaN(output) || input < 0 || output < 0) {
899
- sendJson(res, 400, { error: "invalid_tokens", detail: "input/output must be non-negative integers" });
900
- return;
901
- }
902
- const price = modelId ? await getModelPrice(modelId) : null;
903
- const cost = price ? estimateCost({ inputTokens: input, outputTokens: output }, price) : null;
904
- sendJson(res, 200, { model: modelId, price, cost_usd: cost });
905
- return;
906
- }
907
- if (req.method === "POST" && pathname === "/v1/chat/compact") {
908
- const parsed = await readJsonBody(req);
909
- if (!parsed.ok) {
910
- sendJsonError(res, parsed);
911
- return;
912
- }
913
- const body = parsed.value;
914
- if (!body.session_id) {
915
- sendJson(res, 400, { error: "missing_session_id" });
916
- return;
917
- }
918
- const session = loadSession(body.session_id);
919
- if (!session) {
920
- sendJson(res, 404, { error: "session_not_found" });
921
- return;
922
- }
923
- const model = resolveModel();
924
- const result = await compactMessages(session.messages, model, { autoContinue: false });
925
- if (result.compacted) {
926
- saveSession(result.messages, body.session_id);
927
- }
928
- sendJson(res, 200, { ok: true, compacted: result.compacted, message_count: result.messages.length });
929
- return;
930
- }
931
- if (req.method === "POST" && pathname === "/v1/chat/undo") {
932
- const parsed = await readJsonBody(req);
933
- if (!parsed.ok) {
934
- sendJsonError(res, parsed);
935
- return;
936
- }
937
- const body = parsed.value;
938
- if (!body.session_id) {
939
- sendJson(res, 400, { error: "missing_session_id" });
940
- return;
941
- }
942
- const session = loadSession(body.session_id);
943
- if (!session) {
944
- sendJson(res, 404, { error: "session_not_found" });
945
- return;
946
- }
947
- let userIdx = -1;
948
- for (let i = session.messages.length - 1; i >= 0; i--) {
949
- if (session.messages[i].role === "user") {
950
- userIdx = i;
951
- break;
952
- }
953
- }
954
- if (userIdx === -1) {
955
- sendJson(res, 400, { error: "no_user_message" });
109
+ for (const route of routes) {
110
+ if (await route(req, res, ctx, url, pathname))
956
111
  return;
957
- }
958
- session.messages = session.messages.slice(0, userIdx + 1);
959
- saveSession(session.messages, body.session_id);
960
- sendJson(res, 200, { ok: true, message_count: session.messages.length, messages: session.messages });
961
- return;
962
112
  }
963
113
  sendJson(res, 404, { error: "not_found", path: pathname });
964
114
  }