min-agent 0.2.0 → 0.3.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 (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
package/dist/serve.js CHANGED
@@ -3,35 +3,43 @@
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";
6
+ import { readFileSync } from "fs";
7
7
  import path from "path";
8
- import { initMcp, shutdownMcp, getMcpStatus } from "./mcp.js";
9
- import { discoverSkills, getSkills } from "./skills.js";
8
+ import { fileURLToPath } from "url";
9
+ import { timingSafeEqual } from "crypto";
10
+ import { initMcp, shutdownMcp, getMcpStatus, syncMcpConnections, loadMcpConfigEntries } from "./mcp.js";
11
+ import { discoverSkills, getAllSkills, getSkill, getStaleDisabledSkills, setSkillEnabled } from "./skills.js";
10
12
  import { loadInstructions } from "./instructions.js";
11
- import { loadConfig, fetchModels, isConfigured } from "./config.js";
13
+ import { loadConfig, fetchModels, isConfigured, getActiveProvider } from "./config.js";
12
14
  import { setAutoApprove } from "./confirm.js";
13
- import { runOnce, buildUserContent } from "./agent.js";
14
- import { loadSession, saveSession, listSessions, deleteSession } from "./sessions.js";
15
+ import { runOnce, runOnceWithSystem, buildUserContent } from "./agent.js";
16
+ import { loadSession, saveSession, listSessions, deleteSession, renameSession, sanitizeForStorage } from "./sessions.js";
15
17
  import { loadMemories, addMemory, deleteMemory, searchMemories } from "./memory.js";
16
- import { scanProject } from "./code-mode.js";
17
- import { compactMessages } from "./compaction.js";
18
+ import { scanProject, buildCodeSystemPrompt } from "./code-mode.js";
19
+ import { compactMessages, TokenTracker } from "./compaction.js";
18
20
  import { resolveModel } from "./provider.js";
19
21
  import { getContextWindow } from "./context-window.js";
22
+ import { getModelPrice, estimateCost } from "./pricing.js";
20
23
  const MAX_BODY_BYTES = 2 * 1024 * 1024;
24
+ const BODY_TIMEOUT_MS = 30_000;
21
25
  const MAX_TOOL_RESULT_SSE_CHARS = 48_000;
26
+ const MAX_CONCURRENT_CHATS = 8;
27
+ let activeChats = 0;
22
28
  function packageVersion() {
23
29
  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
- }
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";
29
33
  }
30
34
  catch { }
31
35
  return "0.0.0";
32
36
  }
