@nordbyte/nordrelay 0.2.1 → 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.
@@ -0,0 +1,764 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { URL } from "node:url";
6
+ import { enabledAgents } from "./agent-factory.js";
7
+ import { listAgentAdapterDescriptors } from "./agent-adapter.js";
8
+ import { isAgentId } from "./agent.js";
9
+ import { listChannelDescriptors } from "./channel-adapter.js";
10
+ import { loadConfig } from "./config.js";
11
+ import { friendlyErrorText } from "./error-messages.js";
12
+ import { escapeHTML } from "./format.js";
13
+ import { RelayRuntime } from "./relay-runtime.js";
14
+ import { resolveDashboardEnvPath, SettingsService } from "./settings-service.js";
15
+ const DEFAULT_HOME = path.join(os.homedir(), ".codex", "nordrelay");
16
+ const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
17
+ const options = parseOptions(process.argv.slice(2));
18
+ const auth = resolveDashboardAuth(options.host);
19
+ if (auth.publicBind && !auth.token && !(auth.user && auth.password)) {
20
+ throw new Error("Dashboard bound to 0.0.0.0 requires NORDRELAY_DASHBOARD_TOKEN or NORDRELAY_DASHBOARD_USER/PASSWORD.");
21
+ }
22
+ const config = loadConfig();
23
+ const runtime = new RelayRuntime(config);
24
+ const settings = new SettingsService(resolveDashboardEnvPath(options.home));
25
+ const server = createServer((req, res) => {
26
+ void handleRequest(req, res).catch((error) => {
27
+ sendJson(res, 500, { error: friendlyErrorText(error) });
28
+ });
29
+ });
30
+ await new Promise((resolve) => server.listen(options.port, options.host, resolve));
31
+ console.log(`NordRelay dashboard: http://${options.host}:${options.port}/`);
32
+ process.once("SIGINT", () => shutdown());
33
+ process.once("SIGTERM", () => shutdown());
34
+ async function handleRequest(req, res) {
35
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
36
+ const queryToken = url.searchParams.get("token");
37
+ if (queryToken && isAuthorizedToken(queryToken) && !url.pathname.startsWith("/api/")) {
38
+ setAuthCookie(res, queryToken);
39
+ res.writeHead(302, { location: url.pathname || "/" });
40
+ res.end();
41
+ return;
42
+ }
43
+ if (url.pathname === "/api/auth" && req.method === "POST") {
44
+ await handleLogin(req, res);
45
+ return;
46
+ }
47
+ if (auth.required && !isAuthorizedRequest(req) && !isAuthorizedToken(queryToken ?? "")) {
48
+ if (url.pathname === "/" || url.pathname === "/index.html") {
49
+ sendText(res, 200, renderLoginPage(auth), "text/html; charset=utf-8");
50
+ return;
51
+ }
52
+ if (url.pathname.startsWith("/api/") || url.pathname === "/healthz") {
53
+ sendJson(res, 401, { error: "Authentication required" });
54
+ return;
55
+ }
56
+ sendText(res, 401, "Authentication required\n", "text/plain; charset=utf-8");
57
+ return;
58
+ }
59
+ if (url.pathname === "/healthz") {
60
+ sendText(res, 200, "ok\n", "text/plain; charset=utf-8");
61
+ return;
62
+ }
63
+ if (url.pathname === "/" || url.pathname === "/index.html") {
64
+ sendText(res, 200, renderDashboardApp({ authRequired: auth.required }), "text/html; charset=utf-8");
65
+ return;
66
+ }
67
+ if (url.pathname === "/api/events" && req.method === "GET") {
68
+ handleEvents(req, res);
69
+ return;
70
+ }
71
+ if (!url.pathname.startsWith("/api/")) {
72
+ sendText(res, 404, "not found\n", "text/plain; charset=utf-8");
73
+ return;
74
+ }
75
+ await handleApi(req, res, url);
76
+ }
77
+ async function handleApi(req, res, url) {
78
+ if (req.method === "GET" && url.pathname === "/api/bootstrap") {
79
+ sendJson(res, 200, {
80
+ auth: { required: auth.required, publicBind: auth.publicBind },
81
+ channels: listChannelDescriptors(),
82
+ agentAdapters: listAgentAdapterDescriptors(),
83
+ enabledAgents: enabledAgents(config),
84
+ status: await runtime.status(),
85
+ });
86
+ return;
87
+ }
88
+ if (req.method === "GET" && url.pathname === "/api/health") {
89
+ sendJson(res, 200, await runtime.status());
90
+ return;
91
+ }
92
+ if (req.method === "GET" && url.pathname === "/api/settings") {
93
+ sendJson(res, 200, await settings.snapshot());
94
+ return;
95
+ }
96
+ if (req.method === "PATCH" && url.pathname === "/api/settings") {
97
+ const body = await readJsonBody(req);
98
+ sendJson(res, 200, await settings.update(objectRecord(body?.settings)));
99
+ return;
100
+ }
101
+ if (req.method === "GET" && url.pathname === "/api/snapshot") {
102
+ sendJson(res, 200, await runtime.snapshot());
103
+ return;
104
+ }
105
+ if (req.method === "GET" && url.pathname === "/api/sessions") {
106
+ sendJson(res, 200, await runtime.listSessionsPage(numberParam(url, "page", 1), numberParam(url, "limit", 50), url.searchParams.get("query") ?? ""));
107
+ return;
108
+ }
109
+ if (req.method === "POST" && url.pathname === "/api/agent") {
110
+ const body = await readJsonBody(req);
111
+ const agentId = stringField(body, "agentId");
112
+ if (!isAgentId(agentId)) {
113
+ throw new Error(`Invalid agent: ${agentId}`);
114
+ }
115
+ sendJson(res, 200, { session: await runtime.setAgent(agentId) });
116
+ return;
117
+ }
118
+ if (req.method === "POST" && url.pathname === "/api/sessions/new") {
119
+ const body = await readJsonBody(req);
120
+ sendJson(res, 200, {
121
+ session: await runtime.newSession({
122
+ workspace: optionalStringField(body, "workspace"),
123
+ model: optionalStringField(body, "model"),
124
+ }),
125
+ });
126
+ return;
127
+ }
128
+ if (req.method === "POST" && url.pathname === "/api/sessions/switch") {
129
+ const body = await readJsonBody(req);
130
+ sendJson(res, 200, { session: await runtime.switchSession(stringField(body, "threadId")) });
131
+ return;
132
+ }
133
+ if (req.method === "POST" && url.pathname === "/api/sessions/attach") {
134
+ const body = await readJsonBody(req);
135
+ sendJson(res, 200, { session: await runtime.attachSession(stringField(body, "threadId")) });
136
+ return;
137
+ }
138
+ if (req.method === "GET" && url.pathname === "/api/models") {
139
+ sendJson(res, 200, { models: await runtime.listModels() });
140
+ return;
141
+ }
142
+ if (req.method === "POST" && url.pathname === "/api/session/model") {
143
+ const body = await readJsonBody(req);
144
+ sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model")) });
145
+ return;
146
+ }
147
+ if (req.method === "POST" && url.pathname === "/api/session/reasoning") {
148
+ const body = await readJsonBody(req);
149
+ sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning")) });
150
+ return;
151
+ }
152
+ if (req.method === "POST" && url.pathname === "/api/session/fast") {
153
+ const body = await readJsonBody(req);
154
+ sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled)) });
155
+ return;
156
+ }
157
+ if (req.method === "POST" && url.pathname === "/api/session/launch") {
158
+ const body = await readJsonBody(req);
159
+ sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId")) });
160
+ return;
161
+ }
162
+ if (req.method === "POST" && url.pathname === "/api/prompt") {
163
+ const body = await readJsonBody(req);
164
+ sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text")));
165
+ return;
166
+ }
167
+ if (req.method === "POST" && url.pathname === "/api/prompt/upload") {
168
+ const body = await readJsonBody(req);
169
+ sendJson(res, 202, await runtime.sendUploadPrompt({
170
+ text: optionalStringField(body, "text"),
171
+ files: parseUploadFiles(body.files),
172
+ }));
173
+ return;
174
+ }
175
+ if (req.method === "POST" && url.pathname === "/api/abort") {
176
+ await runtime.abort();
177
+ sendJson(res, 200, { ok: true });
178
+ return;
179
+ }
180
+ if (req.method === "POST" && url.pathname === "/api/handback") {
181
+ sendJson(res, 200, await runtime.handback());
182
+ return;
183
+ }
184
+ if (req.method === "GET" && url.pathname === "/api/queue") {
185
+ sendJson(res, 200, { queue: runtime.queue() });
186
+ return;
187
+ }
188
+ if (req.method === "POST" && url.pathname === "/api/queue") {
189
+ const body = await readJsonBody(req);
190
+ sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")) });
191
+ return;
192
+ }
193
+ if (req.method === "GET" && url.pathname === "/api/artifacts") {
194
+ sendJson(res, 200, { reports: await runtime.artifacts() });
195
+ return;
196
+ }
197
+ if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
198
+ sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
199
+ return;
200
+ }
201
+ if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
202
+ const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
203
+ if (!bundle) {
204
+ sendJson(res, 404, { error: "Artifact turn not found or ZIP could not be created" });
205
+ return;
206
+ }
207
+ sendFile(res, bundle.path, bundle.name);
208
+ return;
209
+ }
210
+ if (req.method === "GET" && url.pathname === "/api/artifacts/file") {
211
+ const turnId = requiredSearch(url, "turnId");
212
+ const relativePath = requiredSearch(url, "path");
213
+ const report = await runtime.artifact(turnId);
214
+ const artifact = report?.artifacts.find((candidate) => candidate.relativePath === relativePath);
215
+ if (!artifact) {
216
+ sendJson(res, 404, { error: "Artifact not found" });
217
+ return;
218
+ }
219
+ sendFile(res, artifact.localPath, artifact.name);
220
+ return;
221
+ }
222
+ if (req.method === "GET" && url.pathname === "/api/logs") {
223
+ sendJson(res, 200, await runtime.logs(url.searchParams.get("target") || "connector", numberParam(url, "lines", 120)));
224
+ return;
225
+ }
226
+ if (req.method === "GET" && url.pathname === "/api/diagnostics") {
227
+ sendJson(res, 200, await runtime.status());
228
+ return;
229
+ }
230
+ sendJson(res, 404, { error: "Unknown endpoint" });
231
+ }
232
+ function handleEvents(req, res) {
233
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
234
+ const token = url.searchParams.get("token");
235
+ if (auth.required && !(isAuthorizedRequest(req) || (token && isAuthorizedToken(token)))) {
236
+ sendJson(res, 401, { error: "Authentication required" });
237
+ return;
238
+ }
239
+ res.writeHead(200, {
240
+ "content-type": "text/event-stream; charset=utf-8",
241
+ "cache-control": "no-cache, no-transform",
242
+ connection: "keep-alive",
243
+ });
244
+ const send = (event) => {
245
+ res.write(`event: ${event.type}\n`);
246
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
247
+ };
248
+ const unsubscribe = runtime.subscribe(send);
249
+ const heartbeat = setInterval(() => {
250
+ res.write(": heartbeat\n\n");
251
+ }, 25_000);
252
+ heartbeat.unref?.();
253
+ req.on("close", () => {
254
+ clearInterval(heartbeat);
255
+ unsubscribe();
256
+ });
257
+ }
258
+ async function handleLogin(req, res) {
259
+ const body = await readJsonBody(req);
260
+ const token = optionalStringField(body, "token");
261
+ const user = optionalStringField(body, "user");
262
+ const password = optionalStringField(body, "password");
263
+ if (token && isAuthorizedToken(token)) {
264
+ setAuthCookie(res, token);
265
+ sendJson(res, 200, { ok: true, mode: "token" });
266
+ return;
267
+ }
268
+ if (user && password && isAuthorizedBasic(user, password)) {
269
+ setBasicCookie(res, user, password);
270
+ sendJson(res, 200, { ok: true, mode: "basic" });
271
+ return;
272
+ }
273
+ sendJson(res, 401, { error: "Invalid dashboard credentials" });
274
+ }
275
+ function parseOptions(argv) {
276
+ let host = process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
277
+ let port = Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10);
278
+ let home = process.env.NORDRELAY_HOME || DEFAULT_HOME;
279
+ for (let index = 0; index < argv.length; index += 1) {
280
+ const arg = argv[index];
281
+ if (arg === "--host")
282
+ host = requireArg(argv, ++index, arg);
283
+ else if (arg === "--port")
284
+ port = Number.parseInt(requireArg(argv, ++index, arg), 10);
285
+ else if (arg === "--home")
286
+ home = requireArg(argv, ++index, arg);
287
+ }
288
+ if (!Number.isFinite(port) || port <= 0) {
289
+ throw new Error("Dashboard port must be a positive number.");
290
+ }
291
+ return { host, port, home };
292
+ }
293
+ function resolveDashboardAuth(host) {
294
+ const token = optionalEnv("NORDRELAY_DASHBOARD_TOKEN");
295
+ const user = optionalEnv("NORDRELAY_DASHBOARD_USER");
296
+ const password = optionalEnv("NORDRELAY_DASHBOARD_PASSWORD");
297
+ const publicBind = isPublicBindHost(host);
298
+ return {
299
+ required: publicBind || Boolean(token || (user && password)),
300
+ publicBind,
301
+ token,
302
+ user,
303
+ password,
304
+ };
305
+ }
306
+ function isPublicBindHost(host) {
307
+ return host === "0.0.0.0" || host === "::" || host === "";
308
+ }
309
+ function isAuthorizedRequest(req) {
310
+ if (!auth.required) {
311
+ return true;
312
+ }
313
+ const header = req.headers.authorization;
314
+ if (header?.startsWith("Bearer ") && isAuthorizedToken(header.slice("Bearer ".length).trim())) {
315
+ return true;
316
+ }
317
+ if (header?.startsWith("Basic ")) {
318
+ const decoded = Buffer.from(header.slice("Basic ".length), "base64").toString("utf8");
319
+ const [user, ...passwordParts] = decoded.split(":");
320
+ if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
321
+ return true;
322
+ }
323
+ }
324
+ const cookies = parseCookies(req.headers.cookie ?? "");
325
+ if (cookies.nrdash && isAuthorizedToken(cookies.nrdash)) {
326
+ return true;
327
+ }
328
+ if (cookies.nrdash_basic) {
329
+ const decoded = Buffer.from(cookies.nrdash_basic, "base64").toString("utf8");
330
+ const [user, ...passwordParts] = decoded.split(":");
331
+ if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
332
+ return true;
333
+ }
334
+ }
335
+ return false;
336
+ }
337
+ function isAuthorizedToken(token) {
338
+ return Boolean(auth.token && constantTimeEqual(token, auth.token));
339
+ }
340
+ function isAuthorizedBasic(user, password) {
341
+ return Boolean(auth.user && auth.password && constantTimeEqual(user, auth.user) && constantTimeEqual(password, auth.password));
342
+ }
343
+ function constantTimeEqual(left, right) {
344
+ const leftBuffer = Buffer.from(left);
345
+ const rightBuffer = Buffer.from(right);
346
+ if (leftBuffer.length !== rightBuffer.length) {
347
+ return false;
348
+ }
349
+ return cryptoTimingSafeEqual(leftBuffer, rightBuffer);
350
+ }
351
+ function cryptoTimingSafeEqual(left, right) {
352
+ let diff = 0;
353
+ for (let index = 0; index < left.length; index += 1) {
354
+ diff |= left[index] ^ right[index];
355
+ }
356
+ return diff === 0;
357
+ }
358
+ function setAuthCookie(res, token) {
359
+ res.setHeader("set-cookie", `nrdash=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/`);
360
+ }
361
+ function setBasicCookie(res, user, password) {
362
+ const value = Buffer.from(`${user}:${password}`).toString("base64");
363
+ res.setHeader("set-cookie", `nrdash_basic=${encodeURIComponent(value)}; HttpOnly; SameSite=Strict; Path=/`);
364
+ }
365
+ function parseCookies(cookieHeader) {
366
+ const cookies = {};
367
+ for (const part of cookieHeader.split(";")) {
368
+ const [key, ...valueParts] = part.trim().split("=");
369
+ if (key)
370
+ cookies[key] = decodeURIComponent(valueParts.join("=") ?? "");
371
+ }
372
+ return cookies;
373
+ }
374
+ async function readJsonBody(req) {
375
+ const chunks = [];
376
+ for await (const chunk of req) {
377
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
378
+ }
379
+ const text = Buffer.concat(chunks).toString("utf8").trim();
380
+ if (!text) {
381
+ return {};
382
+ }
383
+ return JSON.parse(text);
384
+ }
385
+ function sendJson(res, status, value) {
386
+ res.writeHead(status, JSON_HEADERS);
387
+ res.end(`${JSON.stringify(value)}\n`);
388
+ }
389
+ function sendText(res, status, text, contentType) {
390
+ res.writeHead(status, { "content-type": contentType });
391
+ res.end(text);
392
+ }
393
+ function sendFile(res, filePath, filename) {
394
+ res.writeHead(200, {
395
+ "content-type": "application/octet-stream",
396
+ "content-disposition": `attachment; filename="${filename.replace(/"/g, "")}"`,
397
+ });
398
+ createReadStream(filePath).pipe(res);
399
+ }
400
+ function stringField(value, key) {
401
+ const field = value[key];
402
+ if (typeof field !== "string" || !field.trim()) {
403
+ throw new Error(`${key} is required`);
404
+ }
405
+ return field.trim();
406
+ }
407
+ function optionalStringField(value, key) {
408
+ const field = value[key];
409
+ return typeof field === "string" && field.trim() ? field.trim() : undefined;
410
+ }
411
+ function objectRecord(value) {
412
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
413
+ return {};
414
+ }
415
+ return value;
416
+ }
417
+ function parseUploadFiles(value) {
418
+ if (!Array.isArray(value)) {
419
+ return [];
420
+ }
421
+ return value.map((item, index) => {
422
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
423
+ throw new Error(`files[${index}] must be an object`);
424
+ }
425
+ const record = item;
426
+ const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : `upload-${index + 1}`;
427
+ const mimeType = typeof record.mimeType === "string" ? record.mimeType.trim() : undefined;
428
+ const dataBase64 = typeof record.dataBase64 === "string" ? record.dataBase64 : "";
429
+ if (!dataBase64) {
430
+ throw new Error(`files[${index}].dataBase64 is required`);
431
+ }
432
+ return { name, mimeType, data: Buffer.from(stripDataUrlPrefix(dataBase64), "base64") };
433
+ });
434
+ }
435
+ function stripDataUrlPrefix(value) {
436
+ const comma = value.indexOf(",");
437
+ return value.startsWith("data:") && comma !== -1 ? value.slice(comma + 1) : value;
438
+ }
439
+ function numberParam(url, key, fallback) {
440
+ const value = Number(url.searchParams.get(key));
441
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
442
+ }
443
+ function requiredSearch(url, key) {
444
+ const value = url.searchParams.get(key);
445
+ if (!value) {
446
+ throw new Error(`${key} is required`);
447
+ }
448
+ return value;
449
+ }
450
+ function optionalEnv(key) {
451
+ const value = process.env[key]?.trim();
452
+ return value || undefined;
453
+ }
454
+ function requireArg(argv, index, flag) {
455
+ const value = argv[index];
456
+ if (!value || value.startsWith("--")) {
457
+ throw new Error(`${flag} requires a value`);
458
+ }
459
+ return value;
460
+ }
461
+ function shutdown() {
462
+ runtime.dispose();
463
+ server.close(() => process.exit(0));
464
+ }
465
+ function renderLoginPage(currentAuth) {
466
+ return `<!doctype html>
467
+ <html lang="en">
468
+ <head>
469
+ <meta charset="utf-8">
470
+ <meta name="viewport" content="width=device-width, initial-scale=1">
471
+ <title>NordRelay Login</title>
472
+ <style>
473
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f5f2;color:#181c19;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}
474
+ form{width:min(420px,calc(100vw - 32px));background:white;border:1px solid #dfe3dc;border-radius:8px;padding:24px;box-shadow:0 20px 60px rgba(20,30,24,.08)}
475
+ h1{font-size:24px;margin:0 0 8px}
476
+ p{color:#5d665d;margin:0 0 18px}
477
+ label{display:block;font-size:13px;color:#4b544d;margin:14px 0 6px}
478
+ input{box-sizing:border-box;width:100%;height:40px;border:1px solid #cfd6ce;border-radius:6px;padding:0 10px;font:inherit}
479
+ button{margin-top:18px;width:100%;height:42px;border:0;border-radius:6px;background:#205c43;color:white;font-weight:650;cursor:pointer}
480
+ .error{color:#9b1c1c;min-height:22px;margin-top:12px}
481
+ </style>
482
+ </head>
483
+ <body>
484
+ <form id="login">
485
+ <h1>NordRelay Dashboard</h1>
486
+ <p>${currentAuth.publicBind ? "Remote dashboard access requires authentication." : "Authentication required."}</p>
487
+ ${currentAuth.token ? '<label>Token</label><input id="token" name="token" type="password" autocomplete="current-password">' : ""}
488
+ ${currentAuth.user ? '<label>User</label><input id="user" name="user" autocomplete="username"><label>Password</label><input id="password" name="password" type="password" autocomplete="current-password">' : ""}
489
+ <button>Sign in</button>
490
+ <div class="error" id="error"></div>
491
+ </form>
492
+ <script>
493
+ document.getElementById('login').addEventListener('submit', async (event) => {
494
+ event.preventDefault();
495
+ const payload = {
496
+ token: document.getElementById('token')?.value || undefined,
497
+ user: document.getElementById('user')?.value || undefined,
498
+ password: document.getElementById('password')?.value || undefined,
499
+ };
500
+ const res = await fetch('/api/auth', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });
501
+ if (!res.ok) {
502
+ document.getElementById('error').textContent = 'Invalid credentials';
503
+ return;
504
+ }
505
+ if (payload.token) localStorage.setItem('nordrelayDashboardToken', payload.token);
506
+ location.href = '/';
507
+ });
508
+ </script>
509
+ </body>
510
+ </html>`;
511
+ }
512
+ function renderDashboardApp(options) {
513
+ return `<!doctype html>
514
+ <html lang="en">
515
+ <head>
516
+ <meta charset="utf-8">
517
+ <meta name="viewport" content="width=device-width, initial-scale=1">
518
+ <title>NordRelay Dashboard</title>
519
+ <script>document.documentElement.dataset.theme = localStorage.getItem('nordrelayTheme') || 'light';</script>
520
+ <style>${dashboardCss()}</style>
521
+ </head>
522
+ <body>
523
+ <div class="app">
524
+ <aside class="sidebar" id="sidebar">
525
+ <div class="brand"><span class="mark">NR</span><div><strong>NordRelay</strong><small>Remote control</small></div></div>
526
+ <nav>
527
+ <button data-page="overview" class="active">Overview</button>
528
+ <button data-page="chat">Chat</button>
529
+ <button data-page="sessions">Sessions</button>
530
+ <button data-page="queue">Queue</button>
531
+ <button data-page="artifacts">Artifacts</button>
532
+ <button data-page="settings">Settings</button>
533
+ <button data-page="logs">Logs</button>
534
+ <button data-page="diagnostics">Diagnostics</button>
535
+ </nav>
536
+ </aside>
537
+ <main>
538
+ <header>
539
+ <button class="menu" id="menuBtn">Menu</button>
540
+ <div>
541
+ <h1 id="pageTitle">Overview</h1>
542
+ <p id="sessionLine">Loading session...</p>
543
+ </div>
544
+ <div class="header-actions">
545
+ <select id="agentSelect"></select>
546
+ <button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
547
+ <button id="refreshBtn">Refresh</button>
548
+ </div>
549
+ </header>
550
+
551
+ <section class="page active" id="page-overview">
552
+ <div class="metrics" id="metrics"></div>
553
+ <div class="stack">
554
+ <div class="panel"><h2>Current Session</h2><pre id="sessionText"></pre></div>
555
+ <div class="panel"><h2>Adapters</h2><div id="adapters"></div></div>
556
+ </div>
557
+ </section>
558
+
559
+ <section class="page" id="page-chat">
560
+ <div class="chat-layout">
561
+ <div class="panel chat-panel">
562
+ <div class="chat-toolbar">
563
+ <button id="newSessionBtn">New session</button>
564
+ <button id="abortBtn">Abort</button>
565
+ <button id="handbackBtn">Handback</button>
566
+ </div>
567
+ <div id="messages" class="messages"></div>
568
+ <form id="promptForm" class="composer">
569
+ <div class="composer-fields">
570
+ <textarea id="promptInput" placeholder="Send a message to the active coding agent..." rows="3"></textarea>
571
+ <div class="attachment-row">
572
+ <label class="file-button" for="fileInput">Attach files</label>
573
+ <input id="fileInput" type="file" multiple>
574
+ <span id="fileSummary">No files selected</span>
575
+ <button type="button" id="clearFilesBtn" class="secondary">Clear</button>
576
+ </div>
577
+ </div>
578
+ <button>Send</button>
579
+ </form>
580
+ </div>
581
+ <div class="panel side-panel"><h2>Tools / Plan</h2><div id="toolStream" class="tool-stream"></div></div>
582
+ </div>
583
+ </section>
584
+
585
+ <section class="page" id="page-sessions">
586
+ <div class="panel">
587
+ <div class="sessions-toolbar">
588
+ <div class="row search-row"><input id="sessionSearch" placeholder="Search sessions"><button id="sessionSearchBtn">Search</button></div>
589
+ <div class="row attach-row"><input id="attachInput" placeholder="Thread ID to attach/switch"><button id="attachBtn">Attach</button></div>
590
+ </div>
591
+ <div id="sessionsList" class="list"></div>
592
+ <div id="sessionsPager" class="pager"></div>
593
+ </div>
594
+ </section>
595
+
596
+ <section class="page" id="page-queue">
597
+ <div class="panel">
598
+ <div class="row"><button data-queue="pause">Pause</button><button data-queue="resume">Resume</button><button data-queue="clear">Clear</button></div>
599
+ <div id="queueList" class="list"></div>
600
+ </div>
601
+ </section>
602
+
603
+ <section class="page" id="page-artifacts">
604
+ <div class="panel">
605
+ <div class="row"><button id="reloadArtifactsBtn">Reload artifacts</button></div>
606
+ <div id="artifactList" class="list"></div>
607
+ </div>
608
+ </section>
609
+
610
+ <section class="page" id="page-settings">
611
+ <div class="panel">
612
+ <div class="row"><button id="saveSettingsBtn">Save settings</button><span id="settingsStatus"></span></div>
613
+ <div id="settingsTabs" class="tabs"></div>
614
+ <div id="settingsForm" class="settings-grid"></div>
615
+ </div>
616
+ </section>
617
+
618
+ <section class="page" id="page-logs">
619
+ <div class="panel">
620
+ <div class="row"><select id="logTarget"><option value="connector">Connector</option><option value="update">Update</option></select><input id="logLines" type="number" value="120" min="1" max="300"><button id="loadLogsBtn">Load logs</button></div>
621
+ <pre id="logs"></pre>
622
+ </div>
623
+ </section>
624
+
625
+ <section class="page" id="page-diagnostics">
626
+ <div class="panel"><pre id="diagnostics"></pre></div>
627
+ </section>
628
+
629
+ <footer>
630
+ <span id="footerVersion">NordRelay</span>
631
+ <span id="footerHealth">Health: loading</span>
632
+ <span>Dashboard bind: ${escapeHTML(options.authRequired ? "authenticated" : "local")}</span>
633
+ </footer>
634
+ </main>
635
+ </div>
636
+ <div id="toast"></div>
637
+ <script>${dashboardJs()}</script>
638
+ </body>
639
+ </html>`;
640
+ }
641
+ function dashboardCss() {
642
+ return `
643
+ :root{color-scheme:light;--bg:#f4f6f2;--surface:#ffffff;--surface-soft:#fbfcf8;--text:#18201b;--muted:#5d675f;--border:#dce3d9;--border-soft:#e7ede4;--sidebar:#17251d;--sidebar-text:#f4f8f2;--sidebar-muted:#aebcaf;--accent:#235c42;--accent-strong:#17452f;--accent-soft:#dff5e8;--warn:#fff7da;--danger:#9b1c1c;--pre:#111812;--pre-text:#f3f7ef;--shadow:0 8px 24px rgba(24,32,27,.04);--link:#1d6a4c}
644
+ :root[data-theme="dark"]{color-scheme:dark;--bg:#101411;--surface:#171d19;--surface-soft:#1d251f;--text:#edf4ee;--muted:#a7b3aa;--border:#2d3830;--border-soft:#263128;--sidebar:#0c120f;--sidebar-text:#edf7ef;--sidebar-muted:#8da091;--accent:#4fa876;--accent-strong:#64bd89;--accent-soft:#173d2a;--warn:#3b3216;--danger:#cc4b4b;--pre:#070a08;--pre-text:#e8f1ea;--shadow:0 10px 28px rgba(0,0,0,.22);--link:#75c99a}
645
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}.app{min-height:100vh;display:grid;grid-template-columns:260px 1fr}.sidebar{background:var(--sidebar);color:var(--sidebar-text);padding:18px;display:flex;flex-direction:column;gap:22px}.brand{display:flex;align-items:center;gap:12px}.mark{display:grid;place-items:center;width:38px;height:38px;border-radius:8px;background:#d7ffe5;color:#173d29;font-weight:800}.brand small{display:block;color:var(--sidebar-muted)}nav{display:flex;flex-direction:column;gap:6px}nav button,.menu{border:0;border-radius:6px;padding:10px 12px;background:transparent;color:inherit;text-align:left;font:inherit;cursor:pointer}nav button.active,nav button:hover{background:color-mix(in srgb,var(--accent) 35%,transparent)}main{min-width:0;display:flex;flex-direction:column}header{position:sticky;top:0;z-index:5;display:flex;justify-content:space-between;gap:16px;align-items:center;padding:16px 22px;background:color-mix(in srgb,var(--surface) 92%,transparent);backdrop-filter:blur(12px);border-bottom:1px solid var(--border)}h1{font-size:24px;margin:0}h2{font-size:16px;margin:0 0 12px}p{margin:4px 0 0;color:var(--muted)}a{color:var(--link)}.header-actions,.row,.chat-toolbar,.attachment-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.menu{display:none;background:var(--surface-soft);color:var(--text)}.page{display:none;padding:22px}.page.active{display:block}.stack{display:flex;flex-direction:column;gap:16px}.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:16px}.metric,.panel{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px;box-shadow:var(--shadow)}.metric .label{font-size:12px;text-transform:uppercase;color:var(--muted)}.metric .value{font-size:22px;font-weight:750;margin-top:4px;overflow:hidden;text-overflow:ellipsis}button,select,input,textarea{border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font:inherit}button{height:36px;padding:0 12px;background:var(--accent);color:white;border-color:var(--accent);cursor:pointer}button:hover{background:var(--accent-strong)}button.secondary{background:var(--surface);color:var(--text)}input,select{height:36px;padding:0 10px}textarea{width:100%;padding:10px;resize:vertical}.chat-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px}.chat-panel{min-height:calc(100vh - 170px);display:flex;flex-direction:column}.messages{flex:1;min-height:360px;overflow:auto;border:1px solid var(--border-soft);border-radius:8px;padding:12px;background:var(--surface-soft)}.message{margin:0 0 12px;padding:10px 12px;border-radius:8px;max-width:92%;white-space:pre-wrap;word-break:break-word}.message.user{margin-left:auto;background:var(--accent-soft)}.message.agent{background:color-mix(in srgb,var(--surface-soft) 80%,var(--border))}.message.system{background:var(--warn)}.composer{display:grid;grid-template-columns:1fr auto;gap:10px;margin-top:12px}.composer-fields{min-width:0}.composer button{height:auto;min-width:90px}.attachment-row{margin-top:8px;color:var(--muted);font-size:13px}.file-button{display:inline-flex;align-items:center;height:34px;padding:0 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);cursor:pointer}input[type=file]{display:none}.sessions-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}.sessions-toolbar .search-row{flex:1 1 320px}.sessions-toolbar .attach-row{flex:1 1 360px;justify-content:flex-end;margin-left:auto}.sessions-toolbar input{min-width:220px}.copy-id{height:auto;padding:0;border:0;background:transparent;color:var(--link);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}.copy-id:hover{background:transparent;text-decoration:underline}.tool-stream{display:flex;flex-direction:column;gap:8px}.tool{border:1px solid var(--border-soft);border-radius:6px;padding:8px;background:var(--surface-soft)}.list{display:flex;flex-direction:column;gap:8px;margin-top:12px}.item{border:1px solid var(--border-soft);border-radius:8px;padding:12px;background:var(--surface-soft)}.item strong{display:block;overflow-wrap:anywhere}.item small{display:block;color:var(--muted);overflow-wrap:anywhere}.settings-grid{display:block}.setting{border:1px solid var(--border-soft);border-radius:8px;padding:12px;margin-bottom:10px;background:var(--surface-soft)}.setting label{display:block;font-size:13px;font-weight:700;margin-bottom:6px}.setting small{display:block;color:var(--muted);margin-top:6px}.setting input,.setting textarea,.setting select{width:100%}.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.tabs button{background:var(--surface);color:var(--text);border-color:var(--border);height:34px}.tabs button.active{background:var(--accent);color:white;border-color:var(--accent)}.pager{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-top:12px;color:var(--muted)}.pager-actions{display:flex;gap:8px}.pager button:disabled{opacity:.45;cursor:not-allowed}pre{white-space:pre-wrap;word-break:break-word;background:var(--pre);color:var(--pre-text);border-radius:8px;padding:14px;overflow:auto}footer{margin-top:auto;display:flex;gap:18px;flex-wrap:wrap;padding:14px 22px;border-top:1px solid var(--border);color:var(--muted);background:var(--surface)}#toast{position:fixed;right:18px;bottom:18px;display:none;background:var(--accent);color:white;border-radius:8px;padding:12px 14px;max-width:360px}.danger{background:var(--danger);border-color:var(--danger);color:white}@media(max-width:860px){.app{display:block}.sidebar{position:fixed;inset:0 auto 0 0;width:270px;transform:translateX(-100%);transition:.18s transform;z-index:20}.sidebar.open{transform:translateX(0)}.menu{display:inline-block}.header-actions{justify-content:flex-end}.page{padding:14px}.chat-layout{grid-template-columns:1fr}.composer{grid-template-columns:1fr}.composer button{height:40px}.side-panel{order:-1}header{align-items:flex-start}.metrics{grid-template-columns:1fr 1fr}}@media(max-width:560px){.metrics{grid-template-columns:1fr}.row{align-items:stretch}.row>*{width:100%}header{display:grid;grid-template-columns:auto 1fr}.header-actions{grid-column:1/3}.message{max-width:100%}.pager{align-items:stretch}.pager-actions,.pager button{width:100%}.attachment-row>*,.sessions-toolbar,.sessions-toolbar .row,.sessions-toolbar input,.sessions-toolbar button{width:100%}.sessions-toolbar .attach-row{margin-left:0;justify-content:stretch}}
646
+ `;
647
+ }
648
+ function dashboardJs() {
649
+ return `
650
+ const token = localStorage.getItem('nordrelayDashboardToken') || '';
651
+ const state = { snapshot:null, settings:[], currentPage:'overview', currentAgent:null, settingsGroup:null };
652
+ const authHeaders = () => token ? { authorization: 'Bearer ' + token } : {};
653
+ async function api(path, options={}) {
654
+ const headers = { ...(options.body ? {'content-type':'application/json'} : {}), ...authHeaders(), ...(options.headers||{}) };
655
+ const res = await fetch(path, { ...options, headers });
656
+ if (res.status === 401) { location.reload(); return; }
657
+ const text = await res.text();
658
+ const data = text ? JSON.parse(text) : {};
659
+ if (!res.ok) throw new Error(data.error || res.statusText);
660
+ return data;
661
+ }
662
+ function toast(msg){const el=document.getElementById('toast');el.textContent=msg;el.style.display='block';setTimeout(()=>el.style.display='none',3500)}
663
+ function esc(s){return String(s??'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}
664
+ function attr(s){return esc(s).replace(/"/g,'&quot;')}
665
+ function short(s,max=250){const text=String(s??'');return text.length>max?text.slice(0,max-1)+'...':text}
666
+ async function copyText(text){if(!text)return;try{await navigator.clipboard.writeText(text)}catch{const area=document.createElement('textarea');area.value=text;area.style.position='fixed';area.style.opacity='0';document.body.appendChild(area);area.select();document.execCommand('copy');area.remove()}toast('Thread ID copied')}
667
+ function fmtDate(s){return s?new Date(s).toLocaleString(): '-'}
668
+ function fmtBytes(n){if(n<1024)return n+' B';if(n<1048576)return (n/1024).toFixed(1).replace(/\\.0$/,'')+' KB';return (n/1048576).toFixed(1).replace(/\\.0$/,'')+' MB'}
669
+ function applyTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('nordrelayTheme',theme);document.getElementById('themeBtn').textContent=theme==='dark'?'Light':'Dark'}
670
+ function toggleTheme(){applyTheme(document.documentElement.dataset.theme==='dark'?'light':'dark')}
671
+ function page(name){state.currentPage=name;document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active',b.dataset.page===name));document.querySelectorAll('.page').forEach(p=>p.classList.toggle('active',p.id==='page-'+name));document.getElementById('pageTitle').textContent=name[0].toUpperCase()+name.slice(1);document.getElementById('sidebar').classList.remove('open'); if(name==='sessions') loadSessions(); if(name==='settings') loadSettings(); if(name==='logs') loadLogs(); if(name==='diagnostics') loadDiagnostics(); if(name==='artifacts') loadArtifacts();}
672
+ document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>page(b.dataset.page));
673
+ document.getElementById('menuBtn').onclick=()=>document.getElementById('sidebar').classList.toggle('open');
674
+ document.getElementById('refreshBtn').onclick=()=>loadBootstrap();
675
+ document.getElementById('themeBtn').onclick=toggleTheme;
676
+ applyTheme(localStorage.getItem('nordrelayTheme') || 'light');
677
+
678
+ function createPaginator(containerId, onChange, pageSize=50){
679
+ const container=document.getElementById(containerId);
680
+ return {
681
+ page:1,
682
+ pageSize,
683
+ reset(){this.page=1},
684
+ render(meta={}){
685
+ const hasPrevious=Boolean(meta.hasPrevious);
686
+ const hasNext=Boolean(meta.hasNext);
687
+ container.innerHTML='<span>Page '+this.page+' / '+this.pageSize+' per page</span><div class="pager-actions"><button data-page-action="prev" '+(!hasPrevious?'disabled':'')+'>Previous</button><button data-page-action="next" '+(!hasNext?'disabled':'')+'>Next</button></div>';
688
+ const prev=container.querySelector('[data-page-action="prev"]');
689
+ const next=container.querySelector('[data-page-action="next"]');
690
+ prev.onclick=()=>{if(hasPrevious){this.page-=1;onChange()}};
691
+ next.onclick=()=>{if(hasNext){this.page+=1;onChange()}};
692
+ }
693
+ };
694
+ }
695
+ const sessionsPager=createPaginator('sessionsPager',()=>loadSessions(false),50);
696
+
697
+ async function loadBootstrap(){
698
+ const data = await api('/api/bootstrap');
699
+ state.snapshot = data.status.snapshot;
700
+ renderSnapshot(state.snapshot);
701
+ renderAdapters(data.channels, data.agentAdapters);
702
+ document.getElementById('footerVersion').textContent='NordRelay '+(data.status.health?.version || '');
703
+ document.getElementById('footerHealth').textContent='Health: '+(data.status.health?.state?.status || 'unknown');
704
+ const agentSelect=document.getElementById('agentSelect');
705
+ agentSelect.innerHTML=data.enabledAgents.map(a=>'<option value="'+a+'">'+a+'</option>').join('');
706
+ agentSelect.value=state.snapshot.session.agentId;
707
+ agentSelect.onchange=async()=>{await api('/api/agent',{method:'POST',body:JSON.stringify({agentId:agentSelect.value})});toast('Agent switched');loadBootstrap()};
708
+ }
709
+ function renderSnapshot(s){
710
+ document.getElementById('sessionLine').textContent=(s.session.agentLabel||'Agent')+' / '+(s.session.model||'default')+' / '+(s.session.threadId||'not started');
711
+ document.getElementById('sessionText').textContent=s.sessionText||'';
712
+ document.getElementById('metrics').innerHTML=[
713
+ ['Status',s.processing?'working':'idle'],['Agent',s.session.agentLabel],['Queue',s.queue.length],['Workspace',s.session.workspace],['Thread',s.session.threadId||'not started'],['Reasoning',s.session.reasoningEffort||'default']
714
+ ].map(([k,v])=>'<div class="metric"><div class="label">'+esc(k)+'</div><div class="value">'+esc(v)+'</div></div>').join('');
715
+ renderQueue(s.queue);
716
+ }
717
+ function renderAdapters(channels, agents){
718
+ document.getElementById('adapters').innerHTML='<div class="list">'+[...channels.map(c=>'<div class="item"><strong>'+esc(c.label)+' - '+esc(c.status)+'</strong><small>'+esc(c.capabilities.join(', '))+'</small></div>'),...agents.map(a=>'<div class="item"><strong>'+esc(a.label)+' - '+esc(a.status)+'</strong><small>'+esc(a.notes||a.envFlag||'available')+'</small></div>')].join('')+'</div>';
719
+ }
720
+ function appendMessage(cls,text){const box=document.getElementById('messages');const div=document.createElement('div');div.className='message '+cls;div.textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div}
721
+ let currentAgentMessage=null;
722
+ function connectEvents(){
723
+ const qs = token ? '?token='+encodeURIComponent(token) : '';
724
+ const events = new EventSource('/api/events'+qs);
725
+ events.addEventListener('snapshot', e=>{const d=JSON.parse(e.data).data;state.snapshot=d;renderSnapshot(d)});
726
+ events.addEventListener('session_update', e=>{loadBootstrap()});
727
+ events.addEventListener('queue_update', e=>renderQueue(JSON.parse(e.data).queue));
728
+ events.addEventListener('turn_start', e=>{const d=JSON.parse(e.data);appendMessage('user',d.prompt);currentAgentMessage=appendMessage('agent','')});
729
+ events.addEventListener('text_delta', e=>{const d=JSON.parse(e.data);if(!currentAgentMessage)currentAgentMessage=appendMessage('agent','');currentAgentMessage.textContent+=d.delta;currentAgentMessage.scrollIntoView({block:'end'})});
730
+ events.addEventListener('tool_start', e=>{const d=JSON.parse(e.data);tool('tool','Started '+d.toolName)});
731
+ events.addEventListener('tool_update', e=>{const d=JSON.parse(e.data);if(d.partialResult)tool('tool',d.partialResult.slice(-600))});
732
+ events.addEventListener('tool_end', e=>{const d=JSON.parse(e.data);tool(d.isError?'danger':'tool','Finished '+d.toolCallId+(d.isError?' with error':''))});
733
+ events.addEventListener('todo_update', e=>{const d=JSON.parse(e.data);tool('tool','Plan:\\n'+d.items.map(i=>(i.completed?'[x] ':'[ ] ')+i.text).join('\\n'))});
734
+ events.addEventListener('turn_error', e=>{const d=JSON.parse(e.data);appendMessage('system','Error: '+d.error);currentAgentMessage=null});
735
+ events.addEventListener('turn_complete', ()=>{currentAgentMessage=null;loadBootstrap()});
736
+ events.addEventListener('status', e=>{const d=JSON.parse(e.data);toast(d.message)});
737
+ events.onerror=()=>{};
738
+ }
739
+ function tool(cls,text){const div=document.createElement('div');div.className='tool '+(cls==='danger'?'danger':'');div.textContent=text;document.getElementById('toolStream').prepend(div)}
740
+ let selectedFiles=[];
741
+ function renderSelectedFiles(){const summary=document.getElementById('fileSummary');if(selectedFiles.length===0){summary.textContent='No files selected';return}const names=selectedFiles.slice(0,3).map(f=>f.name || 'file').join(', ');const more=selectedFiles.length>3?' +'+(selectedFiles.length-3)+' more':'';const bytes=selectedFiles.reduce((sum,file)=>sum+file.size,0);summary.textContent=names+more+' ('+fmtBytes(bytes)+')'}
742
+ async function filePayload(file){return {name:file.name || 'upload',mimeType:file.type || 'application/octet-stream',dataBase64:await fileToBase64(file)}}
743
+ async function fileToBase64(file){const buffer=await file.arrayBuffer();const bytes=new Uint8Array(buffer);let binary='';const chunk=0x8000;for(let i=0;i<bytes.length;i+=chunk){binary+=String.fromCharCode(...bytes.subarray(i,i+chunk))}return btoa(binary)}
744
+ document.getElementById('fileInput').onchange=e=>{selectedFiles=Array.from(e.target.files||[]);renderSelectedFiles()};
745
+ document.getElementById('clearFilesBtn').onclick=()=>{selectedFiles=[];document.getElementById('fileInput').value='';renderSelectedFiles()};
746
+ document.getElementById('promptForm').onsubmit=async e=>{e.preventDefault();const input=document.getElementById('promptInput');const text=input.value.trim();if(!text&&selectedFiles.length===0)return;const files=selectedFiles;input.value='';selectedFiles=[];document.getElementById('fileInput').value='';renderSelectedFiles();const payloadFiles=files.length?await Promise.all(files.map(filePayload)):[];const r=files.length?await api('/api/prompt/upload',{method:'POST',body:JSON.stringify({text,files:payloadFiles})}):await api('/api/prompt',{method:'POST',body:JSON.stringify({text})});if(r.transcribeOnly)appendMessage('system','Transcribed audio:\\n'+(r.transcript||'(empty)'));else if(r.queued)appendMessage('system','Queued prompt '+r.queueId)};
747
+ document.getElementById('newSessionBtn').onclick=async()=>{await api('/api/sessions/new',{method:'POST',body:'{}'});toast('New session started');loadBootstrap()};
748
+ document.getElementById('abortBtn').onclick=async()=>{await api('/api/abort',{method:'POST'});toast('Abort sent')};
749
+ document.getElementById('handbackBtn').onclick=async()=>{const r=await api('/api/handback',{method:'POST'});appendMessage('system','Handback command:\\n'+(r.command||'No command available'))};
750
+ async function loadSessions(reset=true){if(reset)sessionsPager.reset();const q=document.getElementById('sessionSearch').value||'';const data=await api('/api/sessions?query='+encodeURIComponent(q)+'&page='+sessionsPager.page+'&limit='+sessionsPager.pageSize);document.getElementById('sessionsList').innerHTML=data.sessions.map(s=>'<div class="item"><strong title="'+attr(s.title||s.firstUserMessage||s.id)+'">'+esc(short(s.title||s.firstUserMessage||s.id))+'</strong><small><button type="button" class="copy-id" data-copy-id="'+attr(s.id)+'" title="Copy thread ID">'+esc(short(s.id,64))+'</button> / '+esc(short((s.cwd||'')+' / '+fmtDate(s.updatedAt)))+'</small><div class="row"><button data-switch="'+attr(s.id)+'">Switch</button></div></div>').join('')||'<div class="item">No sessions found.</div>';sessionsPager.render(data.pagination||{});document.querySelectorAll('[data-copy-id]').forEach(b=>b.onclick=()=>copyText(b.dataset.copyId||''));document.querySelectorAll('[data-switch]').forEach(b=>b.onclick=async()=>{await api('/api/sessions/switch',{method:'POST',body:JSON.stringify({threadId:b.dataset.switch})});toast('Session switched');loadBootstrap()})}
751
+ document.getElementById('sessionSearchBtn').onclick=()=>loadSessions(true);document.getElementById('sessionSearch').addEventListener('keydown',e=>{if(e.key==='Enter')loadSessions(true)});document.getElementById('attachBtn').onclick=async()=>{const threadId=document.getElementById('attachInput').value.trim();if(threadId){await api('/api/sessions/attach',{method:'POST',body:JSON.stringify({threadId})});toast('Session attached');loadBootstrap()}};
752
+ function renderQueue(queue){document.getElementById('queueList').innerHTML=(queue||[]).map(q=>'<div class="item"><strong>'+esc(q.id)+' - '+esc(q.description)+'</strong><small>Created '+fmtDate(q.createdAt)+' / attempts '+q.attempts+'</small><div class="row"><button data-q="run" data-id="'+q.id+'">Run</button><button data-q="top" data-id="'+q.id+'">Top</button><button data-q="cancel" data-id="'+q.id+'" class="danger">Cancel</button></div></div>').join('')||'<div class="item">Queue is empty.</div>';document.querySelectorAll('[data-q]').forEach(b=>b.onclick=async()=>{const queue=(await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.q,id:b.dataset.id})})).queue;renderQueue(queue)})}
753
+ document.querySelectorAll('[data-queue]').forEach(b=>b.onclick=async()=>renderQueue((await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.queue})})).queue));
754
+ async function loadArtifacts(){const data=await api('/api/artifacts');document.getElementById('artifactList').innerHTML=data.reports.map(r=>'<div class="item"><strong>'+esc(r.turnId)+' - '+r.fileCount+' files - '+fmtBytes(r.totalSizeBytes)+'</strong><small>'+fmtDate(r.updatedAt)+'</small><div class="row"><a href="/api/artifacts/zip?turnId='+encodeURIComponent(r.turnId)+(token?'&token='+encodeURIComponent(token):'')+'">Download ZIP</a><button data-del-art="'+esc(r.turnId)+'" class="danger">Delete</button></div>'+r.artifacts.slice(0,8).map(a=>'<small><a href="/api/artifacts/file?turnId='+encodeURIComponent(r.turnId)+'&path='+encodeURIComponent(a.relativePath)+(token?'&token='+encodeURIComponent(token):'')+'">'+esc(a.name)+'</a> '+fmtBytes(a.sizeBytes)+'</small>').join('')+'</div>').join('')||'<div class="item">No artifacts.</div>';document.querySelectorAll('[data-del-art]').forEach(b=>b.onclick=async()=>{await api('/api/artifacts?turnId='+encodeURIComponent(b.dataset.delArt),{method:'DELETE'});loadArtifacts()})}
755
+ document.getElementById('reloadArtifactsBtn').onclick=loadArtifacts;
756
+ async function loadSettings(){const data=await api('/api/settings');state.settings=data.settings;renderSettings()}
757
+ function renderSettings(){const groups={};state.settings.forEach(s=>(groups[s.group]??=[]).push(s));const names=Object.keys(groups);if(!state.settingsGroup||!groups[state.settingsGroup])state.settingsGroup=names[0];document.getElementById('settingsTabs').innerHTML=names.map(name=>'<button data-setting-tab="'+attr(name)+'" class="'+(name===state.settingsGroup?'active':'')+'">'+esc(name)+' ('+groups[name].length+')</button>').join('');document.querySelectorAll('[data-setting-tab]').forEach(b=>b.onclick=()=>{state.settingsGroup=b.dataset.settingTab;renderSettings()});const items=groups[state.settingsGroup]||[];document.getElementById('settingsForm').innerHTML='<div class="settings-section"><h2>'+esc(state.settingsGroup||'Settings')+'</h2>'+items.map(s=>'<div class="setting"><label>'+esc(s.label)+'</label>'+settingInput(s)+'<small>'+esc(s.key)+' - '+esc(s.description)+(s.restartRequired?' Restart required.':'')+'</small></div>').join('')+'</div>'}
758
+ function settingInput(s){const value=esc(s.value||''); if(s.kind==='boolean')return '<select data-setting="'+s.key+'"><option value=""></option><option '+(s.value==='true'?'selected':'')+'>true</option><option '+(s.value==='false'?'selected':'')+'>false</option></select>'; if(s.kind==='json')return '<textarea rows="4" data-setting="'+s.key+'">'+value+'</textarea>'; return '<input data-setting="'+s.key+'" value="'+value+'" '+(s.kind==='secret'?'type="password"':'')+'>'}
759
+ document.getElementById('saveSettingsBtn').onclick=async()=>{const patch={};document.querySelectorAll('[data-setting]').forEach(el=>patch[el.dataset.setting]=el.value);const r=await api('/api/settings',{method:'PATCH',body:JSON.stringify({settings:patch})});document.getElementById('settingsStatus').textContent=r.changedKeys.length?'Saved '+r.changedKeys.length+' setting(s)'+(r.restartRequired?' - restart required':''):'No changes';toast('Settings saved')};
760
+ async function loadLogs(){const target=document.getElementById('logTarget').value;const lines=document.getElementById('logLines').value;const data=await api('/api/logs?target='+target+'&lines='+lines);document.getElementById('logs').textContent=data.plain||'(empty)'}document.getElementById('loadLogsBtn').onclick=loadLogs;
761
+ async function loadDiagnostics(){document.getElementById('diagnostics').textContent=JSON.stringify(await api('/api/diagnostics'),null,2)}
762
+ loadBootstrap().then(()=>{connectEvents();loadSessions();loadArtifacts();loadSettings();loadLogs();loadDiagnostics()}).catch(err=>toast(err.message));
763
+ `;
764
+ }