min-agent 0.2.1 → 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 (137) hide show
  1. package/README.md +242 -31
  2. package/dist/agent.js +1233 -485
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli/commands/chat.js +10 -0
  5. package/dist/cli/commands/exec.js +32 -0
  6. package/dist/cli/commands/history.js +58 -0
  7. package/dist/cli/commands/index.js +224 -0
  8. package/dist/cli/commands/init.js +18 -0
  9. package/dist/cli/commands/mcp.js +173 -0
  10. package/dist/cli/commands/memory.js +69 -0
  11. package/dist/cli/commands/models.js +21 -0
  12. package/dist/cli/commands/permission.js +12 -0
  13. package/dist/cli/commands/rules.js +33 -0
  14. package/dist/cli/commands/sandbox.js +13 -0
  15. package/dist/cli/commands/serve.js +9 -0
  16. package/dist/cli/commands/setup.js +4 -0
  17. package/dist/cli/commands/shared.js +16 -0
  18. package/dist/cli/commands/skills.js +119 -0
  19. package/dist/cli/commands/update.js +7 -0
  20. package/dist/cli/commands/write-config.js +30 -0
  21. package/dist/cli/errors.js +36 -0
  22. package/dist/cli/exec-prompt.js +26 -0
  23. package/dist/cli/option-helpers.js +53 -0
  24. package/dist/cli/program.js +180 -0
  25. package/dist/cli.js +7 -632
  26. package/dist/clipboard.js +59 -23
  27. package/dist/code-mode.js +35 -17
  28. package/dist/compaction.js +457 -169
  29. package/dist/config.js +298 -38
  30. package/dist/confirm.js +105 -9
  31. package/dist/context-window.js +156 -75
  32. package/dist/doom-loop.js +268 -26
  33. package/dist/fetch-timeout.js +152 -0
  34. package/dist/http-approvals.js +60 -0
  35. package/dist/http.js +119 -0
  36. package/dist/instructions.js +72 -33
  37. package/dist/logger.js +95 -0
  38. package/dist/markdown.js +35 -50
  39. package/dist/mcp.js +847 -102
  40. package/dist/memory.js +128 -45
  41. package/dist/output.js +42 -31
  42. package/dist/paste-handler.js +3 -3
  43. package/dist/permission-cli.js +43 -0
  44. package/dist/plugins.js +76 -11
  45. package/dist/pricing.js +119 -0
  46. package/dist/provider.js +34 -15
  47. package/dist/question-format.js +60 -0
  48. package/dist/sandbox-cli.js +82 -0
  49. package/dist/sandbox.js +403 -0
  50. package/dist/save-throttle.js +45 -0
  51. package/dist/serve/common.js +404 -0
  52. package/dist/serve/routes-chat.js +347 -0
  53. package/dist/serve/routes-mcp.js +212 -0
  54. package/dist/serve/routes-memory.js +66 -0
  55. package/dist/serve/routes-meta.js +205 -0
  56. package/dist/serve/routes-sessions.js +61 -0
  57. package/dist/serve/routes-skills.js +70 -0
  58. package/dist/serve.js +74 -635
  59. package/dist/sessions.js +197 -15
  60. package/dist/skills.js +531 -77
  61. package/dist/synthetic.js +7 -0
  62. package/dist/title-gen.js +9 -2
  63. package/dist/token-display.js +36 -0
  64. package/dist/tool-display.js +178 -0
  65. package/dist/tool-output.js +53 -46
  66. package/dist/tools/apply_patch.js +265 -0
  67. package/dist/tools/atomic-file.js +35 -0
  68. package/dist/tools/backend.js +61 -0
  69. package/dist/tools/bash.js +186 -71
  70. package/dist/tools/code_search.js +13 -6
  71. package/dist/tools/edit.js +26 -9
  72. package/dist/tools/explore.js +144 -16
  73. package/dist/tools/glob.js +7 -3
  74. package/dist/tools/grep.js +153 -14
  75. package/dist/tools/index.js +9 -24
  76. package/dist/tools/question.js +31 -30
  77. package/dist/tools/read.js +77 -15
  78. package/dist/tools/search-searxng.js +223 -0
  79. package/dist/tools/search-serper.js +189 -0
  80. package/dist/tools/task.js +100 -33
  81. package/dist/tools/todo.js +178 -67
  82. package/dist/tools/web_fetch.js +158 -46
  83. package/dist/tools/web_search.js +217 -29
  84. package/dist/tools/write.js +34 -11
  85. package/dist/tui/App.js +89 -6
  86. package/dist/tui/ConfirmBar.js +57 -4
  87. package/dist/tui/InputBar.js +504 -44
  88. package/dist/tui/MessageList.js +674 -20
  89. package/dist/tui/ModelPicker.js +113 -0
  90. package/dist/tui/QuestionBar.js +136 -0
  91. package/dist/tui/SessionPicker.js +79 -0
  92. package/dist/tui/StatusBar.js +14 -12
  93. package/dist/tui/agent-runner.js +223 -0
  94. package/dist/tui/caret-pos.js +177 -0
  95. package/dist/tui/caret.js +69 -0
  96. package/dist/tui/click-count.js +13 -0
  97. package/dist/tui/diff-view.js +61 -0
  98. package/dist/tui/drag-state.js +49 -0
  99. package/dist/tui/hydrate.js +129 -0
  100. package/dist/tui/index.js +189 -31
  101. package/dist/tui/input-history.js +125 -0
  102. package/dist/tui/layout.js +88 -0
  103. package/dist/tui/mouse.js +46 -0
  104. package/dist/tui/prompt-queue.js +24 -0
  105. package/dist/tui/selection.js +226 -0
  106. package/dist/tui/session-switch.js +28 -0
  107. package/dist/tui/slash-commands.js +106 -0
  108. package/dist/tui/slash-handler.js +545 -0
  109. package/dist/tui/text-width.js +113 -0
  110. package/dist/tui/theme.js +12 -0
  111. package/dist/tui/token-info.js +7 -0
  112. package/dist/tui/tool-children.js +19 -0
  113. package/dist/tui/undo-stack.js +14 -0
  114. package/dist/tui/use-sgr-mouse.js +29 -0
  115. package/dist/tui-chat.js +346 -330
  116. package/dist/updater.js +116 -0
  117. package/dist/xml-search.js +194 -0
  118. package/docs/API.md +410 -32
  119. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  120. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  121. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  122. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  123. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  124. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  125. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  126. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  127. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  128. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  129. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  130. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  131. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  132. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  133. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  134. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  135. package/package.json +12 -8
  136. package/skills/self-config/SKILL.md +90 -0
  137. package/skills/self-config/reference.md +149 -0
