@xingjisoft/neutrino 0.1.4 → 0.1.6
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 +413 -163
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +442 -30
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +19 -0
- package/dist/config.js +121 -28
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +14 -0
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +709 -49
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +131 -14
- package/dist/screens/start.js +50 -16
- package/dist/selection.js +60 -0
- package/dist/state.js +1 -0
- package/dist/terminal/input.js +34 -5
- package/dist/terminal/renderer.js +58 -12
- package/dist/terminal/restore.js +148 -0
- package/dist/ui.js +16 -0
- package/dist/web/auth.js +82 -0
- package/dist/web/hub.js +180 -0
- package/dist/web/server.js +688 -0
- package/dist/web/session.js +572 -0
- package/dist/web/settings.js +101 -0
- package/dist/width.js +20 -0
- package/package.json +6 -4
- package/web/README.md +210 -0
- package/web/app.js +1100 -0
- package/web/index.html +90 -0
- package/web/settings.js +193 -0
- package/web/style.css +1088 -0
- package/web/turns.js +52 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny, dependency-free HTTP server that exposes Neutrino to a browser.
|
|
3
|
+
*
|
|
4
|
+
* It has two jobs:
|
|
5
|
+
* 1. Serve the static single-page app from `web/` at the repository root.
|
|
6
|
+
* 2. Provide a JSON/SSE API over the on-disk history and the live hub so the
|
|
7
|
+
* page can browse every past conversation, watch the running one in real
|
|
8
|
+
* time, and drive it (pause / resume / send).
|
|
9
|
+
*
|
|
10
|
+
* The server binds to loopback by default: it can read your whole conversation
|
|
11
|
+
* history and drive an agent with shell access, so exposing it on a public
|
|
12
|
+
* interface is an explicit opt-in. The API requires a persistent password.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { SESSION_DIR, listSessions, resolveModelSelection } from "../config.js";
|
|
20
|
+
import { getWebSettings, updateWebSettings } from "./settings.js";
|
|
21
|
+
import { WebAuth } from "./auth.js";
|
|
22
|
+
import { normalizeCwd } from "../cwd.js";
|
|
23
|
+
import { hub } from "./hub.js";
|
|
24
|
+
import { writeCrashLog } from "../terminal/restore.js";
|
|
25
|
+
import { resumeSession } from "./session.js";
|
|
26
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
// dist/web -> package root (mirrors the same trick used in config.ts).
|
|
28
|
+
const PKG_ROOT = path.resolve(__dirname, "..", "..");
|
|
29
|
+
const WEB_DIR = path.join(PKG_ROOT, "web");
|
|
30
|
+
/**
|
|
31
|
+
* Conversations resumed from the browser only live as long as this map keeps
|
|
32
|
+
* them reachable; the TUI owns its own sessions and must not be closable here.
|
|
33
|
+
*/
|
|
34
|
+
const webSessions = new Map();
|
|
35
|
+
function closeWebSession(key) {
|
|
36
|
+
const session = webSessions.get(key);
|
|
37
|
+
if (!session)
|
|
38
|
+
return false;
|
|
39
|
+
webSessions.delete(key);
|
|
40
|
+
session.dispose();
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
/** Bind addresses that mean "every interface" rather than one host. */
|
|
44
|
+
const WILDCARD_HOSTS = new Set(["0.0.0.0", "::", "[::]", ""]);
|
|
45
|
+
/** True when `host` only accepts connections from this machine. */
|
|
46
|
+
export function isLoopbackHost(host) {
|
|
47
|
+
const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
48
|
+
if (WILDCARD_HOSTS.has(h))
|
|
49
|
+
return false;
|
|
50
|
+
return h === "localhost" || h === "::1" || h.startsWith("127.");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Adapters that only ever talk to this machine: hypervisor host-only switches,
|
|
54
|
+
* container bridges and loopback variants. Listing them would bury the address
|
|
55
|
+
* the phone actually needs under three that cannot work.
|
|
56
|
+
*/
|
|
57
|
+
const VIRTUAL_ADAPTER = /vEthernet|VMware|VirtualBox|Hyper-V|Loopback|Npcap|Bluetooth|Docker/i;
|
|
58
|
+
/** The IPv4 addresses other devices can reach this machine on. */
|
|
59
|
+
export function lanAddresses() {
|
|
60
|
+
const found = [];
|
|
61
|
+
for (const [name, entries] of Object.entries(os.networkInterfaces())) {
|
|
62
|
+
if (VIRTUAL_ADAPTER.test(name))
|
|
63
|
+
continue;
|
|
64
|
+
for (const entry of entries ?? []) {
|
|
65
|
+
if (entry.internal)
|
|
66
|
+
continue;
|
|
67
|
+
// `family` is "IPv4" in current Node and 4 in older typings.
|
|
68
|
+
const family = String(entry.family);
|
|
69
|
+
if (family !== "IPv4" && family !== "4")
|
|
70
|
+
continue;
|
|
71
|
+
// 169.254/16 is link-local: it only routes on the local segment, so it is
|
|
72
|
+
// never the address worth telling another device to open.
|
|
73
|
+
if (entry.address.startsWith("169.254."))
|
|
74
|
+
continue;
|
|
75
|
+
if (!found.includes(entry.address))
|
|
76
|
+
found.push(entry.address);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
/** Build the list of addresses worth showing to the operator. */
|
|
82
|
+
export function webUrls(host, port) {
|
|
83
|
+
const normalized = host.trim() || "127.0.0.1";
|
|
84
|
+
const wildcard = WILDCARD_HOSTS.has(normalized.toLowerCase());
|
|
85
|
+
const hosts = [];
|
|
86
|
+
if (isLoopbackHost(normalized)) {
|
|
87
|
+
hosts.push("127.0.0.1");
|
|
88
|
+
}
|
|
89
|
+
else if (wildcard) {
|
|
90
|
+
hosts.push(...lanAddresses());
|
|
91
|
+
hosts.push("127.0.0.1");
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
hosts.push(normalized);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
url: `http://${wildcard ? "127.0.0.1" : normalized}:${port}`,
|
|
98
|
+
urls: hosts.map((h) => `http://${h}:${port}/`),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const MIME_TYPES = {
|
|
102
|
+
".html": "text/html; charset=utf-8",
|
|
103
|
+
".css": "text/css; charset=utf-8",
|
|
104
|
+
".js": "text/javascript; charset=utf-8",
|
|
105
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
106
|
+
".json": "application/json; charset=utf-8",
|
|
107
|
+
".svg": "image/svg+xml",
|
|
108
|
+
".png": "image/png",
|
|
109
|
+
".jpg": "image/jpeg",
|
|
110
|
+
".jpeg": "image/jpeg",
|
|
111
|
+
".gif": "image/gif",
|
|
112
|
+
".ico": "image/x-icon",
|
|
113
|
+
".webmanifest": "application/manifest+json",
|
|
114
|
+
".txt": "text/plain; charset=utf-8",
|
|
115
|
+
};
|
|
116
|
+
// Session ids are ISO timestamps with `:`/`.` swapped for `-`. Anything else is
|
|
117
|
+
// rejected so a crafted id cannot read files outside the session directory.
|
|
118
|
+
const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
119
|
+
function isSafeSessionId(id) {
|
|
120
|
+
return SESSION_ID_RE.test(id) && id !== "." && id !== ".." && !id.includes("..");
|
|
121
|
+
}
|
|
122
|
+
function loadSessionSafe(id) {
|
|
123
|
+
if (!isSafeSessionId(id))
|
|
124
|
+
return null;
|
|
125
|
+
const filePath = path.join(SESSION_DIR, `${id}.json`);
|
|
126
|
+
if (path.dirname(filePath) !== SESSION_DIR)
|
|
127
|
+
return null;
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Build the sidebar payload: every session grouped by working directory. */
|
|
136
|
+
function buildOverview() {
|
|
137
|
+
const sessions = listSessions();
|
|
138
|
+
const lives = hub.list();
|
|
139
|
+
const liveBySession = new Map();
|
|
140
|
+
for (const live of lives) {
|
|
141
|
+
if (live.sessionId)
|
|
142
|
+
liveBySession.set(live.sessionId, live);
|
|
143
|
+
}
|
|
144
|
+
const groups = new Map();
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
for (const session of sessions) {
|
|
147
|
+
const key = normalizeCwd(session.cwd) || "(unknown)";
|
|
148
|
+
let group = groups.get(key);
|
|
149
|
+
if (!group) {
|
|
150
|
+
group = { key, cwd: session.cwd || "(unknown directory)", updated_at: session.updated_at, sessions: [] };
|
|
151
|
+
groups.set(key, group);
|
|
152
|
+
}
|
|
153
|
+
const live = liveBySession.get(session.id);
|
|
154
|
+
group.sessions.push({ ...session, live: Boolean(live), liveKey: live?.key ?? null });
|
|
155
|
+
if (session.updated_at > group.updated_at) {
|
|
156
|
+
group.updated_at = session.updated_at;
|
|
157
|
+
group.cwd = session.cwd || group.cwd;
|
|
158
|
+
}
|
|
159
|
+
seen.add(session.id);
|
|
160
|
+
}
|
|
161
|
+
// Live conversations that have not been saved yet (or whose newest save has
|
|
162
|
+
// not landed) still deserve a spot at the top of their directory group.
|
|
163
|
+
for (const live of lives) {
|
|
164
|
+
if (live.sessionId && seen.has(live.sessionId))
|
|
165
|
+
continue;
|
|
166
|
+
const key = normalizeCwd(live.cwd) || "(unknown)";
|
|
167
|
+
let group = groups.get(key);
|
|
168
|
+
if (!group) {
|
|
169
|
+
group = { key, cwd: live.cwd || "(unknown directory)", updated_at: "", sessions: [] };
|
|
170
|
+
groups.set(key, group);
|
|
171
|
+
}
|
|
172
|
+
const now = new Date().toISOString();
|
|
173
|
+
group.sessions.unshift({
|
|
174
|
+
id: live.sessionId ?? `live:${live.key}`,
|
|
175
|
+
created_at: now,
|
|
176
|
+
updated_at: now,
|
|
177
|
+
model: live.model,
|
|
178
|
+
provider: live.provider,
|
|
179
|
+
intensity: live.intensity,
|
|
180
|
+
cwd: live.cwd,
|
|
181
|
+
title: live.title || "New conversation",
|
|
182
|
+
message_count: 0,
|
|
183
|
+
live: true,
|
|
184
|
+
liveKey: live.key,
|
|
185
|
+
});
|
|
186
|
+
if (now > group.updated_at)
|
|
187
|
+
group.updated_at = now;
|
|
188
|
+
}
|
|
189
|
+
const ordered = [...groups.values()]
|
|
190
|
+
.map((group) => ({
|
|
191
|
+
...group,
|
|
192
|
+
// Newest conversation first; the UI reveals only the first five.
|
|
193
|
+
sessions: group.sessions.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0)),
|
|
194
|
+
}))
|
|
195
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
|
|
196
|
+
return {
|
|
197
|
+
groups: ordered,
|
|
198
|
+
live: lives,
|
|
199
|
+
generated_at: new Date().toISOString(),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function sendJson(res, status, body) {
|
|
203
|
+
const data = JSON.stringify(body);
|
|
204
|
+
res.writeHead(status, {
|
|
205
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
206
|
+
"Content-Length": Buffer.byteLength(data),
|
|
207
|
+
"Cache-Control": "no-store",
|
|
208
|
+
});
|
|
209
|
+
res.end(data);
|
|
210
|
+
}
|
|
211
|
+
function sendText(res, status, body) {
|
|
212
|
+
res.writeHead(status, {
|
|
213
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
214
|
+
"Cache-Control": "no-store",
|
|
215
|
+
});
|
|
216
|
+
res.end(body);
|
|
217
|
+
}
|
|
218
|
+
function readJsonBody(req, limit = 1 << 20) {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
let size = 0;
|
|
221
|
+
const chunks = [];
|
|
222
|
+
req.on("data", (chunk) => {
|
|
223
|
+
size += chunk.length;
|
|
224
|
+
if (size > limit) {
|
|
225
|
+
reject(new Error("request body too large"));
|
|
226
|
+
req.destroy();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
chunks.push(chunk);
|
|
230
|
+
});
|
|
231
|
+
req.on("end", () => {
|
|
232
|
+
if (chunks.length === 0) {
|
|
233
|
+
resolve({});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
reject(err);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
req.on("error", reject);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
// Mutations must come from the page the server itself serves. Browsers attach
|
|
247
|
+
// an Origin header to POST requests, so a cross-site form/fetch cannot drive
|
|
248
|
+
// the running agent. Non-browser clients (curl, scripts) send no Origin.
|
|
249
|
+
function isSameOrigin(req) {
|
|
250
|
+
const origin = req.headers.origin;
|
|
251
|
+
if (!origin)
|
|
252
|
+
return true;
|
|
253
|
+
try {
|
|
254
|
+
return new URL(origin).host === req.headers.host;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function serveStatic(res, pathname) {
|
|
261
|
+
let rel = pathname === "/" ? "/index.html" : pathname;
|
|
262
|
+
try {
|
|
263
|
+
rel = decodeURIComponent(rel);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
sendText(res, 400, "Bad request");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
rel = rel.replace(/^\/+/, "");
|
|
270
|
+
const filePath = path.resolve(WEB_DIR, rel);
|
|
271
|
+
if (filePath !== WEB_DIR && !filePath.startsWith(WEB_DIR + path.sep)) {
|
|
272
|
+
sendText(res, 403, "Forbidden");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
fs.stat(filePath, (err, stat) => {
|
|
276
|
+
if (err || !stat.isFile()) {
|
|
277
|
+
sendText(res, 404, "Not found");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const type = MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
281
|
+
res.writeHead(200, {
|
|
282
|
+
"Content-Type": type,
|
|
283
|
+
"Content-Length": stat.size,
|
|
284
|
+
"Cache-Control": "no-cache",
|
|
285
|
+
});
|
|
286
|
+
fs.createReadStream(filePath).pipe(res);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
function streamLive(key, req, res) {
|
|
290
|
+
const initial = hub.snapshot(key);
|
|
291
|
+
if (!initial) {
|
|
292
|
+
sendJson(res, 404, { error: "unknown live session" });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
res.writeHead(200, {
|
|
296
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
297
|
+
"Cache-Control": "no-cache, no-transform",
|
|
298
|
+
Connection: "keep-alive",
|
|
299
|
+
// Disable proxy buffering so tokens arrive as they are produced.
|
|
300
|
+
"X-Accel-Buffering": "no",
|
|
301
|
+
});
|
|
302
|
+
// A browser tab that is closed, reloaded or suspended leaves a socket that
|
|
303
|
+
// writes fail on. Every write goes through here so a dead connection can
|
|
304
|
+
// never emit an 'error' event nobody listens for.
|
|
305
|
+
const write = (chunk) => {
|
|
306
|
+
if (res.destroyed || res.writableEnded)
|
|
307
|
+
return false;
|
|
308
|
+
try {
|
|
309
|
+
res.write(chunk);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
write("retry: 2000\n\n");
|
|
317
|
+
const send = (snapshot) => {
|
|
318
|
+
write(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`);
|
|
319
|
+
};
|
|
320
|
+
const unsubscribe = hub.subscribe(key, send);
|
|
321
|
+
if (!unsubscribe) {
|
|
322
|
+
write("event: gone\ndata: {}\n\n");
|
|
323
|
+
res.end();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Rebound so the hoisted cleanup() below keeps the narrowed (non-null) type.
|
|
327
|
+
const detach = unsubscribe;
|
|
328
|
+
const keepAlive = setInterval(() => {
|
|
329
|
+
if (!write(": ping\n\n"))
|
|
330
|
+
cleanup();
|
|
331
|
+
}, 25_000);
|
|
332
|
+
keepAlive.unref?.();
|
|
333
|
+
function cleanup() {
|
|
334
|
+
clearInterval(keepAlive);
|
|
335
|
+
detach();
|
|
336
|
+
}
|
|
337
|
+
req.on("close", cleanup);
|
|
338
|
+
req.on("error", cleanup);
|
|
339
|
+
// Without this, a client reset during a write is an uncaught 'error' event.
|
|
340
|
+
res.on("error", cleanup);
|
|
341
|
+
}
|
|
342
|
+
async function handleApi(req, res, url, auth) {
|
|
343
|
+
const { pathname } = url;
|
|
344
|
+
if (!pathname.startsWith("/api/"))
|
|
345
|
+
return false;
|
|
346
|
+
if (req.method === "OPTIONS") {
|
|
347
|
+
// Same-origin only: no CORS headers, so cross-site preflights are rejected.
|
|
348
|
+
// Answered before the login check because it reveals nothing.
|
|
349
|
+
res.writeHead(204, { Allow: "GET, POST, OPTIONS" });
|
|
350
|
+
res.end();
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
if (pathname === "/api/auth/login" || pathname === "/api/auth/logout") {
|
|
354
|
+
if (req.method !== "POST") {
|
|
355
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
356
|
+
}
|
|
357
|
+
else if (!isSameOrigin(req)) {
|
|
358
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
359
|
+
}
|
|
360
|
+
else if (pathname.endsWith("/logout")) {
|
|
361
|
+
auth.forget(res);
|
|
362
|
+
sendJson(res, 200, { ok: true });
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
let body;
|
|
366
|
+
try {
|
|
367
|
+
body = await readJsonBody(req, 4096);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
if (await auth.login(body?.password)) {
|
|
374
|
+
auth.remember(res);
|
|
375
|
+
sendJson(res, 200, { ok: true });
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
sendJson(res, 401, { error: "Incorrect password." });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
if (!auth.authenticated(req)) {
|
|
384
|
+
sendJson(res, 401, { error: "Password login required." });
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
if (pathname === "/api/settings") {
|
|
388
|
+
if (req.method === "GET") {
|
|
389
|
+
sendJson(res, 200, getWebSettings());
|
|
390
|
+
}
|
|
391
|
+
else if (req.method === "POST") {
|
|
392
|
+
if (!isSameOrigin(req)) {
|
|
393
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const body = await readJsonBody(req);
|
|
398
|
+
updateWebSettings(body);
|
|
399
|
+
if (body.settings?.web_password !== undefined) {
|
|
400
|
+
auth.remember(res);
|
|
401
|
+
}
|
|
402
|
+
sendJson(res, 200, getWebSettings());
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
sendJson(res, err?.code ? 500 : 400, {
|
|
406
|
+
error: err instanceof Error ? err.message : "invalid settings",
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
412
|
+
}
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
if (req.method === "GET" && pathname === "/api/health") {
|
|
416
|
+
sendJson(res, 200, { ok: true, live: hub.list().length });
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
if (req.method === "GET" && pathname === "/api/overview") {
|
|
420
|
+
sendJson(res, 200, buildOverview());
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
if (req.method === "GET" && pathname === "/api/sessions") {
|
|
424
|
+
sendJson(res, 200, { sessions: listSessions() });
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
|
|
428
|
+
if (req.method === "GET" && sessionMatch) {
|
|
429
|
+
const id = decodeURIComponent(sessionMatch[1]);
|
|
430
|
+
if (!isSafeSessionId(id)) {
|
|
431
|
+
sendJson(res, 400, { error: "invalid session id" });
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
const data = loadSessionSafe(id);
|
|
435
|
+
if (!data) {
|
|
436
|
+
sendJson(res, 404, { error: "session not found" });
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
sendJson(res, 200, data);
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
const continueMatch = /^\/api\/session\/([^/]+)\/continue$/.exec(pathname);
|
|
443
|
+
if (req.method === "POST" && continueMatch) {
|
|
444
|
+
if (!isSameOrigin(req)) {
|
|
445
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
const id = decodeURIComponent(continueMatch[1]);
|
|
449
|
+
if (!isSafeSessionId(id)) {
|
|
450
|
+
sendJson(res, 400, { error: "invalid session id" });
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
let selection;
|
|
454
|
+
try {
|
|
455
|
+
const body = await readJsonBody(req);
|
|
456
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
457
|
+
throw new Error("invalid JSON body");
|
|
458
|
+
const requested = body.selection;
|
|
459
|
+
if (requested !== undefined)
|
|
460
|
+
selection = resolveModelSelection(requested);
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : "invalid model selection" });
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
// Already running (TUI or another tab)? Attach instead of duplicating.
|
|
467
|
+
const existing = hub.list().find((entry) => entry.sessionId === id);
|
|
468
|
+
if (existing) {
|
|
469
|
+
if (selection) {
|
|
470
|
+
const changed = hub.configure(existing.key, selection);
|
|
471
|
+
if (!changed.ok) {
|
|
472
|
+
sendJson(res, 409, { error: changed.error });
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
sendJson(res, 200, { key: existing.key, attached: true });
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
const data = loadSessionSafe(id);
|
|
480
|
+
if (!data) {
|
|
481
|
+
sendJson(res, 404, { error: "session not found" });
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
const requested = selection ? {
|
|
485
|
+
provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
|
|
486
|
+
} : undefined;
|
|
487
|
+
const outcome = resumeSession(data, requested);
|
|
488
|
+
if (!outcome.ok) {
|
|
489
|
+
sendJson(res, 400, { error: outcome.error });
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
const key = outcome.session.start();
|
|
493
|
+
webSessions.set(key, outcome.session);
|
|
494
|
+
sendJson(res, 200, { key, attached: false });
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
const streamMatch = /^\/api\/live\/([^/]+)\/stream$/.exec(pathname);
|
|
498
|
+
if (req.method === "GET" && streamMatch) {
|
|
499
|
+
streamLive(decodeURIComponent(streamMatch[1]), req, res);
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode))?$/.exec(pathname);
|
|
503
|
+
if (liveMatch) {
|
|
504
|
+
const key = decodeURIComponent(liveMatch[1]);
|
|
505
|
+
const action = liveMatch[2];
|
|
506
|
+
if (req.method === "GET" && !action) {
|
|
507
|
+
const snapshot = hub.snapshot(key);
|
|
508
|
+
if (!snapshot) {
|
|
509
|
+
sendJson(res, 404, { error: "unknown live session" });
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
sendJson(res, 200, snapshot);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
if (req.method === "POST" && action) {
|
|
516
|
+
if (!isSameOrigin(req)) {
|
|
517
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
if (!hub.has(key)) {
|
|
521
|
+
sendJson(res, 404, { error: "unknown live session" });
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
if (action === "approval-mode") {
|
|
525
|
+
let body;
|
|
526
|
+
try {
|
|
527
|
+
body = await readJsonBody(req);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
const mode = body?.mode;
|
|
534
|
+
if (mode !== "request" && mode !== "auto") {
|
|
535
|
+
sendJson(res, 400, { error: "mode must be request or auto" });
|
|
536
|
+
}
|
|
537
|
+
else if (!hub.setApprovalMode(key, mode)) {
|
|
538
|
+
sendJson(res, 409, { error: "this session cannot change approval mode" });
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
|
|
542
|
+
}
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
if (action === "model") {
|
|
546
|
+
let selection;
|
|
547
|
+
try {
|
|
548
|
+
selection = resolveModelSelection(await readJsonBody(req));
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : "invalid model selection" });
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
const changed = hub.configure(key, selection);
|
|
555
|
+
if (!changed.ok)
|
|
556
|
+
sendJson(res, 409, { error: changed.error });
|
|
557
|
+
else
|
|
558
|
+
sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
if (action === "pause") {
|
|
562
|
+
hub.pause(key);
|
|
563
|
+
sendJson(res, 200, { ok: true, paused: true });
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
if (action === "resume") {
|
|
567
|
+
hub.resume(key);
|
|
568
|
+
sendJson(res, 200, { ok: true, paused: false });
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
if (action === "approve") {
|
|
572
|
+
let body;
|
|
573
|
+
try {
|
|
574
|
+
body = await readJsonBody(req);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
const approved = body?.approved === true;
|
|
581
|
+
if (!hub.approve(key, approved)) {
|
|
582
|
+
sendJson(res, 409, { error: "no approval is pending" });
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
sendJson(res, 200, { ok: true, approved });
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
if (action === "close") {
|
|
589
|
+
if (!closeWebSession(key)) {
|
|
590
|
+
sendJson(res, 400, { error: "this session is owned by the terminal UI" });
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
sendJson(res, 200, { ok: true, closed: true });
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
// action === "message"
|
|
597
|
+
let body;
|
|
598
|
+
try {
|
|
599
|
+
body = await readJsonBody(req);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
const text = typeof body?.text === "string" ? body.text : "";
|
|
606
|
+
if (!text.trim()) {
|
|
607
|
+
sendJson(res, 400, { error: "empty message" });
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
hub.send(key, text);
|
|
611
|
+
sendJson(res, 200, { ok: true });
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
sendJson(res, 404, { error: "not found" });
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
async function handleRequest(req, res, auth) {
|
|
621
|
+
let url;
|
|
622
|
+
try {
|
|
623
|
+
url = new URL(req.url ?? "/", "http://localhost");
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
sendText(res, 400, "Bad request");
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (await handleApi(req, res, url, auth))
|
|
630
|
+
return;
|
|
631
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
632
|
+
sendText(res, 405, "Method not allowed");
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
serveStatic(res, url.pathname);
|
|
636
|
+
}
|
|
637
|
+
/** Start the web UI. Resolves once the socket is actually listening. */
|
|
638
|
+
export async function startWebServer(options) {
|
|
639
|
+
const host = options.host ?? "127.0.0.1";
|
|
640
|
+
const auth = new WebAuth(options.auth !== false);
|
|
641
|
+
const server = http.createServer((req, res) => {
|
|
642
|
+
handleRequest(req, res, auth).catch((err) => {
|
|
643
|
+
if (!res.headersSent) {
|
|
644
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
res.end();
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
await new Promise((resolve, reject) => {
|
|
652
|
+
const onError = (err) => {
|
|
653
|
+
server.removeListener("listening", onListening);
|
|
654
|
+
reject(err);
|
|
655
|
+
};
|
|
656
|
+
const onListening = () => {
|
|
657
|
+
server.removeListener("error", onError);
|
|
658
|
+
// Once listening, the server must tolerate connection-level failures
|
|
659
|
+
// (resets, EMFILE, half-open sockets) without the process dying. An
|
|
660
|
+
// 'error' event with no listener is fatal in Node.
|
|
661
|
+
server.on("error", (err) => {
|
|
662
|
+
writeCrashLog("web server", err);
|
|
663
|
+
});
|
|
664
|
+
server.on("clientError", (_err, socket) => {
|
|
665
|
+
if (socket.writable)
|
|
666
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
667
|
+
socket.destroy();
|
|
668
|
+
});
|
|
669
|
+
resolve();
|
|
670
|
+
};
|
|
671
|
+
server.once("error", onError);
|
|
672
|
+
server.once("listening", onListening);
|
|
673
|
+
server.listen(options.port, host);
|
|
674
|
+
});
|
|
675
|
+
const address = server.address();
|
|
676
|
+
const port = typeof address === "object" && address ? address.port : options.port;
|
|
677
|
+
return {
|
|
678
|
+
host,
|
|
679
|
+
port,
|
|
680
|
+
authRequired: auth.enabled,
|
|
681
|
+
...webUrls(host, port),
|
|
682
|
+
close: () => new Promise((resolve) => {
|
|
683
|
+
for (const key of [...webSessions.keys()])
|
|
684
|
+
closeWebSession(key);
|
|
685
|
+
server.close(() => resolve());
|
|
686
|
+
}),
|
|
687
|
+
};
|
|
688
|
+
}
|