atom-agent 1.3.0 → 1.5.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/CHANGELOG.md +62 -0
- package/README.md +220 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +127 -14
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +211 -430
- package/dist/agent/tool-pipeline.js +398 -0
- package/dist/agent/turn-events.js +12 -0
- package/dist/cli.js +57 -8
- package/dist/compact.js +72 -8
- package/dist/config.js +19 -0
- package/dist/context-manager.js +6 -2
- package/dist/extensions.js +6 -0
- package/dist/file-diffs.js +108 -0
- package/dist/kilo.js +1 -1
- package/dist/local-discovery.js +2 -2
- package/dist/media.js +276 -0
- package/dist/overflow.js +140 -0
- package/dist/policy.js +8 -0
- package/dist/scheduler.js +38 -9
- package/dist/session-revert.js +125 -0
- package/dist/sessions.js +101 -0
- package/dist/snapshots.js +69 -0
- package/dist/system.js +2 -89
- package/dist/telemetry.js +26 -1
- package/dist/todos.js +241 -0
- package/dist/tools/filesystem.js +102 -22
- package/dist/tools/registry.js +184 -45
- package/dist/tools/ripgrep.js +7 -6
- package/dist/tools/search.js +172 -17
- package/dist/tools/shared.js +6 -0
- package/dist/tools.js +7 -39
- package/dist/ui/diff-panel.js +5 -5
- package/dist/ui/diff-view.js +16 -7
- package/dist/ui/diff.js +73 -51
- package/dist/ui/errors.js +20 -6
- package/dist/ui/input.js +24 -20
- package/dist/ui/live-tail.js +36 -1
- package/dist/ui/markdown.js +9 -4
- package/dist/ui/modals.js +6 -4
- package/dist/ui/paint-scheduler.js +120 -0
- package/dist/ui/palette.js +4 -2
- package/dist/ui/pickers.js +4 -1
- package/dist/ui/side-by-side.js +88 -27
- package/dist/ui/status-bar.js +63 -8
- package/dist/ui/stream-store.js +7 -0
- package/dist/ui/theme.js +23 -1
- package/dist/ui/todo-panel.js +5 -2
- package/dist/ui/tool-inspector.js +33 -4
- package/dist/ui/transcript.js +9 -6
- package/dist/web/events.js +93 -0
- package/dist/web/runtime.js +790 -0
- package/dist/web/server.js +570 -0
- package/dist/web/ui/app.js +1925 -0
- package/dist/web/ui/index.html +135 -0
- package/dist/web/ui/styles.css +515 -0
- package/dist/zen.js +115 -4
- package/documentation/cli.md +5 -5
- package/documentation/configuration.md +11 -6
- package/documentation/development.md +4 -3
- package/documentation/extensions.md +1 -1
- package/documentation/goals.md +1 -1
- package/documentation/index.md +4 -4
- package/documentation/providers.md +2 -3
- package/documentation/skills.md +3 -3
- package/documentation/tools.md +8 -3
- package/documentation/troubleshooting.md +1 -1
- package/examples/extensions/01-audit-gate.js +2 -2
- package/examples/extensions/02-notes-tool.js +2 -2
- package/examples/extensions/03-custom-command.js +2 -2
- package/package.json +3 -2
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
// ATOM WebUI server: a local frontend for the existing ATOM runtime.
|
|
2
|
+
//
|
|
3
|
+
// Design (same posture as src/telemetry-server.ts):
|
|
4
|
+
// - `node:http` + `node:fs` builtins only. No new dependencies.
|
|
5
|
+
// - Loopback-only by default (`127.0.0.1`); never binds a LAN interface
|
|
6
|
+
// unless explicitly asked (`host` option).
|
|
7
|
+
// - Serves the dependency-free frontend (src/web/ui, copied to dist/web/ui
|
|
8
|
+
// by scripts/copy-web-ui.mjs) plus a JSON API over the WebRuntime.
|
|
9
|
+
// - Every handler is guarded — a bad request or a failing store yields a
|
|
10
|
+
// status code, never a crash. Responses carry `Cache-Control: no-store`
|
|
11
|
+
// on API routes (the static UI is immutable per version and may cache).
|
|
12
|
+
// - Realtime is SSE per session (GET /api/sessions/:id/events): the runtime
|
|
13
|
+
// fans out turn events; the server frames them (see src/web/events.ts),
|
|
14
|
+
// replays missed events via Last-Event-ID, and heartbeats idle streams.
|
|
15
|
+
//
|
|
16
|
+
// Routes:
|
|
17
|
+
// - GET /, /app.js, /styles.css → frontend
|
|
18
|
+
// - GET /api/health → {ok, service, version, sessions}
|
|
19
|
+
// - GET /api/providers → provider catalog (hasKey booleans only —
|
|
20
|
+
// keys/secrets never cross the API)
|
|
21
|
+
// - GET /api/tools → tool catalog (name, description, needsApproval)
|
|
22
|
+
// - GET /api/sessions → session summaries (most recent first)
|
|
23
|
+
// - POST /api/sessions → create ({title?, provider?, model?, effort?, mode?})
|
|
24
|
+
// - GET /api/sessions/:id → full record + busy + pending approval/question
|
|
25
|
+
// - PATCH /api/sessions/:id → settings ({provider?, model?, effort?, mode?,
|
|
26
|
+
// title?}; 409 while busy)
|
|
27
|
+
// - POST /api/sessions/:id/messages → start a turn ({content, ...overrides});
|
|
28
|
+
// 202 accepted; 400 validation/start failure; 404 unknown; 409 busy
|
|
29
|
+
// - GET /api/sessions/:id/events → SSE stream (replay + heartbeat)
|
|
30
|
+
// - POST /api/sessions/:id/cancel → {cancelled}
|
|
31
|
+
// - POST /api/sessions/:id/approve → {resolved} ({id, decision})
|
|
32
|
+
// - POST /api/sessions/:id/answer → {resolved} ({id, answer})
|
|
33
|
+
// - anything else → 404; wrong method on a known route → 405.
|
|
34
|
+
import { createServer } from "node:http";
|
|
35
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
36
|
+
import * as path from "node:path";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import { formatSSE, sseHeartbeat } from "./events.js";
|
|
39
|
+
import { validateSendBody, WebRuntime } from "./runtime.js";
|
|
40
|
+
export const WEB_SERVER_DEFAULT_HOST = "127.0.0.1";
|
|
41
|
+
export const WEB_SERVER_DEFAULT_PORT = 0;
|
|
42
|
+
export const WEB_SERVER_PORT_ENV = "ATOM_WEB_PORT";
|
|
43
|
+
export const WEB_SERVICE = "atom-web";
|
|
44
|
+
export const WEB_VERSION = 1;
|
|
45
|
+
const HEARTBEAT_MS = 15_000;
|
|
46
|
+
const BODY_CAP_BYTES = 1_000_000;
|
|
47
|
+
export function parseWebPort(value) {
|
|
48
|
+
if (value === undefined || value === null)
|
|
49
|
+
return null;
|
|
50
|
+
const text = String(value).trim();
|
|
51
|
+
if (!/^\d+$/.test(text))
|
|
52
|
+
return null;
|
|
53
|
+
const n = Number(text);
|
|
54
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > 65535)
|
|
55
|
+
return null;
|
|
56
|
+
return n;
|
|
57
|
+
}
|
|
58
|
+
export function resolveWebPort(env = process.env, cliValue) {
|
|
59
|
+
return (parseWebPort(cliValue) ??
|
|
60
|
+
parseWebPort(env[WEB_SERVER_PORT_ENV]) ??
|
|
61
|
+
WEB_SERVER_DEFAULT_PORT);
|
|
62
|
+
}
|
|
63
|
+
// UI directory: the compiled server lives in dist/web/, the UI beside it at
|
|
64
|
+
// dist/web/ui/ (see scripts/copy-web-ui.mjs); under tsx/vitest it is
|
|
65
|
+
// src/web/ui/. Probe the adjacent dir first, then the caller's checkout.
|
|
66
|
+
function resolveUiDir() {
|
|
67
|
+
const candidates = [];
|
|
68
|
+
try {
|
|
69
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
candidates.push(path.join(here, "ui"));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// import.meta.url unavailable — fall through to the checkout probe
|
|
74
|
+
}
|
|
75
|
+
candidates.push(path.join(process.cwd(), "src", "web", "ui"));
|
|
76
|
+
for (const dir of candidates) {
|
|
77
|
+
try {
|
|
78
|
+
if (existsSync(path.join(dir, "index.html")))
|
|
79
|
+
return dir;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// probe next
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const UI_CONTENT_TYPES = {
|
|
88
|
+
".html": "text/html; charset=utf-8",
|
|
89
|
+
".js": "text/javascript; charset=utf-8",
|
|
90
|
+
".css": "text/css; charset=utf-8",
|
|
91
|
+
};
|
|
92
|
+
function sendJson(res, status, body) {
|
|
93
|
+
try {
|
|
94
|
+
const text = JSON.stringify(body);
|
|
95
|
+
res.writeHead(status, {
|
|
96
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
97
|
+
"Cache-Control": "no-store",
|
|
98
|
+
"X-Content-Type-Options": "nosniff",
|
|
99
|
+
"Content-Length": Buffer.byteLength(text),
|
|
100
|
+
});
|
|
101
|
+
res.end(text);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
try {
|
|
105
|
+
res.end();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// never throw out of a handler
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function readJsonBody(req) {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let size = 0;
|
|
116
|
+
let failed = false;
|
|
117
|
+
req.on("data", (chunk) => {
|
|
118
|
+
if (failed)
|
|
119
|
+
return;
|
|
120
|
+
size += chunk.length;
|
|
121
|
+
if (size > BODY_CAP_BYTES) {
|
|
122
|
+
failed = true;
|
|
123
|
+
resolve({ ok: false, error: "request body too large" });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
chunks.push(chunk);
|
|
127
|
+
});
|
|
128
|
+
req.on("end", () => {
|
|
129
|
+
if (failed)
|
|
130
|
+
return;
|
|
131
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
132
|
+
if (!text) {
|
|
133
|
+
resolve({ ok: true, body: {} });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
resolve({ ok: true, body: JSON.parse(text) });
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
resolve({ ok: false, error: "body must be valid JSON" });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
req.on("error", () => resolve({ ok: false, error: "failed to read request body" }));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Summaries stay light: histories ride the item route only, so listing
|
|
147
|
+
// hundreds of sessions never dumps megabytes of transcripts.
|
|
148
|
+
function sessionSummary(s) {
|
|
149
|
+
return {
|
|
150
|
+
id: s.id,
|
|
151
|
+
title: s.title,
|
|
152
|
+
createdAt: s.createdAt,
|
|
153
|
+
updatedAt: s.updatedAt,
|
|
154
|
+
cwd: s.cwd,
|
|
155
|
+
provider: s.provider,
|
|
156
|
+
model: s.model,
|
|
157
|
+
effort: s.effort,
|
|
158
|
+
mode: s.mode,
|
|
159
|
+
turnCount: s.turns.length,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/;
|
|
163
|
+
function isSessionId(id) {
|
|
164
|
+
return id.length > 0 && id.length <= 128 && SESSION_ID_RE.test(id);
|
|
165
|
+
}
|
|
166
|
+
export function startWebServer(opts = {}) {
|
|
167
|
+
const host = typeof opts.host === "string" && opts.host.length > 0 ? opts.host : WEB_SERVER_DEFAULT_HOST;
|
|
168
|
+
const port = typeof opts.port === "number" && Number.isSafeInteger(opts.port) && opts.port >= 0 && opts.port <= 65535
|
|
169
|
+
? opts.port
|
|
170
|
+
: WEB_SERVER_DEFAULT_PORT;
|
|
171
|
+
const runtime = opts.runtime ?? new WebRuntime(opts.home);
|
|
172
|
+
const uiDir = resolveUiDir();
|
|
173
|
+
function serveUiFile(res, name) {
|
|
174
|
+
try {
|
|
175
|
+
if (!uiDir) {
|
|
176
|
+
sendJson(res, 500, { error: "frontend assets unavailable" });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const file = name === "/" ? "index.html" : name.slice(1);
|
|
180
|
+
if (file.includes("..") || file.includes("\\") || !/^(index\.html|app\.js|styles\.css)$/.test(file)) {
|
|
181
|
+
sendJson(res, 404, { error: "not found" });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const full = path.join(uiDir, file);
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = readFileSync(full);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
sendJson(res, 404, { error: "not found" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const ext = path.extname(file);
|
|
194
|
+
res.writeHead(200, {
|
|
195
|
+
"Content-Type": UI_CONTENT_TYPES[ext] ?? "application/octet-stream",
|
|
196
|
+
"X-Content-Type-Options": "nosniff",
|
|
197
|
+
"Content-Length": raw.length,
|
|
198
|
+
});
|
|
199
|
+
res.end(raw);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
sendJson(res, 500, { error: "internal error" });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function handle(req, res) {
|
|
206
|
+
try {
|
|
207
|
+
const method = req.method ?? "GET";
|
|
208
|
+
let pathname = "/";
|
|
209
|
+
try {
|
|
210
|
+
pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
sendJson(res, 400, { error: "bad request" });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Static frontend.
|
|
217
|
+
if ((pathname === "/" || pathname === "/app.js" || pathname === "/styles.css") && method === "GET") {
|
|
218
|
+
serveUiFile(res, pathname);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Session SSE stream (headers + replay + heartbeat + cleanup).
|
|
222
|
+
const eventsMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
|
|
223
|
+
if (eventsMatch) {
|
|
224
|
+
const id = decodeURIComponent(eventsMatch[1] ?? "");
|
|
225
|
+
if (!isSessionId(id) || !runtime.getSessionRecord(id)) {
|
|
226
|
+
sendJson(res, 404, { error: "session not found" });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (method !== "GET") {
|
|
230
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
let lastEventId;
|
|
234
|
+
try {
|
|
235
|
+
const rawHeader = req.headers["last-event-id"];
|
|
236
|
+
const raw = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
237
|
+
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
238
|
+
const n = Number(raw.trim());
|
|
239
|
+
if (Number.isFinite(n))
|
|
240
|
+
lastEventId = Math.floor(n);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
lastEventId = undefined;
|
|
245
|
+
}
|
|
246
|
+
res.writeHead(200, {
|
|
247
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
248
|
+
"Cache-Control": "no-store",
|
|
249
|
+
"X-Content-Type-Options": "nosniff",
|
|
250
|
+
Connection: "keep-alive",
|
|
251
|
+
});
|
|
252
|
+
const write = (frame) => {
|
|
253
|
+
try {
|
|
254
|
+
return res.write(frame);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
const onEvent = (event) => {
|
|
261
|
+
write(formatSSE(event));
|
|
262
|
+
};
|
|
263
|
+
let unsubscribe = null;
|
|
264
|
+
try {
|
|
265
|
+
unsubscribe = runtime.subscribe(id, onEvent, lastEventId);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
sendJson(res, 404, { error: "session not found" });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const heartbeat = setInterval(() => {
|
|
272
|
+
if (!write(sseHeartbeat())) {
|
|
273
|
+
try {
|
|
274
|
+
clearInterval(heartbeat);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// ignore
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}, HEARTBEAT_MS);
|
|
281
|
+
try {
|
|
282
|
+
heartbeat.unref?.();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// ignore
|
|
286
|
+
}
|
|
287
|
+
const cleanup = () => {
|
|
288
|
+
try {
|
|
289
|
+
clearInterval(heartbeat);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// ignore
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
unsubscribe?.();
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// ignore
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
req.on("close", cleanup);
|
|
302
|
+
res.on("close", cleanup);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// Session item routes.
|
|
306
|
+
const itemMatch = pathname.match(/^\/api\/sessions\/([^/]+)(\/[^/]+)?$/);
|
|
307
|
+
if (itemMatch) {
|
|
308
|
+
const id = decodeURIComponent(itemMatch[1] ?? "");
|
|
309
|
+
const suffix = itemMatch[2] ?? "";
|
|
310
|
+
if (!isSessionId(id)) {
|
|
311
|
+
sendJson(res, 404, { error: "session not found" });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// GET /api/sessions/:id (live view: persisted identity + in-memory
|
|
315
|
+
// turn state, so a fresh client sees the running turn too)
|
|
316
|
+
if (suffix === "" && method === "GET") {
|
|
317
|
+
const live = runtime.getLiveSession(id);
|
|
318
|
+
if (!live) {
|
|
319
|
+
sendJson(res, 404, { error: "session not found" });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
sendJson(res, 200, live);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// PATCH /api/sessions/:id
|
|
326
|
+
if (suffix === "" && method === "PATCH") {
|
|
327
|
+
if (runtime.isBusy(id)) {
|
|
328
|
+
sendJson(res, 409, { error: "session is busy (another turn is running)" });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const parsed = await readJsonBody(req);
|
|
332
|
+
if (!parsed.ok) {
|
|
333
|
+
sendJson(res, 400, { error: parsed.error });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const updated = runtime.updateWebSession(id, (parsed.body ?? {}));
|
|
337
|
+
if (!updated) {
|
|
338
|
+
sendJson(res, 404, { error: "session not found" });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
sendJson(res, 200, updated);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// POST /api/sessions/:id/messages
|
|
345
|
+
if (suffix === "/messages" && method === "POST") {
|
|
346
|
+
const record = runtime.getSessionRecord(id);
|
|
347
|
+
if (!record) {
|
|
348
|
+
sendJson(res, 404, { error: "session not found" });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (runtime.isBusy(id)) {
|
|
352
|
+
sendJson(res, 409, { error: "session is busy (another turn is running)" });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const parsed = await readJsonBody(req);
|
|
356
|
+
if (!parsed.ok) {
|
|
357
|
+
sendJson(res, 400, { error: parsed.error });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const problem = validateSendBody(parsed.body);
|
|
361
|
+
if (problem) {
|
|
362
|
+
sendJson(res, 400, { error: problem });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const body = parsed.body;
|
|
366
|
+
const turnOpts = {
|
|
367
|
+
provider: body["provider"],
|
|
368
|
+
model: typeof body["model"] === "string" ? body["model"] : undefined,
|
|
369
|
+
effort: body["effort"],
|
|
370
|
+
mode: body["mode"],
|
|
371
|
+
};
|
|
372
|
+
try {
|
|
373
|
+
// Synchronous start-gate (validateTurnStart is await-free, so it
|
|
374
|
+
// throws instead of rejecting — the only honest 400/409 source).
|
|
375
|
+
runtime.validateTurnStart(id, body.content, turnOpts);
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
// Start failures only (unknown session, busy, validation, missing
|
|
379
|
+
// key): the turn never started, so 400/409 is honest.
|
|
380
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
381
|
+
const status = /busy|waiting/i.test(message) ? 409 : 400;
|
|
382
|
+
sendJson(res, status, { error: message });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Fire-and-forget: progress arrives as SSE events. Awaiting here
|
|
386
|
+
// would hold the HTTP request for the whole turn.
|
|
387
|
+
void runtime.sendMessage(id, body.content, turnOpts).catch(() => {
|
|
388
|
+
// In-turn failures surface as SSE error/cancelled events —
|
|
389
|
+
// never as unhandled rejections.
|
|
390
|
+
});
|
|
391
|
+
sendJson(res, 202, { accepted: true });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// POST /api/sessions/:id/cancel
|
|
395
|
+
if (suffix === "/cancel" && method === "POST") {
|
|
396
|
+
if (!runtime.getSessionRecord(id)) {
|
|
397
|
+
sendJson(res, 404, { error: "session not found" });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
sendJson(res, 200, { cancelled: runtime.cancelTurn(id) });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// POST /api/sessions/:id/approve
|
|
404
|
+
if (suffix === "/approve" && method === "POST") {
|
|
405
|
+
if (!runtime.getSessionRecord(id)) {
|
|
406
|
+
sendJson(res, 404, { error: "session not found" });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const parsed = await readJsonBody(req);
|
|
410
|
+
if (!parsed.ok) {
|
|
411
|
+
sendJson(res, 400, { error: parsed.error });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const b = (parsed.body ?? {});
|
|
415
|
+
if (typeof b["id"] !== "string" || (b["decision"] !== "once" && b["decision"] !== "always" && b["decision"] !== "no")) {
|
|
416
|
+
sendJson(res, 400, { error: 'body must be {id: string, decision: "once"|"always"|"no"}' });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
sendJson(res, 200, {
|
|
420
|
+
resolved: runtime.resolveApproval(id, b["id"], b["decision"]),
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// POST /api/sessions/:id/answer
|
|
425
|
+
if (suffix === "/answer" && method === "POST") {
|
|
426
|
+
if (!runtime.getSessionRecord(id)) {
|
|
427
|
+
sendJson(res, 404, { error: "session not found" });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const parsed = await readJsonBody(req);
|
|
431
|
+
if (!parsed.ok) {
|
|
432
|
+
sendJson(res, 400, { error: parsed.error });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const b = (parsed.body ?? {});
|
|
436
|
+
if (typeof b["id"] !== "string" || typeof b["answer"] !== "string" || b["answer"].length === 0) {
|
|
437
|
+
sendJson(res, 400, { error: "body must be {id: string, answer: non-empty string}" });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
sendJson(res, 200, {
|
|
441
|
+
resolved: runtime.answerQuestion(id, b["id"], b["answer"]),
|
|
442
|
+
});
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
sendJson(res, method === "GET" || method === "PATCH" || method === "POST" ? 404 : 405, {
|
|
446
|
+
error: "not found",
|
|
447
|
+
});
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// Collection + catalog routes.
|
|
451
|
+
if (pathname === "/api/sessions" && method === "GET") {
|
|
452
|
+
sendJson(res, 200, runtime.listSessions().map(sessionSummary));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (pathname === "/api/sessions" && method === "POST") {
|
|
456
|
+
const parsed = await readJsonBody(req);
|
|
457
|
+
if (!parsed.ok) {
|
|
458
|
+
sendJson(res, 400, { error: parsed.error });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const b = (parsed.body ?? {});
|
|
462
|
+
try {
|
|
463
|
+
const created = runtime.createWebSession({
|
|
464
|
+
title: typeof b["title"] === "string" ? b["title"] : undefined,
|
|
465
|
+
provider: b["provider"],
|
|
466
|
+
model: typeof b["model"] === "string" ? b["model"] : undefined,
|
|
467
|
+
effort: b["effort"],
|
|
468
|
+
mode: b["mode"],
|
|
469
|
+
});
|
|
470
|
+
sendJson(res, 201, created);
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (pathname === "/api/sessions" && method !== "GET" && method !== "POST") {
|
|
478
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (pathname === "/api/providers" && method === "GET") {
|
|
482
|
+
sendJson(res, 200, runtime.listProviders());
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (pathname === "/api/tools" && method === "GET") {
|
|
486
|
+
sendJson(res, 200, runtime.listTools());
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (pathname === "/api/health" && method === "GET") {
|
|
490
|
+
sendJson(res, 200, {
|
|
491
|
+
ok: true,
|
|
492
|
+
service: WEB_SERVICE,
|
|
493
|
+
version: WEB_VERSION,
|
|
494
|
+
sessions: runtime.listSessions().length,
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (pathname === "/api/health" ||
|
|
499
|
+
pathname === "/api/providers" ||
|
|
500
|
+
pathname === "/api/tools") {
|
|
501
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
sendJson(res, 404, { error: "not found" });
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
sendJson(res, 500, { error: "internal error" });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return new Promise((resolve, reject) => {
|
|
511
|
+
let server;
|
|
512
|
+
try {
|
|
513
|
+
server = createServer((req, res) => {
|
|
514
|
+
void handle(req, res);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (e) {
|
|
518
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const sockets = new Set();
|
|
522
|
+
try {
|
|
523
|
+
server.on("connection", (s) => {
|
|
524
|
+
sockets.add(s);
|
|
525
|
+
s.on("close", () => sockets.delete(s));
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
// observer wiring must never break startup
|
|
530
|
+
}
|
|
531
|
+
const onError = (e) => {
|
|
532
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
533
|
+
};
|
|
534
|
+
server.once("error", onError);
|
|
535
|
+
server.listen(port, host, () => {
|
|
536
|
+
server.off("error", onError);
|
|
537
|
+
let actual = port;
|
|
538
|
+
try {
|
|
539
|
+
const addr = server.address();
|
|
540
|
+
if (addr !== null && typeof addr === "object")
|
|
541
|
+
actual = addr.port;
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
// keep the requested port in the URL on introspection failure
|
|
545
|
+
}
|
|
546
|
+
resolve({
|
|
547
|
+
url: `http://${host}:${actual}/`,
|
|
548
|
+
host,
|
|
549
|
+
port: actual,
|
|
550
|
+
runtime,
|
|
551
|
+
close: () => new Promise((done) => {
|
|
552
|
+
try {
|
|
553
|
+
for (const s of sockets) {
|
|
554
|
+
try {
|
|
555
|
+
s.destroy();
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// ignore per-socket failures
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
server.close(() => done());
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
done();
|
|
565
|
+
}
|
|
566
|
+
}),
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
}
|