package/dist/serve.js CHANGED
@@ -3,42 +3,40 @@
3
3
  * Run: min-agent serve [--host 127.0.0.1] [--port 8787]
4
4
  */
5
5
  import { createServer } from "http";
6
- import { readFileSync, existsSync } from "fs";
7
- import path from "path";
8
- import { initMcp, shutdownMcp, getMcpStatus } from "./mcp.js";
9
- import { discoverSkills, getSkills } from "./skills.js";
6
+ import { timingSafeEqual } from "crypto";
7
+ import { initMcp, shutdownMcp } from "./mcp.js";
8
+ import { discoverSkills } from "./skills.js";
10
9
  import { loadInstructions } from "./instructions.js";
11
- import { loadConfig, fetchModels, isConfigured } from "./config.js";
10
+ import { isConfigured } from "./config.js";
12
11
  import { setAutoApprove } from "./confirm.js";
13
- import { runOnce, buildUserContent } from "./agent.js";
14
- import { loadSession, saveSession, listSessions, deleteSession } from "./sessions.js";
15
- import { loadMemories, addMemory, deleteMemory, searchMemories } from "./memory.js";
16
- import { scanProject } from "./code-mode.js";
17
- import { compactMessages } from "./compaction.js";
18
- import { resolveModel } from "./provider.js";
19
- import { getContextWindow } from "./context-window.js";
20
- const MAX_BODY_BYTES = 2 * 1024 * 1024;
21
- const MAX_TOOL_RESULT_SSE_CHARS = 48_000;
22
- function packageVersion() {
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";
20
+ /**
21
+ * With CORS enabled and no auth token, arbitrary web pages could drive the
22
+ * agent. Allow only same-machine origins (localhost) in that configuration;
23
+ * a token bypasses the origin check entirely.
24
+ */
25
+ function originAllowed(req) {
26
+ if (process.env.MIN_AGENT_SERVE_TOKEN?.trim())
27
+ return true;
28
+ const origin = req.headers.origin?.trim();
29
+ if (!origin || origin === "null")
30
+ return true;
23
31
  try {
24
- const pkgPath = path.join(process.cwd(), "package.json");
25
- if (existsSync(pkgPath)) {
26
- const j = JSON.parse(readFileSync(pkgPath, "utf-8"));
27
- return j.version ?? "0.0.0";
28
- }
32
+ const host = new URL(origin).hostname;
33
+ if (host === "localhost" || host === "127.0.0.1" || host === "[::1]")
34
+ return true;
29
35
  }
30
- catch { }
31
- return "0.0.0";
32
- }
33
- function corsHeaders() {
34
- if (process.env.MIN_AGENT_SERVE_CORS === "1" || process.env.MIN_AGENT_SERVE_CORS === "true") {
35
- return {
36
- "Access-Control-Allow-Origin": "*",
37
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
38
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
39
- };
36
+ catch {
37
+ return false;
40
38
  }
41
- return {};
39
+ return false;
42
40
  }
43
41
  function authOk(req) {
44
42
  const token = process.env.MIN_AGENT_SERVE_TOKEN?.trim();
@@ -47,57 +45,9 @@ function authOk(req) {
47
45
  const h = req.headers.authorization?.trim();
48
46
  if (!h?.startsWith("Bearer "))
49
47
  return false;
50
- return h.slice(7) === token;
51
- }
52
- function sendJson(res, status, body) {
53
- const headers = {
54
- "Content-Type": "application/json; charset=utf-8",
55
- ...corsHeaders(),
56
- };
57
- res.writeHead(status, headers);
58
- res.end(JSON.stringify(body));
59
- }
60
- function readBody(req) {
61
- return new Promise((resolve, reject) => {
62
- const chunks = [];
63
- let total = 0;
64
- req.on("data", (c) => {
65
- total += c.length;
66
- if (total > MAX_BODY_BYTES) {
67
- reject(new Error("body_too_large"));
68
- return;
69
- }
70
- chunks.push(c);
71
- });
72
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
73
- req.on("error", reject);
74
- });
75
- }
76
- function sseWrite(res, obj) {
77
- try {
78
- if (res.writableEnded)
79
- return;
80
- res.write(`data: ${JSON.stringify(obj)}\n\n`);
81
- }
82
- catch {
83
- /* client gone */
84
- }
85
- }
86
- function truncateForJson(v, max) {
87
- if (typeof v === "string" && v.length > max)
88
- return v.slice(0, max) + `\n… [truncated ${v.length - max} chars]`;
89
- return v;
90
- }
91
- function normalizeMessages(body) {
92
- if (body.messages && Array.isArray(body.messages)) {
93
- if (body.messages.length === 0)
94
- return { ok: false, error: "messages must be non-empty" };
95
- return { ok: true, messages: [...body.messages] };
96
- }
97
- if (typeof body.message === "string" && body.message.length > 0) {
98
- return { ok: true, messages: [{ role: "user", content: body.message }] };
99
- }
100
- return { ok: false, error: "Provide `message` (string) or non-empty `messages` array" };
48
+ const provided = Buffer.from(h.slice(7));
49
+ const expected = Buffer.from(token);
50
+ return provided.length === expected.length && timingSafeEqual(provided, expected);
101
51
  }
102
52
  export async function runServe(opts = {}) {
103
53
  if (!isConfigured()) {
@@ -106,13 +56,32 @@ export async function runServe(opts = {}) {
106
56
  }
107
57
  const host = opts.host ?? process.env.MIN_AGENT_SERVE_HOST ?? "127.0.0.1";
108
58
  const port = opts.port ?? parseInt(process.env.MIN_AGENT_SERVE_PORT ?? "8787", 10);
109
- 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");
59
+ const isLoopbackHost = host === "127.0.0.1" || host === "::1" || host === "localhost";
60
+ if (!isLoopbackHost && !process.env.MIN_AGENT_SERVE_TOKEN?.trim()) {
61
+ console.error("Refusing to serve on a non-loopback address without MIN_AGENT_SERVE_TOKEN. " +
62
+ "Set the token (Authorization: Bearer <token>) or bind to 127.0.0.1.");
63
+ process.exit(1);
64
+ }
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");
110
66
  setAutoApprove(true);
111
67
  console.error("\x1b[90m⟳ Initializing MCP, skills, instructions…\x1b[0m");
112
68
  await initMcp();
113
69
  discoverSkills();
114
- let instructions = await loadInstructions();
115
- const version = packageVersion();
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
+ ];
116
85
  const server = createServer(async (req, res) => {
117
86
  const c = corsHeaders();
118
87
  if (req.method === "OPTIONS") {
@@ -121,560 +90,25 @@ export async function runServe(opts = {}) {
121
90
  return;
122
91
  }
123
92
  if (!authOk(req)) {
124
- 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
+ });
97
+ return;
98
+ }
99
+ if (corsEnabled() && !originAllowed(req)) {
100
+ sendJson(res, 403, {
101
+ error: "origin_not_allowed",
102
+ detail: "CORS is enabled without a token; only localhost origins are allowed. Set MIN_AGENT_SERVE_TOKEN or disable CORS.",
103
+ });
125
104
  return;
126
105
  }
127
106
  const url = new URL(req.url ?? "/", `http://${host}`);
128
107
  const pathname = url.pathname.replace(/\/$/, "") || "/";
129
108
  try {
130
- if (req.method === "GET" && pathname === "/health") {
131
- sendJson(res, 200, { ok: true, service: "min-agent", version });
132
- return;
133
- }
134
- if (req.method === "GET" && pathname === "/v1/meta") {
135
- sendJson(res, 200, {
136
- version,
137
- cwd: process.cwd(),
138
- instructions_chars: instructions.join("\n").length,
139
- });
140
- return;
141
- }
142
- if (req.method === "GET" && pathname === "/v1/models") {
143
- const config = loadConfig();
144
- const base = config.provider?.baseURL;
145
- const key = config.provider?.apiKey;
146
- if (!base || !key) {
147
- sendJson(res, 500, { error: "provider_not_configured" });
148
- return;
149
- }
150
- const models = await fetchModels(base, key);
151
- sendJson(res, 200, {
152
- default_model: config.provider?.defaultModel ?? null,
153
- models,
154
- });
155
- return;
156
- }
157
- if (req.method === "POST" && pathname === "/v1/chat/reload-instructions") {
158
- instructions = await loadInstructions();
159
- sendJson(res, 200, { ok: true, instructions_chars: instructions.join("\n").length });
160
- return;
161
- }
162
- if (req.method === "POST" && pathname === "/v1/chat") {
163
- if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
164
- sendJson(res, 415, { error: "unsupported_media_type", detail: "Use Content-Type: application/json" });
165
- return;
166
- }
167
- let raw;
168
- try {
169
- raw = await readBody(req);
170
- }
171
- catch (e) {
172
- if (e?.message === "body_too_large") {
173
- sendJson(res, 413, { error: "payload_too_large", max_bytes: MAX_BODY_BYTES });
174
- return;
175
- }
176
- throw e;
177
- }
178
- let body;
179
- try {
180
- body = JSON.parse(raw);
181
- }
182
- catch {
183
- sendJson(res, 400, { error: "invalid_json" });
184
- return;
185
- }
186
- const modelId = typeof body.model === "string" ? body.model : undefined;
187
- const stream = body.stream === true;
188
- const sessionId = typeof body.session_id === "string" ? body.session_id : undefined;
189
- let messages;
190
- if (sessionId) {
191
- const session = loadSession(sessionId);
192
- if (!session) {
193
- sendJson(res, 404, { error: "session_not_found", session_id: sessionId });
194
- return;
195
- }
196
- if (typeof body.message !== "string" || !body.message.trim()) {
197
- sendJson(res, 400, {
198
- error: "session_requires_message",
199
- detail: "With `session_id`, send a non-empty `message` for the new user turn",
200
- });
201
- return;
202
- }
203
- messages = [...session.messages];
204
- const content = body.images && body.images.length > 0 ? await buildUserContent(body.message, body.images) : body.message;
205
- messages.push({ role: "user", content });
206
- }
207
- else {
208
- const norm = normalizeMessages(body);
209
- if (!norm.ok) {
210
- sendJson(res, 400, { error: "invalid_body", detail: norm.error });
211
- return;
212
- }
213
- messages = norm.messages;
214
- if (body.images && body.images.length > 0) {
215
- const last = messages[messages.length - 1];
216
- if (!last || last.role !== "user" || typeof body.message !== "string") {
217
- sendJson(res, 400, {
218
- error: "images_require_message",
219
- detail: "With `images`, send a top-level `message` string for the user turn",
220
- });
221
- return;
222
- }
223
- messages[messages.length - 1] = {
224
- role: "user",
225
- content: await buildUserContent(body.message, body.images),
226
- };
227
- }
228
- }
229
- const abort = new AbortController();
230
- req.on("close", () => abort.abort());
231
- if (stream) {
232
- res.writeHead(200, {
233
- "Content-Type": "text/event-stream; charset=utf-8",
234
- "Cache-Control": "no-cache, no-transform",
235
- Connection: "keep-alive",
236
- "X-Accel-Buffering": "no",
237
- ...c,
238
- });
239
- res.flushHeaders?.();
240
- const toolCalls = [];
241
- const toolResults = [];
242
- const callbacks = {
243
- onAssistantDisplayDelta(delta) {
244
- sseWrite(res, { type: "assistant", text: delta });
245
- },
246
- onThinkingDelta(delta) {
247
- sseWrite(res, { type: "thinking", text: delta });
248
- },
249
- onToolCall(name, input) {
250
- toolCalls.push({ name, input });
251
- sseWrite(res, { type: "tool_call", name, input });
252
- },
253
- onToolResult(name, output) {
254
- const out = truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS);
255
- toolResults.push({ name, output: out });
256
- sseWrite(res, { type: "tool_result", name, output: out });
257
- },
258
- onCompaction(line) {
259
- sseWrite(res, { type: "compaction", line });
260
- },
261
- onStreamError(message) {
262
- sseWrite(res, { type: "error", message });
263
- },
264
- onRunFinish(info) {
265
- let saved;
266
- if (sessionId && messages.length > 0) {
267
- try {
268
- saved = saveSession(messages, sessionId);
269
- }
270
- catch {
271
- saved = undefined;
272
- }
273
- }
274
- sseWrite(res, {
275
- type: "done",
276
- step_count: info.stepCount,
277
- usage: info.usage,
278
- has_error: info.hasError,
279
- aborted: info.aborted,
280
- session_id: saved,
281
- messages,
282
- });
283
- if (!res.writableEnded)
284
- res.end();
285
- },
286
- };
287
- try {
288
- await runOnce(messages, instructions, modelId, abort.signal, callbacks);
289
- }
290
- catch (err) {
291
- if (!res.writableEnded) {
292
- sseWrite(res, { type: "fatal", message: err?.message ?? String(err) });
293
- res.end();
294
- }
295
- }
109
+ for (const route of routes) {
110
+ if (await route(req, res, ctx, url, pathname))
296
111
  return;
297
- }
298
- const toolCalls = [];
299
- const toolResults = [];
300
- const finishBox = { info: null };
301
- await runOnce(messages, instructions, modelId, abort.signal, {
302
- onToolCall(name, input) {
303
- toolCalls.push({ name, input });
304
- },
305
- onToolResult(name, output) {
306
- toolResults.push({ name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) });
307
- },
308
- onRunFinish(info) {
309
- finishBox.info = info;
310
- },
311
- });
312
- const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
313
- let savedSession;
314
- if (sessionId && messages.length > 0) {
315
- try {
316
- savedSession = saveSession(messages, sessionId);
317
- }
318
- catch {
319
- savedSession = undefined;
320
- }
321
- }
322
- const fi = finishBox.info;
323
- sendJson(res, 200, {
324
- messages,
325
- assistant: lastAssistant ?? null,
326
- tool_calls: toolCalls,
327
- tool_results: toolResults,
328
- session_id: savedSession,
329
- step_count: fi?.stepCount ?? 0,
330
- usage: fi?.usage ?? null,
331
- has_error: fi?.hasError ?? false,
332
- aborted: fi?.aborted ?? false,
333
- });
334
- return;
335
- }
336
- // ─── Sessions ──────────────────────────────────────────────────────
337
- if (req.method === "GET" && pathname === "/v1/sessions") {
338
- const sessions = listSessions();
339
- sendJson(res, 200, { sessions });
340
- return;
341
- }
342
- if (req.method === "DELETE" && pathname.startsWith("/v1/sessions/")) {
343
- const id = pathname.slice("/v1/sessions/".length);
344
- if (!id) {
345
- sendJson(res, 400, { error: "missing_session_id" });
346
- return;
347
- }
348
- const ok = deleteSession(id);
349
- if (!ok) {
350
- sendJson(res, 404, { error: "session_not_found", session_id: id });
351
- return;
352
- }
353
- sendJson(res, 200, { ok: true, deleted: id });
354
- return;
355
- }
356
- // ─── Memory ────────────────────────────────────────────────────────
357
- if (req.method === "GET" && pathname === "/v1/memory") {
358
- const memories = loadMemories();
359
- sendJson(res, 200, { memories });
360
- return;
361
- }
362
- if (req.method === "POST" && pathname === "/v1/memory") {
363
- const raw = await readBody(req);
364
- const body = JSON.parse(raw);
365
- if (!body.content) {
366
- sendJson(res, 400, { error: "missing_content" });
367
- return;
368
- }
369
- const memory = addMemory(body.content, body.tags ?? []);
370
- sendJson(res, 201, { ok: true, memory });
371
- return;
372
- }
373
- if (req.method === "GET" && pathname === "/v1/memory/search") {
374
- const query = url.searchParams.get("q") ?? "";
375
- if (!query) {
376
- sendJson(res, 400, { error: "missing_query_param_q" });
377
- return;
378
- }
379
- const results = searchMemories(query);
380
- sendJson(res, 200, { results });
381
- return;
382
- }
383
- if (req.method === "DELETE" && pathname.startsWith("/v1/memory/")) {
384
- const idx = parseInt(pathname.slice("/v1/memory/".length));
385
- if (isNaN(idx)) {
386
- sendJson(res, 400, { error: "invalid_index" });
387
- return;
388
- }
389
- const ok = deleteMemory(idx - 1);
390
- if (!ok) {
391
- sendJson(res, 404, { error: "memory_not_found", index: idx });
392
- return;
393
- }
394
- sendJson(res, 200, { ok: true, deleted: idx });
395
- return;
396
- }
397
- // ─── MCP ───────────────────────────────────────────────────────────
398
- if (req.method === "GET" && pathname === "/v1/mcp") {
399
- const status = getMcpStatus();
400
- sendJson(res, 200, { servers: status });
401
- return;
402
- }
403
- // ─── Skills ────────────────────────────────────────────────────────
404
- if (req.method === "GET" && pathname === "/v1/skills") {
405
- const skills = getSkills();
406
- sendJson(res, 200, {
407
- skills: skills.map((s) => ({ name: s.name, description: s.description, location: s.location })),
408
- });
409
- return;
410
- }
411
- // ─── Rules ─────────────────────────────────────────────────────────
412
- if (req.method === "GET" && pathname === "/v1/rules") {
413
- sendJson(res, 200, {
414
- count: instructions.length,
415
- rules: instructions.map((inst) => {
416
- const firstLine = inst.split("\n")[0] ?? "";
417
- return { source: firstLine.replace("Instructions from: ", ""), chars: inst.length };
418
- }),
419
- });
420
- return;
421
- }
422
- // ─── Project (code mode scan) ──────────────────────────────────────
423
- if (req.method === "GET" && pathname === "/v1/project") {
424
- const project = scanProject();
425
- sendJson(res, 200, { project });
426
- return;
427
- }
428
- // ─── Code mode chat ────────────────────────────────────────────────
429
- if (req.method === "POST" && pathname === "/v1/code") {
430
- if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
431
- sendJson(res, 415, { error: "unsupported_media_type" });
432
- return;
433
- }
434
- let raw;
435
- try {
436
- raw = await readBody(req);
437
- }
438
- catch {
439
- sendJson(res, 413, { error: "payload_too_large" });
440
- return;
441
- }
442
- let body;
443
- try {
444
- body = JSON.parse(raw);
445
- }
446
- catch {
447
- sendJson(res, 400, { error: "invalid_json" });
448
- return;
449
- }
450
- const modelId = typeof body.model === "string" ? body.model : undefined;
451
- const stream = body.stream === true;
452
- const sessionId = typeof body.session_id === "string" ? body.session_id : undefined;
453
- // Build code-mode system prompt
454
- const { buildCodeSystemPrompt } = await import("./code-mode.js");
455
- const project = scanProject();
456
- const codeSystemPrompt = buildCodeSystemPrompt(project, instructions);
457
- let messages;
458
- if (sessionId) {
459
- const session = loadSession(sessionId);
460
- if (!session) {
461
- sendJson(res, 404, { error: "session_not_found", session_id: sessionId });
462
- return;
463
- }
464
- if (!body.message) {
465
- sendJson(res, 400, { error: "session_requires_message" });
466
- return;
467
- }
468
- messages = [...session.messages];
469
- const content = body.images?.length ? await buildUserContent(body.message, body.images) : body.message;
470
- messages.push({ role: "user", content });
471
- }
472
- else {
473
- const norm = normalizeMessages(body);
474
- if (!norm.ok) {
475
- sendJson(res, 400, { error: "invalid_body", detail: norm.error });
476
- return;
477
- }
478
- messages = norm.messages;
479
- }
480
- const abort = new AbortController();
481
- req.on("close", () => abort.abort());
482
- // Use runOnce with code system prompt override
483
- const { runOnceWithSystem } = await import("./agent.js");
484
- if (stream) {
485
- res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive", ...c });
486
- res.flushHeaders?.();
487
- const callbacks = {
488
- onAssistantDisplayDelta(delta) { sseWrite(res, { type: "assistant", text: delta }); },
489
- onThinkingDelta(delta) { sseWrite(res, { type: "thinking", text: delta }); },
490
- onToolCall(name, input) { sseWrite(res, { type: "tool_call", name, input }); },
491
- onToolResult(name, output) { sseWrite(res, { type: "tool_result", name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) }); },
492
- onCompaction(line) { sseWrite(res, { type: "compaction", line }); },
493
- onStreamError(message) { sseWrite(res, { type: "error", message }); },
494
- onRunFinish(info) {
495
- let saved;
496
- if (sessionId && messages.length > 0) {
497
- try {
498
- saved = saveSession(messages, sessionId);
499
- }
500
- catch { }
501
- }
502
- sseWrite(res, { type: "done", step_count: info.stepCount, usage: info.usage, has_error: info.hasError, session_id: saved, messages, mode: "code", project });
503
- if (!res.writableEnded)
504
- res.end();
505
- },
506
- };
507
- try {
508
- await runOnceWithSystem(messages, codeSystemPrompt, modelId, abort.signal, callbacks);
509
- }
510
- catch (err) {
511
- if (!res.writableEnded) {
512
- sseWrite(res, { type: "fatal", message: err?.message ?? String(err) });
513
- res.end();
514
- }
515
- }
516
- }
517
- else {
518
- const toolCalls = [];
519
- const toolResults = [];
520
- const finishBox = { info: null };
521
- await runOnceWithSystem(messages, codeSystemPrompt, modelId, abort.signal, {
522
- onToolCall(name, input) { toolCalls.push({ name, input }); },
523
- onToolResult(name, output) { toolResults.push({ name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) }); },
524
- onRunFinish(info) { finishBox.info = info; },
525
- });
526
- const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
527
- let saved;
528
- if (sessionId && messages.length > 0) {
529
- try {
530
- saved = saveSession(messages, sessionId);
531
- }
532
- catch { }
533
- }
534
- const fi = finishBox.info;
535
- sendJson(res, 200, { mode: "code", project, messages, assistant: lastAssistant ?? null, tool_calls: toolCalls, tool_results: toolResults, session_id: saved, step_count: fi?.stepCount ?? 0, usage: fi?.usage ?? null, has_error: fi?.hasError ?? false });
536
- }
537
- return;
538
- }
539
- // ─── MCP management ────────────────────────────────────────────────
540
- if (req.method === "POST" && pathname === "/v1/paste") {
541
- if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
542
- sendJson(res, 415, { error: "unsupported_media_type" });
543
- return;
544
- }
545
- const raw = await readBody(req);
546
- const body = JSON.parse(raw);
547
- if (!body.image_base64) {
548
- sendJson(res, 400, { error: "missing_image_base64" });
549
- return;
550
- }
551
- const imageBuffer = Buffer.from(body.image_base64, "base64");
552
- const mimeType = body.mime_type ?? "image/png";
553
- const text = body.message ?? "What's in this image?";
554
- const modelId = body.model;
555
- const sessionId = body.session_id;
556
- const stream = body.stream === true;
557
- let messages = [];
558
- if (sessionId) {
559
- const session = loadSession(sessionId);
560
- if (session)
561
- messages = [...session.messages];
562
- }
563
- const content = [
564
- { type: "text", text },
565
- { type: "image", image: imageBuffer, mimeType },
566
- ];
567
- messages.push({ role: "user", content });
568
- const abort = new AbortController();
569
- req.on("close", () => abort.abort());
570
- if (stream) {
571
- res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive", ...c });
572
- res.flushHeaders?.();
573
- const callbacks = {
574
- onAssistantDisplayDelta(delta) { sseWrite(res, { type: "assistant", text: delta }); },
575
- onThinkingDelta(delta) { sseWrite(res, { type: "thinking", text: delta }); },
576
- onToolCall(name, input) { sseWrite(res, { type: "tool_call", name, input }); },
577
- onToolResult(name, output) { sseWrite(res, { type: "tool_result", name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) }); },
578
- onStreamError(message) { sseWrite(res, { type: "error", message }); },
579
- onRunFinish(info) {
580
- let saved;
581
- if (sessionId) {
582
- try {
583
- saved = saveSession(messages, sessionId);
584
- }
585
- catch { }
586
- }
587
- sseWrite(res, { type: "done", step_count: info.stepCount, usage: info.usage, session_id: saved });
588
- if (!res.writableEnded)
589
- res.end();
590
- },
591
- };
592
- await runOnce(messages, instructions, modelId, abort.signal, callbacks);
593
- }
594
- else {
595
- await runOnce(messages, instructions, modelId, abort.signal);
596
- const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
597
- let saved;
598
- if (sessionId) {
599
- try {
600
- saved = saveSession(messages, sessionId);
601
- }
602
- catch { }
603
- }
604
- sendJson(res, 200, { messages, assistant: lastAssistant ?? null, session_id: saved });
605
- }
606
- return;
607
- }
608
- // ─── MCP management (continued) ───────────────────────────────────
609
- if (req.method === "POST" && pathname === "/v1/mcp") {
610
- const raw = await readBody(req);
611
- const body = JSON.parse(raw);
612
- if (!body.name) {
613
- sendJson(res, 400, { error: "missing_name" });
614
- return;
615
- }
616
- const { loadMcpConfig, saveMcpConfig } = await import("./mcp.js");
617
- const config = loadMcpConfig();
618
- if (body.url) {
619
- config.mcpServers[body.name] = { url: body.url, token: body.token, enabled: body.enabled !== false };
620
- }
621
- else if (body.command && body.command.length > 0) {
622
- config.mcpServers[body.name] = { command: body.command, enabled: body.enabled !== false };
623
- }
624
- else {
625
- sendJson(res, 400, { error: "provide command[] or url" });
626
- return;
627
- }
628
- saveMcpConfig(config);
629
- sendJson(res, 201, { ok: true, name: body.name });
630
- return;
631
- }
632
- if (req.method === "DELETE" && pathname.startsWith("/v1/mcp/")) {
633
- const name = decodeURIComponent(pathname.slice("/v1/mcp/".length));
634
- if (!name) {
635
- sendJson(res, 400, { error: "missing_name" });
636
- return;
637
- }
638
- const { loadMcpConfig, saveMcpConfig } = await import("./mcp.js");
639
- const config = loadMcpConfig();
640
- if (!config.mcpServers[name]) {
641
- sendJson(res, 404, { error: "mcp_not_found", name });
642
- return;
643
- }
644
- delete config.mcpServers[name];
645
- saveMcpConfig(config);
646
- sendJson(res, 200, { ok: true, deleted: name });
647
- return;
648
- }
649
- // ─── Context / Compact ─────────────────────────────────────────────
650
- if (req.method === "GET" && pathname === "/v1/context") {
651
- const config = loadConfig();
652
- const ctxWindow = await getContextWindow(config.provider?.defaultModel);
653
- sendJson(res, 200, {
654
- context_window: ctxWindow,
655
- model: config.provider?.defaultModel ?? null,
656
- });
657
- return;
658
- }
659
- if (req.method === "POST" && pathname === "/v1/chat/compact") {
660
- const raw = await readBody(req);
661
- const body = JSON.parse(raw);
662
- if (!body.session_id) {
663
- sendJson(res, 400, { error: "missing_session_id" });
664
- return;
665
- }
666
- const session = loadSession(body.session_id);
667
- if (!session) {
668
- sendJson(res, 404, { error: "session_not_found" });
669
- return;
670
- }
671
- const model = resolveModel();
672
- const result = await compactMessages(session.messages, model, { autoContinue: false });
673
- if (result.compacted) {
674
- saveSession(result.messages, body.session_id);
675
- }
676
- sendJson(res, 200, { ok: true, compacted: result.compacted, message_count: result.messages.length });
677
- return;
678
112
  }
679
113
  sendJson(res, 404, { error: "not_found", path: pathname });
680
114
  }
@@ -689,11 +123,16 @@ export async function runServe(opts = {}) {
689
123
  resolve();
690
124
  });
691
125
  });
692
- const shutdown = async () => {
126
+ const closeServer = async () => {
693
127
  await shutdownMcp();
694
- server.close();
128
+ server.closeAllConnections?.();
129
+ await new Promise((resolve) => server.close(() => resolve()));
130
+ };
131
+ const shutdown = async () => {
132
+ await closeServer();
695
133
  process.exit(0);
696
134
  };
697
135
  process.on("SIGINT", shutdown);
698
136
  process.on("SIGTERM", shutdown);
137
+ return { close: closeServer };
699
138
  }