37
+ function corsEnabled() {
38
+ const v = process.env.MIN_AGENT_SERVE_CORS;
39
+ return v === "1" || v === "true";
40
+ }
33
41
  function corsHeaders() {
34
- if (process.env.MIN_AGENT_SERVE_CORS === "1" || process.env.MIN_AGENT_SERVE_CORS === "true") {
42
+ if (corsEnabled()) {
35
43
  return {
36
44
  "Access-Control-Allow-Origin": "*",
37
45
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
@@ -40,6 +48,27 @@ function corsHeaders() {
40
48
  }
41
49
  return {};
42
50
  }
51
+ /**
52
+ * With CORS enabled and no auth token, arbitrary web pages could drive the
53
+ * agent. Allow only same-machine origins (localhost) in that configuration;
54
+ * a token bypasses the origin check entirely.
55
+ */
56
+ function originAllowed(req) {
57
+ if (process.env.MIN_AGENT_SERVE_TOKEN?.trim())
58
+ return true;
59
+ const origin = req.headers.origin?.trim();
60
+ if (!origin || origin === "null")
61
+ return true;
62
+ try {
63
+ const host = new URL(origin).hostname;
64
+ if (host === "localhost" || host === "127.0.0.1" || host === "[::1]")
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ return false;
71
+ }
43
72
  function authOk(req) {
44
73
  const token = process.env.MIN_AGENT_SERVE_TOKEN?.trim();
45
74
  if (!token)
@@ -47,7 +76,21 @@ function authOk(req) {
47
76
  const h = req.headers.authorization?.trim();
48
77
  if (!h?.startsWith("Bearer "))
49
78
  return false;
50
- return h.slice(7) === token;
79
+ const provided = Buffer.from(h.slice(7));
80
+ const expected = Buffer.from(token);
81
+ return provided.length === expected.length && timingSafeEqual(provided, expected);
82
+ }
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
+ };
51
94
  }
52
95
  function sendJson(res, status, body) {
53
96
  const headers = {
@@ -57,22 +100,73 @@ function sendJson(res, status, body) {
57
100
  res.writeHead(status, headers);
58
101
  res.end(JSON.stringify(body));
59
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
+ }
60
112
  function readBody(req) {
61
113
  return new Promise((resolve, reject) => {
62
114
  const chunks = [];
63
115
  let total = 0;
116
+ const timer = setTimeout(() => {
117
+ req.destroy();
118
+ reject(new Error("body_timeout"));
119
+ }, BODY_TIMEOUT_MS);
64
120
  req.on("data", (c) => {
65
121
  total += c.length;
66
122
  if (total > MAX_BODY_BYTES) {
123
+ clearTimeout(timer);
124
+ req.removeAllListeners("data");
125
+ req.pause();
67
126
  reject(new Error("body_too_large"));
68
127
  return;
69
128
  }
70
129
  chunks.push(c);
71
130
  });
72
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
73
- req.on("error", reject);
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
+ });
74
139
  });
75
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
+ }
76
170
  function sseWrite(res, obj) {
77
171
  try {
78
172
  if (res.writableEnded)
@@ -88,6 +182,79 @@ function truncateForJson(v, max) {
88
182
  return v.slice(0, max) + `\n… [truncated ${v.length - max} chars]`;
89
183
  return v;
90
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
+ }
91
258
  function normalizeMessages(body) {
92
259
  if (body.messages && Array.isArray(body.messages)) {
93
260
  if (body.messages.length === 0)
@@ -106,6 +273,12 @@ export async function runServe(opts = {}) {
106
273
  }
107
274
  const host = opts.host ?? process.env.MIN_AGENT_SERVE_HOST ?? "127.0.0.1";
108
275
  const port = opts.port ?? parseInt(process.env.MIN_AGENT_SERVE_PORT ?? "8787", 10);
276
+ const isLoopbackHost = host === "127.0.0.1" || host === "::1" || host === "localhost";
277
+ if (!isLoopbackHost && !process.env.MIN_AGENT_SERVE_TOKEN?.trim()) {
278
+ console.error("Refusing to serve on a non-loopback address without MIN_AGENT_SERVE_TOKEN. " +
279
+ "Set the token (Authorization: Bearer <token>) or bind to 127.0.0.1.");
280
+ process.exit(1);
281
+ }
109
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");
110
283
  setAutoApprove(true);
111
284
  console.error("\x1b[90m⟳ Initializing MCP, skills, instructions…\x1b[0m");
@@ -113,6 +286,132 @@ export async function runServe(opts = {}) {
113
286
  discoverSkills();
114
287
  let instructions = await loadInstructions();
115
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
+ }
116
415
  const server = createServer(async (req, res) => {
117
416
  const c = corsHeaders();
118
417
  if (req.method === "OPTIONS") {
@@ -124,6 +423,13 @@ export async function runServe(opts = {}) {
124
423
  sendJson(res, 401, { error: "unauthorized", detail: "Set Authorization: Bearer <MIN_AGENT_SERVE_TOKEN> when MIN_AGENT_SERVE_TOKEN is set" });
125
424
  return;
126
425
  }
426
+ if (corsEnabled() && !originAllowed(req)) {
427
+ sendJson(res, 403, {
428
+ error: "origin_not_allowed",
429
+ detail: "CORS is enabled without a token; only localhost origins are allowed. Set MIN_AGENT_SERVE_TOKEN or disable CORS.",
430
+ });
431
+ return;
432
+ }
127
433
  const url = new URL(req.url ?? "/", `http://${host}`);
128
434
  const pathname = url.pathname.replace(/\/$/, "") || "/";
129
435
  try {
@@ -141,15 +447,16 @@ export async function runServe(opts = {}) {
141
447
  }
142
448
  if (req.method === "GET" && pathname === "/v1/models") {
143
449
  const config = loadConfig();
144
- const base = config.provider?.baseURL;
145
- const key = config.provider?.apiKey;
450
+ const provider = getActiveProvider(config);
451
+ const base = provider?.baseURL;
452
+ const key = provider?.apiKey;
146
453
  if (!base || !key) {
147
454
  sendJson(res, 500, { error: "provider_not_configured" });
148
455
  return;
149
456
  }
150
457
  const models = await fetchModels(base, key);
151
458
  sendJson(res, 200, {
152
- default_model: config.provider?.defaultModel ?? null,
459
+ default_model: provider?.defaultModel ?? null,
153
460
  models,
154
461
  });
155
462
  return;
@@ -159,178 +466,8 @@ export async function runServe(opts = {}) {
159
466
  sendJson(res, 200, { ok: true, instructions_chars: instructions.join("\n").length });
160
467
  return;
161
468
  }
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
- }
296
- 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
- });
469
+ if (req.method === "POST" && (pathname === "/v1/chat" || pathname === "/v1/code")) {
470
+ await handleChatRequest(req, res, pathname === "/v1/code");
334
471
  return;
335
472
  }
336
473
  // ─── Sessions ──────────────────────────────────────────────────────
@@ -340,7 +477,7 @@ export async function runServe(opts = {}) {
340
477
  return;
341
478
  }
342
479
  if (req.method === "DELETE" && pathname.startsWith("/v1/sessions/")) {
343
- const id = pathname.slice("/v1/sessions/".length);
480
+ const id = decodePathSegment(pathname.slice("/v1/sessions/".length));
344
481
  if (!id) {
345
482
  sendJson(res, 400, { error: "missing_session_id" });
346
483
  return;
@@ -353,6 +490,43 @@ export async function runServe(opts = {}) {
353
490
  sendJson(res, 200, { ok: true, deleted: id });
354
491
  return;
355
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
+ }
356
530
  // ─── Memory ────────────────────────────────────────────────────────
357
531
  if (req.method === "GET" && pathname === "/v1/memory") {
358
532
  const memories = loadMemories();
@@ -360,8 +534,12 @@ export async function runServe(opts = {}) {
360
534
  return;
361
535
  }
362
536
  if (req.method === "POST" && pathname === "/v1/memory") {
363
- const raw = await readBody(req);
364
- const body = JSON.parse(raw);
537
+ const parsed = await readJsonBody(req);
538
+ if (!parsed.ok) {
539
+ sendJsonError(res, parsed);
540
+ return;
541
+ }
542
+ const body = parsed.value;
365
543
  if (!body.content) {
366
544
  sendJson(res, 400, { error: "missing_content" });
367
545
  return;
@@ -402,9 +580,67 @@ export async function runServe(opts = {}) {
402
580
  }
403
581
  // ─── Skills ────────────────────────────────────────────────────────
404
582
  if (req.method === "GET" && pathname === "/v1/skills") {
405
- const skills = getSkills();
583
+ const all = getAllSkills();
406
584
  sendJson(res, 200, {
407
- skills: skills.map((s) => ({ name: s.name, description: s.description, location: s.location })),
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 } : {}),
408
644
  });
409
645
  return;
410
646
  }
@@ -426,124 +662,22 @@ export async function runServe(opts = {}) {
426
662
  return;
427
663
  }
428
664
  // ─── Code mode chat ────────────────────────────────────────────────
429
- if (req.method === "POST" && pathname === "/v1/code") {
665
+ // ─── MCP management ────────────────────────────────────────────────
666
+ if (req.method === "POST" && pathname === "/v1/paste") {
430
667
  if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
431
668
  sendJson(res, 415, { error: "unsupported_media_type" });
432
669
  return;
433
670
  }
434
- let raw;
435
- try {
436
- raw = await readBody(req);
437
- }
438
- catch {
439
- sendJson(res, 413, { error: "payload_too_large" });
671
+ if (activeChats >= MAX_CONCURRENT_CHATS) {
672
+ sendJson(res, 429, { error: "too_many_concurrent_requests", detail: `Max ${MAX_CONCURRENT_CHATS} concurrent chats` });
440
673
  return;
441
674
  }
442
- let body;
443
- try {
444
- body = JSON.parse(raw);
445
- }
446
- catch {
447
- sendJson(res, 400, { error: "invalid_json" });
675
+ const parsed = await readJsonBody(req);
676
+ if (!parsed.ok) {
677
+ sendJsonError(res, parsed);
448
678
  return;
449
679
  }
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);
680
+ const body = parsed.value;
547
681
  if (!body.image_base64) {
548
682
  sendJson(res, 400, { error: "missing_image_base64" });
549
683
  return;
@@ -554,111 +688,229 @@ export async function runServe(opts = {}) {
554
688
  const modelId = body.model;
555
689
  const sessionId = body.session_id;
556
690
  const stream = body.stream === true;
691
+ const codeMode = body.code === true;
557
692
  let messages = [];
558
693
  if (sessionId) {
559
694
  const session = loadSession(sessionId);
560
- if (session)
561
- messages = [...session.messages];
695
+ if (!session) {
696
+ sendJson(res, 404, { error: "session_not_found", session_id: sessionId });
697
+ return;
698
+ }
699
+ messages = [...session.messages];
562
700
  }
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
- },
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,
591
716
  };
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) {
717
+ if (stream) {
718
+ res.writeHead(200, sseHeaders(c));
719
+ res.flushHeaders?.();
720
+ const callbacks = createSseCallbacks(res, messages, { sessionId });
599
721
  try {
600
- saved = saveSession(messages, sessionId);
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 { }
601
756
  }
602
- catch { }
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
+ });
603
771
  }
604
- sendJson(res, 200, { messages, assistant: lastAssistant ?? null, session_id: saved });
772
+ }
773
+ finally {
774
+ activeChats--;
605
775
  }
606
776
  return;
607
777
  }
608
778
  // ─── MCP management (continued) ───────────────────────────────────
609
779
  if (req.method === "POST" && pathname === "/v1/mcp") {
610
- const raw = await readBody(req);
611
- const body = JSON.parse(raw);
780
+ const parsed = await readJsonBody(req);
781
+ if (!parsed.ok) {
782
+ sendJsonError(res, parsed);
783
+ return;
784
+ }
785
+ const body = parsed.value;
612
786
  if (!body.name) {
613
787
  sendJson(res, 400, { error: "missing_name" });
614
788
  return;
615
789
  }
616
- const { loadMcpConfig, saveMcpConfig } = await import("./mcp.js");
617
- const config = loadMcpConfig();
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
+ };
618
806
  if (body.url) {
619
- config.mcpServers[body.name] = { url: body.url, token: body.token, enabled: body.enabled !== false };
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 };
620
811
  }
621
- else if (body.command && body.command.length > 0) {
622
- config.mcpServers[body.name] = { command: body.command, enabled: body.enabled !== false };
812
+ else if (Array.isArray(body.command) && body.command.length > 0) {
813
+ config.mcpServers[body.name] = { command: body.command, ...common };
623
814
  }
624
815
  else {
625
- sendJson(res, 400, { error: "provide command[] or url" });
816
+ sendJson(res, 400, { error: "provide command or url" });
626
817
  return;
627
818
  }
628
- saveMcpConfig(config);
629
- sendJson(res, 201, { ok: true, name: body.name });
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
+ });
630
830
  return;
631
831
  }
632
832
  if (req.method === "DELETE" && pathname.startsWith("/v1/mcp/")) {
633
- const name = decodeURIComponent(pathname.slice("/v1/mcp/".length));
833
+ const name = decodePathSegment(pathname.slice("/v1/mcp/".length));
634
834
  if (!name) {
635
835
  sendJson(res, 400, { error: "missing_name" });
636
836
  return;
637
837
  }
638
- const { loadMcpConfig, saveMcpConfig } = await import("./mcp.js");
639
- const config = loadMcpConfig();
838
+ const scope = url.searchParams.get("project") === "1" ? "project" : "global";
839
+ const { loadMcpConfigFile, saveMcpConfig } = await import("./mcp.js");
840
+ const config = loadMcpConfigFile(scope);
640
841
  if (!config.mcpServers[name]) {
641
- sendJson(res, 404, { error: "mcp_not_found", name });
842
+ sendJson(res, 404, { error: "mcp_not_found", name, scope, available: Object.keys(config.mcpServers) });
642
843
  return;
643
844
  }
644
845
  delete config.mcpServers[name];
645
- saveMcpConfig(config);
646
- sendJson(res, 200, { ok: true, deleted: 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 });
647
877
  return;
648
878
  }
649
879
  // ─── Context / Compact ─────────────────────────────────────────────
650
880
  if (req.method === "GET" && pathname === "/v1/context") {
651
881
  const config = loadConfig();
652
- const ctxWindow = await getContextWindow(config.provider?.defaultModel);
882
+ const provider = getActiveProvider(config);
883
+ const ctxWindow = await getContextWindow(provider?.defaultModel);
653
884
  sendJson(res, 200, {
654
885
  context_window: ctxWindow,
655
- model: config.provider?.defaultModel ?? null,
886
+ model: provider?.defaultModel ?? null,
656
887
  });
657
888
  return;
658
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
+ }
659
907
  if (req.method === "POST" && pathname === "/v1/chat/compact") {
660
- const raw = await readBody(req);
661
- const body = JSON.parse(raw);
908
+ const parsed = await readJsonBody(req);
909
+ if (!parsed.ok) {
910
+ sendJsonError(res, parsed);
911
+ return;
912
+ }
913
+ const body = parsed.value;
662
914
  if (!body.session_id) {
663
915
  sendJson(res, 400, { error: "missing_session_id" });
664
916
  return;
@@ -676,6 +928,38 @@ export async function runServe(opts = {}) {
676
928
  sendJson(res, 200, { ok: true, compacted: result.compacted, message_count: result.messages.length });
677
929
  return;
678
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" });
956
+ 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
+ }
679
963
  sendJson(res, 404, { error: "not_found", path: pathname });
680
964
  }
681
965
  catch (err) {
@@ -689,11 +973,16 @@ export async function runServe(opts = {}) {
689
973
  resolve();
690
974
  });
691
975
  });
692
- const shutdown = async () => {
976
+ const closeServer = async () => {
693
977
  await shutdownMcp();
694
- server.close();
978
+ server.closeAllConnections?.();
979
+ await new Promise((resolve) => server.close(() => resolve()));
980
+ };
981
+ const shutdown = async () => {
982
+ await closeServer();
695
983
  process.exit(0);
696
984
  };
697
985
  process.on("SIGINT", shutdown);
698
986
  process.on("SIGTERM", shutdown);
987
+ return { close: closeServer };
699
988
  }