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.
- package/README.md +146 -18
- package/dist/agent.js +293 -408
- package/dist/assistant-stream.js +11 -7
- package/dist/cli.js +403 -140
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +3 -3
- package/dist/compaction.js +182 -81
- package/dist/config.js +186 -35
- package/dist/confirm.js +55 -6
- package/dist/context-window.js +67 -54
- package/dist/doom-loop.js +19 -12
- package/dist/http.js +119 -0
- package/dist/instructions.js +51 -33
- package/dist/logger.js +66 -0
- package/dist/markdown.js +3 -44
- package/dist/mcp.js +547 -100
- package/dist/memory.js +48 -6
- package/dist/output.js +36 -27
- package/dist/paste-handler.js +3 -3
- package/dist/plugins.js +33 -6
- package/dist/pricing.js +119 -0
- package/dist/provider.js +17 -15
- package/dist/serve.js +658 -369
- package/dist/sessions.js +151 -13
- package/dist/skills.js +466 -76
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +2 -1
- package/dist/tool-display.js +173 -0
- package/dist/tool-output.js +54 -45
- package/dist/tools/apply_patch.js +191 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +147 -70
- package/dist/tools/code_search.js +6 -5
- package/dist/tools/edit.js +23 -7
- package/dist/tools/explore.js +80 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +146 -14
- package/dist/tools/index.js +7 -7
- package/dist/tools/question.js +4 -22
- package/dist/tools/read.js +71 -11
- package/dist/tools/task.js +33 -20
- package/dist/tools/todo.js +83 -73
- package/dist/tools/web_fetch.js +150 -46
- package/dist/tools/web_search.js +706 -28
- package/dist/tools/write.js +13 -7
- package/dist/tui/App.js +40 -6
- package/dist/tui/ConfirmBar.js +24 -3
- package/dist/tui/InputBar.js +390 -45
- package/dist/tui/MessageList.js +533 -20
- package/dist/tui/ModelPicker.js +108 -0
- package/dist/tui/QuestionBar.js +104 -0
- package/dist/tui/StatusBar.js +19 -11
- package/dist/tui/agent-runner.js +103 -0
- package/dist/tui/caret-pos.js +134 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +44 -0
- package/dist/tui/index.js +153 -24
- package/dist/tui/input-history.js +44 -0
- package/dist/tui/layout.js +17 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/selection.js +134 -0
- package/dist/tui/slash-commands.js +90 -0
- package/dist/tui/slash-handler.js +370 -0
- package/dist/tui/text-width.js +91 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +27 -0
- package/dist/tui-chat.js +111 -331
- package/dist/updater.js +57 -0
- package/docs/API.md +160 -14
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- 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
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
7
|
import path from "path";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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.
|
|
25
|
-
|
|
26
|
-
|
|
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 (
|
|
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
|
-
|
|
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", () =>
|
|
73
|
-
|
|
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
|
|
145
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
|
364
|
-
|
|
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
|
|
583
|
+
const all = getAllSkills();
|
|
406
584
|
sendJson(res, 200, {
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
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
|
-
|
|
695
|
+
if (!session) {
|
|
696
|
+
sendJson(res, 404, { error: "session_not_found", session_id: sessionId });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
messages = [...session.messages];
|
|
562
700
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
res.
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
611
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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,
|
|
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,
|
|
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
|
|
816
|
+
sendJson(res, 400, { error: "provide command or url" });
|
|
626
817
|
return;
|
|
627
818
|
}
|
|
628
|
-
saveMcpConfig(config);
|
|
629
|
-
|
|
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 =
|
|
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
|
|
639
|
-
const
|
|
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
|
-
|
|
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
|
|
882
|
+
const provider = getActiveProvider(config);
|
|
883
|
+
const ctxWindow = await getContextWindow(provider?.defaultModel);
|
|
653
884
|
sendJson(res, 200, {
|
|
654
885
|
context_window: ctxWindow,
|
|
655
|
-
model:
|
|
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
|
|
661
|
-
|
|
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
|
|
976
|
+
const closeServer = async () => {
|
|
693
977
|
await shutdownMcp();
|
|
694
|
-
server.
|
|
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
|
}
|