@nordbyte/nordrelay 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.env.example +45 -2
  2. package/README.md +227 -47
  3. package/dist/agent-activity.js +300 -0
  4. package/dist/agent-adapter.js +17 -30
  5. package/dist/agent-factory.js +27 -0
  6. package/dist/agent.js +123 -9
  7. package/dist/artifacts.js +1 -1
  8. package/dist/audit-log.js +1 -1
  9. package/dist/bot-ui.js +1 -1
  10. package/dist/bot.js +333 -161
  11. package/dist/claude-code-auth.js +121 -0
  12. package/dist/claude-code-cli.js +19 -0
  13. package/dist/claude-code-launch.js +73 -0
  14. package/dist/claude-code-session.js +660 -0
  15. package/dist/claude-code-state.js +590 -0
  16. package/dist/codex-session.js +15 -2
  17. package/dist/config.js +113 -9
  18. package/dist/context-key.js +23 -0
  19. package/dist/hermes-api.js +150 -0
  20. package/dist/hermes-auth.js +96 -0
  21. package/dist/hermes-cli.js +19 -0
  22. package/dist/hermes-launch.js +57 -0
  23. package/dist/hermes-session.js +477 -0
  24. package/dist/hermes-state.js +609 -0
  25. package/dist/index.js +51 -8
  26. package/dist/openclaw-auth.js +27 -0
  27. package/dist/openclaw-cli.js +19 -0
  28. package/dist/openclaw-gateway.js +285 -0
  29. package/dist/openclaw-launch.js +65 -0
  30. package/dist/openclaw-session.js +549 -0
  31. package/dist/openclaw-state.js +409 -0
  32. package/dist/operations.js +84 -3
  33. package/dist/pi-auth.js +59 -0
  34. package/dist/pi-launch.js +61 -0
  35. package/dist/pi-rpc.js +18 -0
  36. package/dist/pi-session.js +103 -15
  37. package/dist/pi-state.js +253 -0
  38. package/dist/relay-runtime.js +1073 -22
  39. package/dist/session-format.js +28 -18
  40. package/dist/session-registry.js +43 -18
  41. package/dist/settings-service.js +80 -26
  42. package/dist/state-backend.js +17 -8
  43. package/dist/web-dashboard-ui.js +18 -0
  44. package/dist/web-dashboard.js +463 -55
  45. package/dist/web-state.js +131 -0
  46. package/docker-compose.yml +1 -1
  47. package/package.json +8 -3
  48. package/plugins/nordrelay/.codex-plugin/plugin.json +7 -4
  49. package/plugins/nordrelay/commands/remote.md +2 -2
  50. package/plugins/nordrelay/scripts/nordrelay.mjs +173 -20
  51. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +2 -2
  52. package/CHANGELOG.md +0 -17
@@ -12,8 +12,9 @@ import { friendlyErrorText } from "./error-messages.js";
12
12
  import { escapeHTML } from "./format.js";
13
13
  import { RelayRuntime } from "./relay-runtime.js";
14
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" };
15
+ import { renderDashboardNav } from "./web-dashboard-ui.js";
16
+ const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
17
+ const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
17
18
  const options = parseOptions(process.argv.slice(2));
18
19
  const auth = resolveDashboardAuth(options.host);
19
20
  if (auth.publicBind && !auth.token && !(auth.user && auth.password)) {
@@ -81,16 +82,72 @@ async function handleApi(req, res, url) {
81
82
  channels: listChannelDescriptors(),
82
83
  agentAdapters: listAgentAdapterDescriptors(),
83
84
  enabledAgents: enabledAgents(config),
85
+ controls: await runtime.controlOptions(),
84
86
  status: await runtime.status(),
85
87
  });
86
88
  return;
87
89
  }
90
+ if (req.method === "GET" && url.pathname === "/api/control-options") {
91
+ sendJson(res, 200, await runtime.controlOptions(parseAgentId(url.searchParams.get("agent") ?? undefined)));
92
+ return;
93
+ }
88
94
  if (req.method === "GET" && url.pathname === "/api/health") {
89
95
  sendJson(res, 200, await runtime.status());
90
96
  return;
91
97
  }
98
+ if (req.method === "GET" && url.pathname === "/api/version") {
99
+ sendJson(res, 200, await runtime.version());
100
+ return;
101
+ }
102
+ if (req.method === "POST" && url.pathname === "/api/update") {
103
+ sendJson(res, 202, runtime.updateConnector());
104
+ return;
105
+ }
106
+ if (req.method === "GET" && (url.pathname === "/api/tasks" || url.pathname === "/api/progress")) {
107
+ sendJson(res, 200, runtime.tasks());
108
+ return;
109
+ }
110
+ if (req.method === "GET" && url.pathname === "/api/adapters/health") {
111
+ sendJson(res, 200, { adapters: await runtime.adapterHealth() });
112
+ return;
113
+ }
114
+ if (req.method === "GET" && url.pathname === "/api/permissions") {
115
+ sendJson(res, 200, runtime.permissions());
116
+ return;
117
+ }
118
+ if (req.method === "GET" && url.pathname === "/api/audit") {
119
+ sendJson(res, 200, { events: runtime.audit(numberParam(url, "limit", 50)) });
120
+ return;
121
+ }
122
+ if (req.method === "GET" && url.pathname === "/api/locks") {
123
+ sendJson(res, 200, { locks: runtime.locks() });
124
+ return;
125
+ }
126
+ if (req.method === "POST" && url.pathname === "/api/locks") {
127
+ const body = await readJsonBody(req);
128
+ sendJson(res, 200, { lock: runtime.lockWebSession(optionalStringField(body, "ownerName")), locks: runtime.locks() });
129
+ return;
130
+ }
131
+ if (req.method === "DELETE" && url.pathname === "/api/locks") {
132
+ sendJson(res, 200, runtime.unlockWebSession());
133
+ return;
134
+ }
135
+ if (req.method === "GET" && url.pathname === "/api/auth/status") {
136
+ sendJson(res, 200, await runtime.authStatus(parseAgentId(url.searchParams.get("agent") ?? undefined)));
137
+ return;
138
+ }
139
+ if (req.method === "POST" && url.pathname === "/api/auth/login") {
140
+ const body = await readJsonBody(req);
141
+ sendJson(res, 200, await runtime.login(parseAgentId(optionalStringField(body, "agentId"))));
142
+ return;
143
+ }
144
+ if (req.method === "POST" && url.pathname === "/api/auth/logout") {
145
+ const body = await readJsonBody(req);
146
+ sendJson(res, 200, await runtime.logout(parseAgentId(optionalStringField(body, "agentId"))));
147
+ return;
148
+ }
92
149
  if (req.method === "GET" && url.pathname === "/api/settings") {
93
- sendJson(res, 200, await settings.snapshot());
150
+ sendJson(res, 200, await settings.snapshot(process.env, activeSettingsValues(config)));
94
151
  return;
95
152
  }
96
153
  if (req.method === "PATCH" && url.pathname === "/api/settings") {
@@ -119,8 +176,12 @@ async function handleApi(req, res, url) {
119
176
  const body = await readJsonBody(req);
120
177
  sendJson(res, 200, {
121
178
  session: await runtime.newSession({
179
+ agentId: parseAgentId(optionalStringField(body, "agentId")),
122
180
  workspace: optionalStringField(body, "workspace"),
123
181
  model: optionalStringField(body, "model"),
182
+ reasoningEffort: optionalStringField(body, "reasoningEffort"),
183
+ launchProfileId: optionalStringField(body, "launchProfileId"),
184
+ fastMode: optionalBooleanField(body, "fastMode"),
124
185
  }),
125
186
  });
126
187
  return;
@@ -135,6 +196,10 @@ async function handleApi(req, res, url) {
135
196
  sendJson(res, 200, { session: await runtime.attachSession(stringField(body, "threadId")) });
136
197
  return;
137
198
  }
199
+ if (req.method === "GET" && url.pathname === "/api/sessions/detail") {
200
+ sendJson(res, 200, await runtime.sessionDetail(requiredSearch(url, "threadId")));
201
+ return;
202
+ }
138
203
  if (req.method === "GET" && url.pathname === "/api/models") {
139
204
  sendJson(res, 200, { models: await runtime.listModels() });
140
205
  return;
@@ -172,7 +237,7 @@ async function handleApi(req, res, url) {
172
237
  }));
173
238
  return;
174
239
  }
175
- if (req.method === "POST" && url.pathname === "/api/abort") {
240
+ if (req.method === "POST" && (url.pathname === "/api/abort" || url.pathname === "/api/stop")) {
176
241
  await runtime.abort();
177
242
  sendJson(res, 200, { ok: true });
178
243
  return;
@@ -181,13 +246,39 @@ async function handleApi(req, res, url) {
181
246
  sendJson(res, 200, await runtime.handback());
182
247
  return;
183
248
  }
249
+ if (req.method === "POST" && url.pathname === "/api/retry") {
250
+ sendJson(res, 202, await runtime.retry());
251
+ return;
252
+ }
253
+ if (req.method === "POST" && url.pathname === "/api/sync") {
254
+ sendJson(res, 200, await runtime.sync());
255
+ return;
256
+ }
184
257
  if (req.method === "GET" && url.pathname === "/api/queue") {
185
- sendJson(res, 200, { queue: runtime.queue() });
258
+ sendJson(res, 200, { queue: runtime.queue(), paused: runtime.queuePaused() });
186
259
  return;
187
260
  }
188
261
  if (req.method === "POST" && url.pathname === "/api/queue") {
189
262
  const body = await readJsonBody(req);
190
- sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")) });
263
+ sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")), paused: runtime.queuePaused() });
264
+ return;
265
+ }
266
+ if (req.method === "GET" && url.pathname === "/api/chat/history") {
267
+ sendJson(res, 200, { messages: await runtime.chatHistory(numberParam(url, "limit", 200)) });
268
+ return;
269
+ }
270
+ if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
271
+ sendJson(res, 200, await runtime.clearChatHistory());
272
+ return;
273
+ }
274
+ if (req.method === "GET" && url.pathname === "/api/activity") {
275
+ sendJson(res, 200, {
276
+ events: runtime.activity({
277
+ limit: numberParam(url, "limit", 100),
278
+ source: (url.searchParams.get("source") || "all"),
279
+ status: (url.searchParams.get("status") || "all"),
280
+ }),
281
+ });
191
282
  return;
192
283
  }
193
284
  if (req.method === "GET" && url.pathname === "/api/artifacts") {
@@ -198,6 +289,22 @@ async function handleApi(req, res, url) {
198
289
  sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
199
290
  return;
200
291
  }
292
+ if (req.method === "POST" && url.pathname === "/api/artifacts/bulk") {
293
+ const body = await readJsonBody(req);
294
+ const action = stringField(body, "action");
295
+ const turnIds = Array.isArray(body.turnIds) ? body.turnIds.filter((item) => typeof item === "string") : [];
296
+ if (action !== "delete") {
297
+ throw new Error("Unsupported artifact bulk action.");
298
+ }
299
+ const removed = [];
300
+ for (const turnId of turnIds) {
301
+ if (await runtime.deleteArtifact(turnId)) {
302
+ removed.push(turnId);
303
+ }
304
+ }
305
+ sendJson(res, 200, { removed });
306
+ return;
307
+ }
201
308
  if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
202
309
  const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
203
310
  if (!bundle) {
@@ -219,12 +326,25 @@ async function handleApi(req, res, url) {
219
326
  sendFile(res, artifact.localPath, artifact.name);
220
327
  return;
221
328
  }
329
+ if (req.method === "GET" && url.pathname === "/api/artifacts/preview") {
330
+ const preview = await runtime.artifactPreview(requiredSearch(url, "turnId"), requiredSearch(url, "path"));
331
+ if (!preview) {
332
+ sendJson(res, 404, { error: "Artifact not found" });
333
+ return;
334
+ }
335
+ sendJson(res, 200, preview);
336
+ return;
337
+ }
222
338
  if (req.method === "GET" && url.pathname === "/api/logs") {
223
339
  sendJson(res, 200, await runtime.logs(url.searchParams.get("target") || "connector", numberParam(url, "lines", 120)));
224
340
  return;
225
341
  }
226
342
  if (req.method === "GET" && url.pathname === "/api/diagnostics") {
227
- sendJson(res, 200, await runtime.status());
343
+ sendJson(res, 200, await runtime.diagnostics());
344
+ return;
345
+ }
346
+ if (req.method === "POST" && url.pathname === "/api/runtime/restart") {
347
+ sendJson(res, 202, runtime.restartConnector());
228
348
  return;
229
349
  }
230
350
  sendJson(res, 404, { error: "Unknown endpoint" });
@@ -387,7 +507,7 @@ function sendJson(res, status, value) {
387
507
  res.end(`${JSON.stringify(value)}\n`);
388
508
  }
389
509
  function sendText(res, status, text, contentType) {
390
- res.writeHead(status, { "content-type": contentType });
510
+ res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
391
511
  res.end(text);
392
512
  }
393
513
  function sendFile(res, filePath, filename) {
@@ -408,6 +528,19 @@ function optionalStringField(value, key) {
408
528
  const field = value[key];
409
529
  return typeof field === "string" && field.trim() ? field.trim() : undefined;
410
530
  }
531
+ function optionalBooleanField(value, key) {
532
+ const field = value[key];
533
+ return typeof field === "boolean" ? field : undefined;
534
+ }
535
+ function parseAgentId(value) {
536
+ if (!value) {
537
+ return undefined;
538
+ }
539
+ if (!isAgentId(value)) {
540
+ throw new Error(`Invalid agent: ${value}`);
541
+ }
542
+ return value;
543
+ }
411
544
  function objectRecord(value) {
412
545
  if (!value || typeof value !== "object" || Array.isArray(value)) {
413
546
  return {};
@@ -451,6 +584,110 @@ function optionalEnv(key) {
451
584
  const value = process.env[key]?.trim();
452
585
  return value || undefined;
453
586
  }
587
+ function activeSettingsValues(current) {
588
+ return {
589
+ TELEGRAM_ALLOW_ANY_CHAT: boolValue(current.telegramAllowAnyChat),
590
+ TELEGRAM_BOT_TOKEN: current.telegramBotToken,
591
+ TELEGRAM_ADMIN_USER_IDS: current.telegramAdminUserIds.join(","),
592
+ TELEGRAM_ALLOWED_USER_IDS: current.telegramAllowedUserIds.join(","),
593
+ TELEGRAM_READONLY_USER_IDS: current.telegramReadOnlyUserIds.join(","),
594
+ TELEGRAM_ALLOWED_CHAT_IDS: current.telegramAllowedChatIds.join(","),
595
+ TELEGRAM_ROLE_POLICIES_JSON: optionalEnv("TELEGRAM_ROLE_POLICIES_JSON"),
596
+ TELEGRAM_TRANSPORT: current.telegramTransport,
597
+ TELEGRAM_WEBHOOK_URL: current.telegramWebhookUrl,
598
+ TELEGRAM_WEBHOOK_HOST: current.telegramWebhookHost,
599
+ TELEGRAM_WEBHOOK_PORT: String(current.telegramWebhookPort),
600
+ TELEGRAM_WEBHOOK_PATH: current.telegramWebhookPath,
601
+ TELEGRAM_WEBHOOK_SECRET: current.telegramWebhookSecret,
602
+ NORDRELAY_CODEX_ENABLED: boolValue(current.codexEnabled),
603
+ NORDRELAY_PI_ENABLED: boolValue(current.piEnabled),
604
+ NORDRELAY_HERMES_ENABLED: boolValue(current.hermesEnabled),
605
+ NORDRELAY_OPENCLAW_ENABLED: boolValue(current.openClawEnabled),
606
+ NORDRELAY_CLAUDE_CODE_ENABLED: boolValue(current.claudeCodeEnabled),
607
+ NORDRELAY_DEFAULT_AGENT: current.defaultAgent,
608
+ CODEX_API_KEY: current.codexApiKey,
609
+ CODEX_CLI_PATH: optionalEnv("CODEX_CLI_PATH"),
610
+ CODEX_USE_BUNDLED_CLI: process.env.CODEX_USE_BUNDLED_CLI,
611
+ CODEX_MODEL: current.codexModel,
612
+ CODEX_SYNC_INTERVAL_MS: String(current.codexSyncIntervalMs),
613
+ CODEX_EXTERNAL_BUSY_CHECK_MS: String(current.codexExternalBusyCheckMs),
614
+ CODEX_EXTERNAL_BUSY_STALE_MS: String(current.codexExternalBusyStaleMs),
615
+ CODEX_SANDBOX_MODE: current.codexSandboxMode,
616
+ CODEX_APPROVAL_POLICY: current.codexApprovalPolicy,
617
+ CODEX_LAUNCH_PROFILES_JSON: optionalEnv("CODEX_LAUNCH_PROFILES_JSON"),
618
+ CODEX_DEFAULT_LAUNCH_PROFILE: current.defaultLaunchProfileId,
619
+ ENABLE_UNSAFE_LAUNCH_PROFILES: boolValue(current.enableUnsafeLaunchProfiles),
620
+ PI_CLI_PATH: current.piCliPath,
621
+ PI_SESSION_DIR: current.piSessionDir,
622
+ PI_DEFAULT_MODEL: current.piDefaultModel,
623
+ PI_DEFAULT_THINKING: current.piDefaultThinking,
624
+ PI_DEFAULT_PROFILE: current.piDefaultLaunchProfileId,
625
+ HERMES_CLI_PATH: current.hermesCliPath,
626
+ HERMES_HOME: current.hermesHome,
627
+ HERMES_STATE_DB_PATH: current.hermesStateDbPath,
628
+ HERMES_API_BASE_URL: current.hermesApiBaseUrl,
629
+ HERMES_API_KEY: current.hermesApiKey,
630
+ HERMES_DEFAULT_MODEL: current.hermesDefaultModel,
631
+ HERMES_DEFAULT_REASONING: current.hermesDefaultReasoning,
632
+ HERMES_DEFAULT_PROFILE: current.hermesDefaultLaunchProfileId,
633
+ OPENCLAW_GATEWAY_URL: current.openClawGatewayUrl,
634
+ OPENCLAW_CLI_PATH: current.openClawCliPath,
635
+ OPENCLAW_GATEWAY_TOKEN: current.openClawGatewayToken,
636
+ OPENCLAW_GATEWAY_PASSWORD: current.openClawGatewayPassword,
637
+ OPENCLAW_AGENT_ID: current.openClawAgentId,
638
+ OPENCLAW_HOME: current.openClawHome,
639
+ OPENCLAW_STATE_DIR: current.openClawStateDir,
640
+ OPENCLAW_DEFAULT_MODEL: current.openClawDefaultModel,
641
+ OPENCLAW_DEFAULT_THINKING: current.openClawDefaultThinking,
642
+ OPENCLAW_DEFAULT_PROFILE: current.openClawDefaultLaunchProfileId,
643
+ CLAUDE_CODE_CLI_PATH: current.claudeCodeCliPath,
644
+ CLAUDE_CONFIG_DIR: current.claudeCodeConfigDir,
645
+ CLAUDE_CODE_DEFAULT_MODEL: current.claudeCodeDefaultModel,
646
+ CLAUDE_CODE_DEFAULT_EFFORT: current.claudeCodeDefaultEffort,
647
+ CLAUDE_CODE_DEFAULT_PROFILE: current.claudeCodeDefaultLaunchProfileId,
648
+ CLAUDE_CODE_MAX_TURNS: String(current.claudeCodeMaxTurns),
649
+ CONNECTOR_LOG_FORMAT: current.logFormat,
650
+ TOOL_VERBOSITY: current.toolVerbosity,
651
+ SHOW_TURN_TOKEN_USAGE: boolValue(current.showTurnTokenUsage),
652
+ ENABLE_TELEGRAM_LOGIN: boolValue(current.enableTelegramLogin),
653
+ ENABLE_TELEGRAM_REACTIONS: boolValue(current.enableTelegramReactions),
654
+ TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS: String(current.telegramRateLimitMinIntervalMs),
655
+ TELEGRAM_EDIT_MIN_INTERVAL_MS: String(current.telegramEditMinIntervalMs),
656
+ TELEGRAM_CLI_MIRROR_MODE: current.telegramMirrorMode,
657
+ TELEGRAM_CLI_MIRROR_MIN_UPDATE_MS: String(current.telegramMirrorMinUpdateMs),
658
+ TELEGRAM_NOTIFY_MODE: current.telegramNotifyMode,
659
+ TELEGRAM_QUIET_HOURS: current.telegramQuietHours ? `${current.telegramQuietHours.startHour}-${current.telegramQuietHours.endHour}` : "",
660
+ TELEGRAM_REDACT_PATTERNS: current.telegramRedactPatterns.join(","),
661
+ NORDRELAY_UPDATE_METHOD: process.env.NORDRELAY_UPDATE_METHOD || "auto",
662
+ MAX_FILE_SIZE: String(current.maxFileSize),
663
+ ARTIFACT_RETENTION_DAYS: String(current.artifactRetentionDays),
664
+ ARTIFACT_MAX_TURNS: String(current.artifactMaxTurnDirs),
665
+ ARTIFACT_MAX_INBOX_DIRS: String(current.artifactMaxInboxDirs),
666
+ ARTIFACT_IGNORE_DIRS: current.artifactIgnoreDirs.join(","),
667
+ ARTIFACT_IGNORE_GLOBS: current.artifactIgnoreGlobs.join(","),
668
+ TELEGRAM_AUTO_SEND_ARTIFACTS: boolValue(current.telegramAutoSendArtifacts),
669
+ WORKSPACE_ALLOWED_ROOTS: current.workspaceAllowedRoots.join(","),
670
+ WORKSPACE_WARN_ROOTS: current.workspaceWarnRoots.join(","),
671
+ NORDRELAY_STATE_BACKEND: current.stateBackend,
672
+ NORDRELAY_AUDIT_MAX_EVENTS: String(current.auditMaxEvents),
673
+ NORDRELAY_SESSION_LOCK_TTL_MS: String(current.sessionLockTtlMs),
674
+ NORDRELAY_VERSION_CACHE_TTL_MS: process.env.NORDRELAY_VERSION_CACHE_TTL_MS,
675
+ VOICE_PREFERRED_BACKEND: current.voicePreferredBackend,
676
+ VOICE_DEFAULT_LANGUAGE: current.voiceDefaultLanguage,
677
+ VOICE_TRANSCRIBE_ONLY: boolValue(current.voiceTranscribeOnly),
678
+ FASTER_WHISPER_PYTHON: process.env.FASTER_WHISPER_PYTHON,
679
+ FASTER_WHISPER_MODEL: process.env.FASTER_WHISPER_MODEL,
680
+ FASTER_WHISPER_DEVICE: process.env.FASTER_WHISPER_DEVICE,
681
+ FASTER_WHISPER_COMPUTE_TYPE: process.env.FASTER_WHISPER_COMPUTE_TYPE,
682
+ FASTER_WHISPER_LANGUAGE: process.env.FASTER_WHISPER_LANGUAGE,
683
+ FASTER_WHISPER_TIMEOUT_MS: process.env.FASTER_WHISPER_TIMEOUT_MS,
684
+ NORDRELAY_DASHBOARD_HOST: options.host,
685
+ NORDRELAY_DASHBOARD_PORT: String(options.port),
686
+ };
687
+ }
688
+ function boolValue(value) {
689
+ return value ? "true" : "false";
690
+ }
454
691
  function requireArg(argv, index, flag) {
455
692
  const value = argv[index];
456
693
  if (!value || value.startsWith("--")) {
@@ -524,14 +761,7 @@ function renderDashboardApp(options) {
524
761
  <aside class="sidebar" id="sidebar">
525
762
  <div class="brand"><span class="mark">NR</span><div><strong>NordRelay</strong><small>Remote control</small></div></div>
526
763
  <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>
764
+ ${renderDashboardNav()}
535
765
  </nav>
536
766
  </aside>
537
767
  <main>
@@ -542,6 +772,7 @@ function renderDashboardApp(options) {
542
772
  <p id="sessionLine">Loading session...</p>
543
773
  </div>
544
774
  <div class="header-actions">
775
+ <span id="connectionStatus" class="badge">Connecting</span>
545
776
  <select id="agentSelect"></select>
546
777
  <button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
547
778
  <button id="refreshBtn">Refresh</button>
@@ -561,9 +792,15 @@ function renderDashboardApp(options) {
561
792
  <div class="panel chat-panel">
562
793
  <div class="chat-toolbar">
563
794
  <button id="newSessionBtn">New session</button>
795
+ <button id="retryBtn" class="secondary">Retry</button>
796
+ <button id="editLastBtn" class="secondary">Edit last</button>
797
+ <button id="syncBtn" class="secondary">Sync</button>
798
+ <button id="notifyBtn" class="secondary">Notify</button>
799
+ <button id="clearChatBtn" class="secondary">Clear history</button>
564
800
  <button id="abortBtn">Abort</button>
565
801
  <button id="handbackBtn">Handback</button>
566
802
  </div>
803
+ <div class="control-grid" id="sessionControls"></div>
567
804
  <div id="messages" class="messages"></div>
568
805
  <form id="promptForm" class="composer">
569
806
  <div class="composer-fields">
@@ -571,6 +808,7 @@ function renderDashboardApp(options) {
571
808
  <div class="attachment-row">
572
809
  <label class="file-button" for="fileInput">Attach files</label>
573
810
  <input id="fileInput" type="file" multiple>
811
+ <button type="button" id="recordBtn" class="secondary">Record voice</button>
574
812
  <span id="fileSummary">No files selected</span>
575
813
  <button type="button" id="clearFilesBtn" class="secondary">Clear</button>
576
814
  </div>
@@ -582,6 +820,13 @@ function renderDashboardApp(options) {
582
820
  </div>
583
821
  </section>
584
822
 
823
+ <section class="page" id="page-tasks">
824
+ <div class="panel">
825
+ <div class="row"><button id="reloadTasksBtn">Reload tasks</button></div>
826
+ <div id="tasksList" class="list"></div>
827
+ </div>
828
+ </section>
829
+
585
830
  <section class="page" id="page-sessions">
586
831
  <div class="panel">
587
832
  <div class="sessions-toolbar">
@@ -595,21 +840,55 @@ function renderDashboardApp(options) {
595
840
 
596
841
  <section class="page" id="page-queue">
597
842
  <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>
843
+ <div class="row"><button data-queue="pause">Pause</button><button data-queue="resume">Resume</button><button data-queue="clear" class="danger">Clear</button><span id="queueStatus"></span></div>
599
844
  <div id="queueList" class="list"></div>
600
845
  </div>
601
846
  </section>
602
847
 
848
+ <section class="page" id="page-activity">
849
+ <div class="panel">
850
+ <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="cli">CLI</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
851
+ <div id="activityList" class="list"></div>
852
+ </div>
853
+ </section>
854
+
603
855
  <section class="page" id="page-artifacts">
604
856
  <div class="panel">
605
- <div class="row"><button id="reloadArtifactsBtn">Reload artifacts</button></div>
857
+ <div class="row"><button id="reloadArtifactsBtn">Reload artifacts</button><input id="artifactSearch" placeholder="Search artifacts"><select id="artifactKind"><option value="all">All files</option><option value="images">Images</option><option value="docs">Docs/code</option></select><button id="zipSelectedArtifactsBtn" class="secondary">ZIP selected</button><button id="deleteSelectedArtifactsBtn" class="danger">Delete selected</button></div>
606
858
  <div id="artifactList" class="list"></div>
859
+ <div id="artifactPreview" class="preview"></div>
860
+ </div>
861
+ </section>
862
+
863
+ <section class="page" id="page-adapters">
864
+ <div class="panel">
865
+ <div class="row"><button id="reloadAdaptersBtn">Reload adapters</button></div>
866
+ <div id="adapterHealth" class="list"></div>
867
+ </div>
868
+ </section>
869
+
870
+ <section class="page" id="page-access">
871
+ <div class="panel">
872
+ <div class="row"><button id="loadAccessBtn">Reload access</button><button id="saveAccessBtn">Save access settings</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
873
+ <div id="accessPanel" class="settings-grid"></div>
874
+ <h2>Locks</h2>
875
+ <div id="locksList" class="list"></div>
876
+ <h2>Audit</h2>
877
+ <div class="row"><input id="auditLimit" type="number" value="50" min="1" max="200"><button id="loadAuditBtn">Load audit</button></div>
878
+ <div id="auditList" class="list"></div>
879
+ </div>
880
+ </section>
881
+
882
+ <section class="page" id="page-version">
883
+ <div class="panel">
884
+ <div class="row"><button id="loadVersionBtn">Check versions</button><button id="updateBtn" class="secondary">Update NordRelay</button></div>
885
+ <div id="versionPanel" class="list"></div>
607
886
  </div>
608
887
  </section>
609
888
 
610
889
  <section class="page" id="page-settings">
611
890
  <div class="panel">
612
- <div class="row"><button id="saveSettingsBtn">Save settings</button><span id="settingsStatus"></span></div>
891
+ <div class="row"><button id="saveSettingsBtn">Save settings</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
613
892
  <div id="settingsTabs" class="tabs"></div>
614
893
  <div id="settingsForm" class="settings-grid"></div>
615
894
  </div>
@@ -617,13 +896,13 @@ function renderDashboardApp(options) {
617
896
 
618
897
  <section class="page" id="page-logs">
619
898
  <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>
899
+ <div class="row"><select id="logTarget"><option value="connector">Connector</option><option value="update">Update</option></select><select id="logLevel"><option value="all">All levels</option><option value="ERROR">Error</option><option value="WARN">Warn</option><option value="INFO">Info</option></select><input id="logSearch" placeholder="Search logs"><input id="logSince" type="datetime-local" title="Show entries after this time"><input id="logLines" type="number" value="120" min="1" max="300"><label class="checkbox"><input id="logAutoRefresh" type="checkbox"> Auto</label><label class="checkbox"><input id="logFollow" type="checkbox"> Follow</label><button id="loadLogsBtn">Load logs</button><button id="downloadLogsBtn" class="secondary">Download</button></div>
621
900
  <pre id="logs"></pre>
622
901
  </div>
623
902
  </section>
624
903
 
625
904
  <section class="page" id="page-diagnostics">
626
- <div class="panel"><pre id="diagnostics"></pre></div>
905
+ <div class="panel"><div id="diagnostics" class="list"></div></div>
627
906
  </section>
628
907
 
629
908
  <footer>
@@ -633,6 +912,25 @@ function renderDashboardApp(options) {
633
912
  </footer>
634
913
  </main>
635
914
  </div>
915
+ <dialog id="newSessionDialog">
916
+ <form method="dialog" id="newSessionForm">
917
+ <h2>New Session</h2>
918
+ <div class="form-grid">
919
+ <label>Agent<select id="newAgent"></select></label>
920
+ <label>Workspace<input id="newWorkspace" list="workspaceOptions" placeholder="Current workspace"></label>
921
+ <label>Model<select id="newModel"></select></label>
922
+ <label id="newReasoningWrap">Reasoning<select id="newReasoning"></select></label>
923
+ <label id="newLaunchWrap">Launch profile<select id="newLaunch"></select></label>
924
+ <label id="newFastWrap" class="checkbox"><input id="newFast" type="checkbox"> Fast mode</label>
925
+ </div>
926
+ <datalist id="workspaceOptions"></datalist>
927
+ <div class="row dialog-actions"><button type="button" id="cancelSessionBtn" class="secondary">Cancel</button><button id="createSessionBtn" value="default">Create session</button></div>
928
+ </form>
929
+ </dialog>
930
+ <dialog id="sessionDetailDialog">
931
+ <div id="sessionDetail"></div>
932
+ <div class="row dialog-actions"><button id="closeSessionDetailBtn" class="secondary">Close</button></div>
933
+ </dialog>
636
934
  <div id="toast"></div>
637
935
  <script>${dashboardJs()}</script>
638
936
  </body>
@@ -642,13 +940,15 @@ function dashboardCss() {
642
940
  return `
643
941
  :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
942
  :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}}
943
+ .agent-settings-nav{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px;padding:10px;border:1px solid var(--border-soft);border-radius:8px;background:var(--surface)}.agent-settings-nav strong{font-size:13px;color:var(--muted);margin-right:4px}.agent-settings-nav button{background:var(--surface);color:var(--text);border-color:var(--border);height:32px}.agent-settings-nav button.active{background:var(--accent);color:white;border-color:var(--accent)}@media(max-width:560px){.agent-settings-nav{align-items:stretch}.agent-settings-nav button{width:100%}}
944
+ .drop-active{outline:2px dashed var(--accent);outline-offset:-8px}.chip{display:inline-flex;align-items:center;border-radius:999px;border:1px solid var(--border);padding:2px 8px;font-size:12px;color:var(--muted);margin-right:6px}.chip.error{color:var(--danger);border-color:var(--danger)}.chip.warn{color:#8a6a12;border-color:#d9c27a;background:var(--warn)}.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:12px}.artifact-card{border:1px solid var(--border-soft);border-radius:8px;padding:8px;background:var(--surface-soft);min-width:0}.artifact-card img{width:100%;aspect-ratio:1.4;object-fit:cover;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.artifact-card small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.setting.dirty{border-color:var(--accent)}.setting-actions{display:flex;gap:8px;align-items:center;margin-top:8px}.setting-help{font-size:12px;color:var(--muted)}.restart-banner{border:1px solid #d9c27a;background:var(--warn);border-radius:8px;padding:10px;margin:0 0 12px}.task-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px}.log-line{display:block}.log-line.ERROR{color:var(--danger);font-weight:700}.log-line.WARN{color:#8a6a12;font-weight:700}.connection-ok{color:#1e754e;border-color:#8ed0aa}.connection-warn{color:#8a6a12;border-color:#d9c27a}.connection-error{color:var(--danger);border-color:var(--danger)}
945
+ *{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;align-items:start}.chat-panel{height:calc(100vh - 170px);min-height:520px;display:flex;flex-direction:column}.control-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin:12px 0}.control-grid label,.form-grid label{display:grid;gap:5px;font-size:12px;color:var(--muted)}.messages{flex:1;min-height:0;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}.side-panel{max-height:calc(100vh - 126px);display:flex;flex-direction:column}.tool-stream{display:flex;flex-direction:column;gap:8px;overflow:auto;max-height:calc(100vh - 190px);padding-right:4px}.tool{border:1px solid var(--border-soft);border-radius:6px;padding:8px;background:var(--surface-soft);white-space:pre-wrap;word-break:break-word}.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}.queue-item{cursor:grab}.queue-item.dragging{opacity:.55}.badge,.adapter-status{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:2px 8px;color:var(--muted);font-size:12px}.adapter-status{margin-left:6px;text-transform:capitalize}.adapter-status.enabled,.adapter-status.available{color:#1e754e;border-color:#8ed0aa;background:color-mix(in srgb,var(--accent-soft) 55%,transparent)}.adapter-status.disabled{color:var(--muted)}.adapter-status.planned{color:#8a6a12;border-color:#d9c27a;background:var(--warn)}.preview{margin-top:12px}.preview img{max-width:100%;border:1px solid var(--border);border-radius:8px;background:var(--surface)}.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%}.setting-error{color:var(--danger);font-size:12px;margin-top:6px}.checkbox{display:inline-flex!important;grid-template-columns:auto 1fr!important;align-items:center;gap:8px}.checkbox input{height:auto;width:auto}.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)}dialog{border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);width:min(720px,calc(100vw - 28px));padding:18px;box-shadow:0 18px 70px rgba(0,0,0,.22)}dialog::backdrop{background:rgba(0,0,0,.35)}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}.dialog-actions{justify-content:flex-end;margin-top:16px}#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}.chat-panel{height:auto;min-height:0}.messages{max-height:55vh;min-height:300px}.composer{grid-template-columns:1fr}.composer button{height:40px}.side-panel{order:-1;max-height:360px}.tool-stream{max-height:300px}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
946
  `;
647
947
  }
648
948
  function dashboardJs() {
649
949
  return `
650
950
  const token = localStorage.getItem('nordrelayDashboardToken') || '';
651
- const state = { snapshot:null, settings:[], currentPage:'overview', currentAgent:null, settingsGroup:null };
951
+ const state = { snapshot:null, controls:null, newSessionControls:null, enabledAgents:[], settings:[], currentPage:'overview', settingsGroup:null, logsPlain:'', logTimer:null, toastTimer:null, cliStatusActive:false, selectedArtifactTurns:new Set(), mediaRecorder:null, recordedChunks:[], events:null, reconnectTimer:null, notifications:false };
652
952
  const authHeaders = () => token ? { authorization: 'Bearer ' + token } : {};
653
953
  async function api(path, options={}) {
654
954
  const headers = { ...(options.body ? {'content-type':'application/json'} : {}), ...authHeaders(), ...(options.headers||{}) };
@@ -659,16 +959,23 @@ async function api(path, options={}) {
659
959
  if (!res.ok) throw new Error(data.error || res.statusText);
660
960
  return data;
661
961
  }
662
- function toast(msg){const el=document.getElementById('toast');el.textContent=msg;el.style.display='block';setTimeout(()=>el.style.display='none',3500)}
962
+ function toast(msg,options={}){const el=document.getElementById('toast');el.textContent=msg;el.style.display='block';if(state.toastTimer)clearTimeout(state.toastTimer);state.toastTimer=null;if(!options.sticky){state.toastTimer=setTimeout(()=>{el.style.display='none';state.toastTimer=null},options.duration||3500)}}
663
963
  function esc(s){return String(s??'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}
664
964
  function attr(s){return esc(s).replace(/"/g,'&quot;')}
965
+ function cssEscape(s){return window.CSS&&CSS.escape?CSS.escape(s):String(s).replace(/[^a-zA-Z0-9_-]/g,'\\\\$&')}
665
966
  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')}
967
+ async function copyText(text,label='Copied'){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(label)}
667
968
  function fmtDate(s){return s?new Date(s).toLocaleString(): '-'}
969
+ function fmtDuration(ms){if(!ms&&ms!==0)return '-';const sec=Math.round(ms/1000);if(sec<60)return sec+'s';return Math.floor(sec/60)+'m '+(sec%60)+'s'}
668
970
  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'}
971
+ function compactNum(n){if(!n)return'';if(n>=1000000000)return Math.round(n/100000000)/10+'B';if(n>=1000000)return Math.round(n/100000)/10+'M';if(n>=1000)return Math.round(n/100)/10+'K';return String(n)}
972
+ function modelLabel(m){const meta=[m.contextWindow?compactNum(m.contextWindow):'',m.supportsImages===true?'img':m.supportsImages===false?'text':'',m.supportsThinking===true?'think':''].filter(Boolean).join(' ');return (m.displayName||m.slug)+(meta?' · '+meta:'')}
973
+ function fmtAge(ms){const sec=Math.max(0,Math.floor(ms/1000));if(sec<60)return sec+'s ago';const min=Math.floor(sec/60);if(min<60)return min+'m ago';return Math.floor(min/60)+'h ago'}
974
+ function isCliRunningStatus(msg){return / CLI running\\b/.test(String(msg||''))}
975
+ function isCliDoneStatus(msg){return / CLI task\\b/.test(String(msg||''))}
669
976
  function applyTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('nordrelayTheme',theme);document.getElementById('themeBtn').textContent=theme==='dark'?'Light':'Dark'}
670
977
  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();}
978
+ 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(); if(name==='activity') loadActivity(); if(name==='tasks') loadTasks(); if(name==='adapters') loadAdapterHealth(); if(name==='access') loadAccess(); if(name==='version') loadVersion();}
672
979
  document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>page(b.dataset.page));
673
980
  document.getElementById('menuBtn').onclick=()=>document.getElementById('sidebar').classList.toggle('open');
674
981
  document.getElementById('refreshBtn').onclick=()=>loadBootstrap();
@@ -697,68 +1004,169 @@ const sessionsPager=createPaginator('sessionsPager',()=>loadSessions(false),50);
697
1004
  async function loadBootstrap(){
698
1005
  const data = await api('/api/bootstrap');
699
1006
  state.snapshot = data.status.snapshot;
1007
+ state.controls = data.controls;
1008
+ state.enabledAgents = data.enabledAgents || [];
700
1009
  renderSnapshot(state.snapshot);
1010
+ renderSessionControls();
1011
+ populateNewSessionForm(data.enabledAgents);
701
1012
  renderAdapters(data.channels, data.agentAdapters);
702
1013
  document.getElementById('footerVersion').textContent='NordRelay '+(data.status.health?.version || '');
703
1014
  document.getElementById('footerHealth').textContent='Health: '+(data.status.health?.state?.status || 'unknown');
704
1015
  const agentSelect=document.getElementById('agentSelect');
705
1016
  agentSelect.innerHTML=data.enabledAgents.map(a=>'<option value="'+a+'">'+a+'</option>').join('');
706
1017
  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()};
1018
+ agentSelect.onchange=()=>safe(async()=>{await api('/api/agent',{method:'POST',body:JSON.stringify({agentId:agentSelect.value})});toast('Agent switched');await loadBootstrap();await loadChatHistory()});
708
1019
  }
709
1020
  function renderSnapshot(s){
710
1021
  document.getElementById('sessionLine').textContent=(s.session.agentLabel||'Agent')+' / '+(s.session.model||'default')+' / '+(s.session.threadId||'not started');
711
1022
  document.getElementById('sessionText').textContent=s.sessionText||'';
712
1023
  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']
1024
+ ['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'],['Fast',s.session.capabilities&&s.session.capabilities.fastMode?(s.session.fastMode?'on':'off'):'n/a']
714
1025
  ].map(([k,v])=>'<div class="metric"><div class="label">'+esc(k)+'</div><div class="value">'+esc(v)+'</div></div>').join('');
715
- renderQueue(s.queue);
1026
+ renderQueue(s.queue,s.queuePaused);
1027
+ }
1028
+ function renderSessionControls(){
1029
+ const c=state.controls||{};const s=state.snapshot?.session||{};const caps=c.capabilities||{};
1030
+ const modelOptions=['<option value="">Default</option>'].concat((c.models||[]).map(m=>'<option value="'+attr(m.slug)+'" '+(m.slug===s.model?'selected':'')+'>'+esc(modelLabel(m))+'</option>')).join('');
1031
+ const reasoningOptions=(c.reasoningOptions||[]).map(v=>'<option value="'+attr(v)+'" '+(v===s.reasoningEffort?'selected':'')+'>'+esc(v)+'</option>').join('');
1032
+ const launchOptions=(c.launchProfiles||[]).map(p=>'<option value="'+attr(p.id)+'" '+(p.id===(s.nextLaunchProfileId||s.launchProfileId)?'selected':'')+'>'+esc(p.label+' - '+p.behavior+(p.unsafe?' - unsafe':''))+'</option>').join('');
1033
+ document.getElementById('sessionControls').innerHTML=[
1034
+ caps.modelSelection?'<label>Model<select id="controlModel">'+modelOptions+'</select></label>':'',
1035
+ caps.reasoningSelection?'<label>'+esc(c.reasoningLabel||'Reasoning')+'<select id="controlReasoning">'+reasoningOptions+'</select></label>':'',
1036
+ caps.launchProfiles?'<label>Launch<select id="controlLaunch">'+launchOptions+'</select></label>':'',
1037
+ caps.fastMode?'<label class="checkbox"><input id="controlFast" type="checkbox" '+(s.fastMode?'checked':'')+'> Fast mode</label>':''
1038
+ ].join('');
1039
+ const model=document.getElementById('controlModel'); if(model) model.onchange=()=>safe(async()=>{if(model.value){await api('/api/session/model',{method:'POST',body:JSON.stringify({model:model.value})});toast('Model updated');loadBootstrap()}});
1040
+ const reasoning=document.getElementById('controlReasoning'); if(reasoning) reasoning.onchange=()=>safe(async()=>{await api('/api/session/reasoning',{method:'POST',body:JSON.stringify({reasoning:reasoning.value})});toast((c.reasoningLabel||'Reasoning')+' updated');loadBootstrap()});
1041
+ const launch=document.getElementById('controlLaunch'); if(launch) launch.onchange=()=>safe(async()=>{await api('/api/session/launch',{method:'POST',body:JSON.stringify({profileId:launch.value})});toast('Launch profile updated');loadBootstrap()});
1042
+ const fast=document.getElementById('controlFast'); if(fast) fast.onchange=()=>safe(async()=>{await api('/api/session/fast',{method:'POST',body:JSON.stringify({enabled:fast.checked})});toast('Fast mode updated');loadBootstrap()});
716
1043
  }
717
1044
  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>';
1045
+ const channelCards=(channels||[]).map(c=>adapterCard(c.label,c.status,c.capabilities.join(', ')));
1046
+ const agentCards=(agents||[]).map(a=>{const available=a.status==='available';const status=available?(state.enabledAgents.includes(a.id)?'enabled':'disabled'):(a.status||'planned');const detail=[available?'integrated':(a.status||'planned'),a.envFlag,a.notes].filter(Boolean).join(' / ');return adapterCard(a.label,status,detail)});
1047
+ document.getElementById('adapters').innerHTML='<div class="list">'+channelCards.concat(agentCards).join('')+'</div>';
719
1048
  }
1049
+ function adapterCard(label,status,detail){return '<div class="item"><strong>'+esc(label)+' <span class="adapter-status '+esc(status)+'">'+esc(status)+'</span></strong><small>'+esc(detail||'')+'</small></div>'}
720
1050
  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}
1051
+ function appendQueuedMessage(id){const div=appendMessage('system','Queued prompt '+id);const btn=document.createElement('button');btn.textContent='Cancel queued message';btn.className='danger';btn.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:'cancel',id})});renderQueue(r.queue,r.paused);div.textContent='Cancelled queued prompt '+id});div.appendChild(document.createElement('br'));div.appendChild(btn)}
1052
+ function renderChatMessages(messages){state.chatMessages=messages||[];const box=document.getElementById('messages');box.innerHTML=(messages||[]).map(m=>'<div class="message '+esc(m.role)+'"><small>'+esc((m.source||'web')+' / '+fmtDate(m.timestamp))+'</small>\\n'+esc(m.text)+'</div>').join('');box.scrollTop=box.scrollHeight}
1053
+ async function loadChatHistory(){const data=await api('/api/chat/history');renderChatMessages(data.messages||[])}
721
1054
  let currentAgentMessage=null;
722
1055
  function connectEvents(){
1056
+ if(state.events) state.events.close();
723
1057
  const qs = token ? '?token='+encodeURIComponent(token) : '';
724
1058
  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)});
1059
+ state.events=events;
1060
+ setConnection('Connecting','warn');
1061
+ events.onopen=()=>{if(state.reconnectTimer){clearTimeout(state.reconnectTimer);state.reconnectTimer=null}setConnection('Live','ok')};
1062
+ events.addEventListener('snapshot', e=>{const d=JSON.parse(e.data).data;state.snapshot=d;renderSnapshot(d);renderSessionControls()});
1063
+ events.addEventListener('chat_history', e=>renderChatMessages(JSON.parse(e.data).messages||[]));
1064
+ events.addEventListener('activity_update', e=>renderActivity(JSON.parse(e.data).events||[]));
1065
+ events.addEventListener('session_update', e=>{loadBootstrap();loadChatHistory()});
1066
+ events.addEventListener('queue_update', e=>{const d=JSON.parse(e.data);renderQueue(d.queue,d.paused)});
1067
+ events.addEventListener('turn_start', e=>{const d=JSON.parse(e.data);appendMessage('user',d.prompt);currentAgentMessage=appendMessage('agent','');if(state.currentPage==='tasks')loadTasks()});
1068
+ events.addEventListener('text_delta', e=>{const d=JSON.parse(e.data);if(!currentAgentMessage)currentAgentMessage=appendMessage('agent','');currentAgentMessage.textContent+=d.delta;currentAgentMessage.scrollIntoView({block:'end'});if(state.currentPage==='tasks')loadTasks()});
1069
+ events.addEventListener('tool_start', e=>{const d=JSON.parse(e.data);tool('tool','Started '+d.toolName);if(state.currentPage==='tasks')loadTasks()});
731
1070
  events.addEventListener('tool_update', e=>{const d=JSON.parse(e.data);if(d.partialResult)tool('tool',d.partialResult.slice(-600))});
732
1071
  events.addEventListener('tool_end', e=>{const d=JSON.parse(e.data);tool(d.isError?'danger':'tool','Finished '+d.toolCallId+(d.isError?' with error':''))});
733
1072
  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
1073
  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=()=>{};
1074
+ events.addEventListener('turn_complete', ()=>{currentAgentMessage=null;notify('NordRelay turn finished','The active task completed.');loadBootstrap();if(state.currentPage==='tasks')loadTasks()});
1075
+ events.addEventListener('status', e=>{const d=JSON.parse(e.data);const msg=d.message||'';if(isCliRunningStatus(msg)){state.cliStatusActive=true;toast(msg,{sticky:true});return}if(isCliDoneStatus(msg))state.cliStatusActive=false;toast(msg)});
1076
+ events.onerror=()=>{setConnection('Reconnecting','error');if(!state.reconnectTimer)state.reconnectTimer=setTimeout(()=>{state.reconnectTimer=null;connectEvents()},5000)};
738
1077
  }
739
- function tool(cls,text){const div=document.createElement('div');div.className='tool '+(cls==='danger'?'danger':'');div.textContent=text;document.getElementById('toolStream').prepend(div)}
1078
+ function setConnection(text,kind){const el=document.getElementById('connectionStatus');el.textContent=text;el.className='badge connection-'+kind}
1079
+ async function enableNotifications(){if(!('Notification' in window)){toast('Browser notifications are not supported');return}const permission=Notification.permission==='granted'?'granted':await Notification.requestPermission();state.notifications=permission==='granted';toast(state.notifications?'Browser notifications enabled':'Browser notifications denied')}
1080
+ function notify(title,body){if(state.notifications&&'Notification' in window&&Notification.permission==='granted')new Notification(title,{body})}
1081
+ function updateToolAgeTitles(){document.querySelectorAll('.tool[data-created-at]').forEach(el=>{const created=Number(el.dataset.createdAt||Date.now());el.title='Updated '+fmtAge(Date.now()-created)})}
1082
+ function tool(cls,text){const div=document.createElement('div');div.className='tool '+(cls==='danger'?'danger':'');div.dataset.createdAt=String(Date.now());div.textContent=text;document.getElementById('toolStream').prepend(div);updateToolAgeTitles()}
1083
+ setInterval(updateToolAgeTitles,30000);
740
1084
  let selectedFiles=[];
741
1085
  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)+')'}
1086
+ function addFiles(files){selectedFiles=selectedFiles.concat(Array.from(files||[]));renderSelectedFiles()}
742
1087
  async function filePayload(file){return {name:file.name || 'upload',mimeType:file.type || 'application/octet-stream',dataBase64:await fileToBase64(file)}}
743
1088
  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()};
1089
+ document.getElementById('fileInput').onchange=e=>{addFiles(e.target.files)};
745
1090
  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()})}
1091
+ document.addEventListener('paste',e=>{const files=Array.from(e.clipboardData?.files||[]);if(files.length){addFiles(files);toast('Pasted '+files.length+' file(s)')}});
1092
+ document.addEventListener('dragover',e=>{e.preventDefault();document.body.classList.add('drop-active')});
1093
+ document.addEventListener('dragleave',()=>document.body.classList.remove('drop-active'));
1094
+ document.addEventListener('drop',e=>{e.preventDefault();document.body.classList.remove('drop-active');const files=Array.from(e.dataTransfer?.files||[]);if(files.length){addFiles(files);toast('Added '+files.length+' dropped file(s)')}});
1095
+ document.getElementById('promptForm').onsubmit=e=>safe(async()=>{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)appendQueuedMessage(r.queueId)},e);
1096
+ document.getElementById('newSessionBtn').onclick=()=>openNewSessionDialog();
1097
+ document.getElementById('retryBtn').onclick=()=>safe(async()=>{const r=await api('/api/retry',{method:'POST'});toast(r.queued?'Retry queued '+r.queueId:'Retry started')});
1098
+ document.getElementById('editLastBtn').onclick=()=>{const last=[...(state.chatMessages||[])].reverse().find(m=>m.role==='user');if(last){document.getElementById('promptInput').value=last.text;document.getElementById('promptInput').focus()}else toast('No user prompt found')};
1099
+ document.getElementById('syncBtn').onclick=()=>safe(async()=>{const r=await api('/api/sync',{method:'POST'});toast(r.changed?'Synced: '+(r.changedFields||[]).join(', '):'Already in sync');loadBootstrap()});
1100
+ document.getElementById('notifyBtn').onclick=()=>enableNotifications();
1101
+ document.getElementById('clearChatBtn').onclick=()=>safe(async()=>{if(confirm('Clear chat history for the current thread?')){const r=await api('/api/chat/history',{method:'DELETE'});renderChatMessages(r.messages||[]);toast('Removed '+r.removed+' messages')}});
1102
+ document.getElementById('abortBtn').onclick=()=>safe(async()=>{await api('/api/abort',{method:'POST'});toast('Abort sent')});
1103
+ document.getElementById('handbackBtn').onclick=()=>safe(async()=>{const r=await api('/api/handback',{method:'POST'});appendMessage('system','Handback command:\\n'+(r.command||'No command available'))});
1104
+ document.getElementById('recordBtn').onclick=()=>safe(async()=>{const btn=document.getElementById('recordBtn');if(state.mediaRecorder&&state.mediaRecorder.state==='recording'){state.mediaRecorder.stop();btn.textContent='Record voice';return}const stream=await navigator.mediaDevices.getUserMedia({audio:true});state.recordedChunks=[];state.mediaRecorder=new MediaRecorder(stream);state.mediaRecorder.ondataavailable=e=>{if(e.data.size>0)state.recordedChunks.push(e.data)};state.mediaRecorder.onstop=()=>{stream.getTracks().forEach(t=>t.stop());const blob=new Blob(state.recordedChunks,{type:'audio/webm'});addFiles([new File([blob],'voice-note.webm',{type:'audio/webm'})]);toast('Voice note attached')};state.mediaRecorder.start();btn.textContent='Stop recording'});
1105
+ function renderNewSessionControls(c){const s=state.snapshot?.session||{};const caps=c.capabilities||{};document.getElementById('workspaceOptions').innerHTML=(c.workspaces||[]).map(w=>'<option value="'+attr(w)+'"></option>').join('');document.getElementById('newModel').innerHTML='<option value="">Default</option>'+((c.models||[]).map(m=>'<option value="'+attr(m.slug)+'">'+esc(modelLabel(m))+'</option>').join(''));document.getElementById('newModel').parentElement.style.display=caps.modelSelection?'grid':'none';const reasoningWrap=document.getElementById('newReasoningWrap');reasoningWrap.firstChild.nodeValue=(c.reasoningLabel||'Reasoning');reasoningWrap.style.display=caps.reasoningSelection?'grid':'none';document.getElementById('newReasoning').innerHTML='<option value="">Default</option>'+((c.reasoningOptions||[]).map(v=>'<option value="'+attr(v)+'">'+esc(v)+'</option>').join(''));document.getElementById('newLaunch').innerHTML='<option value="">Default</option>'+((c.launchProfiles||[]).map(p=>'<option value="'+attr(p.id)+'">'+esc(p.label+' - '+p.behavior)+'</option>').join(''));document.getElementById('newFast').checked=Boolean(s.fastMode&&caps.fastMode);document.getElementById('newLaunchWrap').style.display=caps.launchProfiles?'grid':'none';document.getElementById('newFastWrap').style.display=caps.fastMode?'inline-flex':'none'}
1106
+ function populateNewSessionForm(agents){const s=state.snapshot?.session||{};const agentSelect=document.getElementById('newAgent');agentSelect.innerHTML=(agents||[]).map(a=>'<option value="'+attr(a)+'" '+(a===s.agentId?'selected':'')+'>'+esc(a)+'</option>').join('');agentSelect.value=s.agentId||agentSelect.value;document.getElementById('newWorkspace').value=s.workspace||'';state.newSessionControls=state.controls||{};renderNewSessionControls(state.newSessionControls);agentSelect.onchange=()=>safe(async()=>{state.newSessionControls=await api('/api/control-options?agent='+encodeURIComponent(agentSelect.value));renderNewSessionControls(state.newSessionControls)})}
1107
+ function openNewSessionDialog(){populateNewSessionForm(state.enabledAgents);document.getElementById('newSessionDialog').showModal()}
1108
+ document.getElementById('newSessionForm').onsubmit=e=>safe(async()=>{e.preventDefault();const payload={agentId:val('newAgent'),workspace:val('newWorkspace')||undefined,model:val('newModel')||undefined,reasoningEffort:val('newReasoning')||undefined,launchProfileId:val('newLaunch')||undefined,fastMode:document.getElementById('newFast').checked};await api('/api/sessions/new',{method:'POST',body:JSON.stringify(payload)});document.getElementById('newSessionDialog').close();toast('New session started');await loadBootstrap();await loadChatHistory()},e);
1109
+ document.getElementById('cancelSessionBtn').onclick=()=>document.getElementById('newSessionDialog').close();
1110
+ function val(id){return document.getElementById(id).value.trim()}
1111
+ 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><button class="secondary" data-session-detail="'+attr(s.id)+'">Details</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||'','Thread ID copied'));document.querySelectorAll('[data-switch]').forEach(b=>b.onclick=()=>safe(async()=>{await api('/api/sessions/switch',{method:'POST',body:JSON.stringify({threadId:b.dataset.switch})});toast('Session switched');loadBootstrap()}));document.querySelectorAll('[data-session-detail]').forEach(b=>b.onclick=()=>safe(()=>loadSessionDetail(b.dataset.sessionDetail)))}
1112
+ async function loadSessionDetail(threadId){const d=await api('/api/sessions/detail?threadId='+encodeURIComponent(threadId));const r=d.record||{};document.getElementById('sessionDetail').innerHTML='<h2>Session detail</h2>'+card('Metadata',[['Thread',threadId],['Agent',r.agentId],['Title',r.title],['Workspace',r.cwd],['Model',r.model],['Reasoning',r.reasoningEffort],['Updated',fmtDate(r.updatedAt)],['Path',r.sessionPath]])+'<h2>Recent messages</h2><div class="list">'+(d.messages||[]).slice(-20).map(m=>'<div class="item"><strong>'+esc(m.role+' / '+fmtDate(m.timestamp))+'</strong><small>'+esc(short(m.text,500))+'</small></div>').join('')+'</div><h2>Activity</h2><div class="list">'+(d.activity||[]).map(e=>'<div class="item"><strong>'+esc(e.status+' / '+e.type+' / '+fmtDate(e.timestamp))+'</strong><small>'+esc(short(e.prompt||e.detail||'',300))+'</small></div>').join('')+'</div>';document.getElementById('sessionDetailDialog').showModal()}
1113
+ document.getElementById('closeSessionDetailBtn').onclick=()=>document.getElementById('sessionDetailDialog').close();
751
1114
  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()})}
1115
+ function renderQueue(queue,paused){document.getElementById('queueStatus').textContent=paused?'Paused':'Running';document.getElementById('queueList').innerHTML=(queue||[]).map((q,i)=>'<div class="item queue-item" draggable="true" data-queue-id="'+attr(q.id)+'"><strong>'+esc((i+1)+'. '+q.id+' - '+q.description)+'</strong><small>Created '+fmtDate(q.createdAt)+' / attempts '+q.attempts+(q.lastError?' / '+esc(q.lastError):'')+'</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="up" data-id="'+q.id+'">Up</button><button data-q="down" data-id="'+q.id+'">Down</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=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.q,id:b.dataset.id})});renderQueue(r.queue,r.paused)}));let dragged=null;document.querySelectorAll('.queue-item').forEach(item=>{item.ondragstart=()=>{dragged=item.dataset.queueId;item.classList.add('dragging')};item.ondragend=()=>item.classList.remove('dragging');item.ondragover=e=>e.preventDefault();item.ondrop=()=>safe(async()=>{if(dragged&&dragged!==item.dataset.queueId){const ids=Array.from(document.querySelectorAll('.queue-item')).map(el=>el.dataset.queueId);const targetIndex=Math.max(0,ids.indexOf(item.dataset.queueId));await api('/api/queue',{method:'POST',body:JSON.stringify({action:'top',id:dragged})});for(let i=0;i<targetIndex;i++)await api('/api/queue',{method:'POST',body:JSON.stringify({action:'down',id:dragged})});const r=await api('/api/queue');renderQueue(r.queue,r.paused)}})})}
1116
+ document.querySelectorAll('[data-queue]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.queue})});renderQueue(r.queue,r.paused)}));
1117
+ async function loadTasks(){const d=await api('/api/tasks');renderTasks(d)}
1118
+ function taskCard(t,title){if(!t)return '<div class="item"><strong>'+esc(title)+'</strong><small>Idle</small></div>';const tools=(t.tools||[]).map(x=>x.name+' x'+x.count).join(', ')||'-';return '<div class="item"><strong>'+esc(title+' · '+t.status)+'</strong><small>'+esc((t.agentLabel||t.agentId||t.source)+' / '+(t.threadId||'-'))+'</small><small>'+esc('Elapsed '+fmtDuration(t.durationMs)+' / current '+(t.currentTool||'-')+' / last '+(t.lastTool||'-'))+'</small><small>'+esc('Tools: '+tools+' / output chars '+(t.outputChars||0))+'</small><small>'+esc(t.prompt||t.detail||'')+'</small></div>'}
1119
+ function renderTasks(d){document.getElementById('tasksList').innerHTML='<div class="task-grid">'+taskCard(d.current,'Current web turn')+taskCard(d.external,'External CLI turn')+'</div><h2>Queue</h2><div class="list">'+((d.queue||[]).map(q=>'<div class="item"><strong>'+esc(q.id+' · '+q.description)+'</strong><small>'+esc(fmtDate(q.createdAt)+' / attempts '+q.attempts)+'</small><div class="row"><button data-q="run" data-id="'+attr(q.id)+'">Run</button><button data-q="cancel" data-id="'+attr(q.id)+'" class="danger">Cancel</button></div></div>').join('')||'<div class="item">Queue is empty.</div>')+'</div><h2>Recent turns</h2><div class="list">'+((d.recent||[]).map(e=>'<div class="item"><strong>'+esc(e.status+' / '+e.source+' / '+e.type)+'</strong><small>'+esc(fmtDate(e.timestamp)+' / '+(e.threadId||'-'))+'</small><small>'+esc(short(e.prompt||e.detail||'',300))+'</small></div>').join('')||'<div class="item">No recent tasks.</div>')+'</div>';document.querySelectorAll('#tasksList [data-q]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.q,id:b.dataset.id})});renderQueue(r.queue,r.paused);loadTasks()}))}
1120
+ document.getElementById('reloadTasksBtn').onclick=()=>loadTasks();
1121
+ async function loadArtifacts(){const data=await api('/api/artifacts');state.artifactReports=data.reports||[];renderArtifacts()}
1122
+ function artifactMatches(a,kind,query){const name=(a.name||a.relativePath||'').toLowerCase();if(query&&!name.includes(query))return false;if(kind==='images')return /\\.(png|jpe?g|gif|webp|svg)$/i.test(name);if(kind==='docs')return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);return true}
1123
+ function renderArtifacts(){const query=(document.getElementById('artifactSearch').value||'').toLowerCase();const kind=document.getElementById('artifactKind').value;const reports=state.artifactReports||[];document.getElementById('artifactList').innerHTML=reports.map(r=>{const files=(r.artifacts||[]).filter(a=>artifactMatches(a,kind,query));if(files.length===0)return'';const gallery=files.map(a=>{const href='/api/artifacts/file?turnId='+encodeURIComponent(r.turnId)+'&path='+encodeURIComponent(a.relativePath)+(token?'&token='+encodeURIComponent(token):'');const img=/\\.(png|jpe?g|gif|webp|svg)$/i.test(a.name)?'<img src="'+href+'">':'<pre>'+esc(a.name.split('.').pop()||'file')+'</pre>';return '<div class="artifact-card"><label><input type="checkbox" data-artifact-select="'+attr(r.turnId)+'" '+(state.selectedArtifactTurns.has(r.turnId)?'checked':'')+'> '+esc(short(a.name,32))+'</label>'+img+'<small>'+esc(fmtBytes(a.sizeBytes))+'</small><div class="row"><a href="'+href+'">Open</a><button class="secondary" data-preview-turn="'+attr(r.turnId)+'" data-preview-path="'+attr(a.relativePath)+'">Preview</button></div></div>'}).join('');return '<div class="item"><strong>'+esc(r.turnId)+' - '+files.length+'/'+r.fileCount+' files - '+fmtBytes(r.totalSizeBytes)+'</strong><small>'+fmtDate(r.updatedAt)+' / '+esc(r.source||'turn')+'</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><div class="gallery">'+gallery+'</div></div>'}).join('')||'<div class="item">No artifacts.</div>';document.querySelectorAll('[data-artifact-select]').forEach(c=>c.onchange=()=>{if(c.checked)state.selectedArtifactTurns.add(c.dataset.artifactSelect);else state.selectedArtifactTurns.delete(c.dataset.artifactSelect)});document.querySelectorAll('[data-del-art]').forEach(b=>b.onclick=()=>safe(async()=>{if(confirm('Delete artifact turn '+b.dataset.delArt+'?')){await api('/api/artifacts?turnId='+encodeURIComponent(b.dataset.delArt),{method:'DELETE'});state.selectedArtifactTurns.delete(b.dataset.delArt);loadArtifacts()}}));document.querySelectorAll('[data-preview-turn]').forEach(b=>b.onclick=()=>previewArtifact(b.dataset.previewTurn,b.dataset.previewPath))}
755
1124
  document.getElementById('reloadArtifactsBtn').onclick=loadArtifacts;
1125
+ document.getElementById('artifactSearch').oninput=renderArtifacts;
1126
+ document.getElementById('artifactKind').onchange=renderArtifacts;
1127
+ document.getElementById('zipSelectedArtifactsBtn').onclick=()=>{const turnIds=[...state.selectedArtifactTurns];if(turnIds.length===0){toast('No artifact turns selected');return}turnIds.forEach(turnId=>window.open('/api/artifacts/zip?turnId='+encodeURIComponent(turnId)+(token?'&token='+encodeURIComponent(token):''),'_blank'))};
1128
+ document.getElementById('deleteSelectedArtifactsBtn').onclick=()=>safe(async()=>{const turnIds=[...state.selectedArtifactTurns];if(turnIds.length===0){toast('No artifact turns selected');return}if(confirm('Delete '+turnIds.length+' selected artifact turn(s)?')){const r=await api('/api/artifacts/bulk',{method:'POST',body:JSON.stringify({action:'delete',turnIds})});state.selectedArtifactTurns.clear();toast('Deleted '+(r.removed||[]).length+' artifact turn(s)');loadArtifacts()}});
1129
+ function highlightCode(text){return esc(text).replace(/\\b(import|export|const|let|function|return|if|else|for|while|class|interface|type|async|await)\\b/g,'<span class="chip">$1</span>')}
1130
+ async function previewArtifact(turnId,path){const data=await api('/api/artifacts/preview?turnId='+encodeURIComponent(turnId)+'&path='+encodeURIComponent(path));const target=document.getElementById('artifactPreview');if(data.kind==='image'){target.innerHTML='<div class="panel"><h2>'+esc(data.name)+'</h2><img src="/api/artifacts/file?turnId='+encodeURIComponent(turnId)+'&path='+encodeURIComponent(path)+(token?'&token='+encodeURIComponent(token):'')+'"></div>';return}if(data.kind==='text'){target.innerHTML='<div class="panel"><h2>'+esc(data.name)+' '+fmtBytes(data.sizeBytes)+'</h2><pre>'+highlightCode(data.text||'')+'</pre>'+(data.truncated?'<small>Preview truncated.</small>':'')+'</div>';return}target.innerHTML='<div class="panel"><h2>'+esc(data.name)+'</h2><p>'+esc(data.detail||'Preview unavailable')+'</p></div>'}
1131
+ async function loadActivity(){const q='?source='+encodeURIComponent(val('activitySource'))+'&status='+encodeURIComponent(val('activityStatus'))+'&limit='+encodeURIComponent(val('activityLimit')||'100');const data=await api('/api/activity'+q);state.activityEvents=data.events||[];renderActivity(state.activityEvents)}
1132
+ function renderActivity(events){const since=val('activitySince')?new Date(val('activitySince')).getTime():0;const filtered=(events||[]).filter(e=>!since||new Date(e.timestamp).getTime()>=since);document.getElementById('activityList').innerHTML=filtered.map(e=>'<div class="item"><strong><span class="chip '+(e.status==='failed'?'error':e.status==='queued'?'warn':'')+'">'+esc(e.status)+'</span>'+esc(fmtDate(e.timestamp)+' / '+e.source+' / '+e.type)+'</strong><small>'+esc(short(e.prompt||e.detail||'',220))+'</small><small><button type="button" class="copy-id" data-copy-id="'+attr(e.threadId||'')+'">'+esc(e.threadId||'-')+'</button> / '+esc((e.workspace||'-')+' / '+fmtDuration(e.durationMs))+'</small></div>').join('')||'<div class="item">No activity.</div>';document.querySelectorAll('#activityList [data-copy-id]').forEach(b=>b.onclick=()=>copyText(b.dataset.copyId||'','Thread ID copied'))}
1133
+ document.getElementById('loadActivityBtn').onclick=()=>loadActivity();
1134
+ document.getElementById('activitySince').onchange=()=>renderActivity(state.activityEvents||[]);
1135
+ document.getElementById('exportActivityBtn').onclick=()=>{const rows=(state.activityEvents||[]).map(e=>[e.timestamp,e.source,e.status,e.type,e.threadId||'',e.prompt||e.detail||''].join('\\t')).join('\\n');const blob=new Blob([rows],{type:'text/tab-separated-values'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='nordrelay-activity.tsv';a.click();URL.revokeObjectURL(a.href)};
756
1136
  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));
1137
+ const settingsGroupOrder=['Agents','Codex','Pi','Hermes','OpenClaw','Claude Code','Telegram','Operations','Artifacts','Workspace','Voice','Dashboard'];
1138
+ const agentSettingGroups=['Codex','Pi','Hermes','OpenClaw','Claude Code'];
1139
+ function orderedSettingsGroups(groups){const known=settingsGroupOrder.filter(name=>groups[name]);const extra=Object.keys(groups).filter(name=>!settingsGroupOrder.includes(name)).sort();return known.concat(extra)}
1140
+ function agentSettingsNav(current){return '<div class="agent-settings-nav"><strong>Agent settings</strong>'+agentSettingGroups.map(name=>'<button type="button" data-setting-tab="'+attr(name)+'" class="'+(name===current?'active':'')+'">'+esc(name)+'</button>').join('')+'</div>'}
1141
+ function renderSettings(){const groups={};state.settings.forEach(s=>(groups[s.group]??=[]).push(s));const names=orderedSettingsGroups(groups);if(!state.settingsGroup||!groups[state.settingsGroup])state.settingsGroup=groups.Agents?'Agents':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]||[];const nav=(state.settingsGroup==='Agents'||agentSettingGroups.includes(state.settingsGroup))?agentSettingsNav(state.settingsGroup):'';document.getElementById('settingsForm').innerHTML='<div class="settings-section"><h2>'+esc(state.settingsGroup||'Settings')+'</h2><div id="settingsRestartBanner"></div>'+nav+items.map(s=>'<div class="setting" data-setting-box="'+attr(s.key)+'" data-restart-required="'+(s.restartRequired?'true':'false')+'"><label>'+esc(s.label)+'</label>'+settingInput(s)+'<small>'+esc(s.key)+' - '+esc(s.description)+(s.effectiveValue?' Active: '+esc(s.effectiveValue)+'.':'')+(s.restartRequired?' Restart required.':'')+(s.configured?' Configured.':' Inherited/default.')+'</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="'+attr(s.key)+'">Use default</button>'+(s.kind==='secret'?'<button type="button" class="secondary" data-reveal-setting="'+attr(s.key)+'">Reveal/replace</button>':'')+'</div><div class="setting-error"></div></div>').join('')+'</div>';document.querySelectorAll('[data-setting-tab]').forEach(b=>b.onclick=()=>{state.settingsGroup=b.dataset.settingTab;renderSettings()});bindSettingsUx()}
1142
+ function settingAttrs(s,original){return ' data-setting="'+attr(s.key)+'" data-original-value="'+attr(original)+'" data-configured="'+(s.configured?'true':'false')+'"'}
1143
+ function settingInput(s){const display=s.configured?(s.value||''):(s.effectiveValue||''); if(s.options){const blankLabel=s.effectiveValue?'Use active default ('+s.effectiveValue+')':'Use active default';return '<select'+settingAttrs(s,s.configured?s.value:'')+'><option value="" '+(!s.configured?'selected':'')+'>'+esc(blankLabel)+'</option>'+s.options.map(o=>'<option value="'+attr(o)+'" '+(s.configured&&s.value===o?'selected':'')+'>'+esc(o)+'</option>').join('')+'</select>'} if(s.kind==='boolean'){const blankLabel=s.effectiveValue?'Use active default ('+s.effectiveValue+')':'Use active default';return '<select'+settingAttrs(s,s.configured?s.value:'')+'><option value="" '+(!s.configured?'selected':'')+'>'+esc(blankLabel)+'</option><option value="true" '+(s.configured&&s.value==='true'?'selected':'')+'>true</option><option value="false" '+(s.configured&&s.value==='false'?'selected':'')+'>false</option></select>'} const value=esc(display); if(s.kind==='json')return '<textarea rows="4"'+settingAttrs(s,display)+'>'+value+'</textarea>'; return '<input'+settingAttrs(s,display)+' value="'+value+'" '+(s.kind==='secret'?'type="password"':'')+'>'}
1144
+ function bindSettingsUx(){document.querySelectorAll('[data-setting]').forEach(el=>{el.oninput=markSettingDirty;el.onchange=markSettingDirty});document.querySelectorAll('[data-reset-setting]').forEach(b=>b.onclick=()=>{const input=document.querySelector('[data-setting="'+cssEscape(b.dataset.resetSetting)+'"]');if(input){input.value='';markSettingDirty({target:input})}});document.querySelectorAll('[data-reveal-setting]').forEach(b=>b.onclick=()=>{const input=document.querySelector('[data-setting="'+cssEscape(b.dataset.revealSetting)+'"]');if(input){input.type=input.type==='password'?'text':'password';input.focus()}})}
1145
+ function markSettingDirty(e){const el=e.target;const box=el.closest('.setting');const dirty=el.value!==(el.dataset.originalValue??'');box.classList.toggle('dirty',dirty);const dirtyInputs=Array.from(document.querySelectorAll('[data-setting]')).filter(x=>x.value!==(x.dataset.originalValue??''));const restart=dirtyInputs.some(x=>x.closest('.setting')?.dataset.restartRequired==='true');document.getElementById('settingsStatus').textContent=dirtyInputs.length?dirtyInputs.length+' unsaved change(s)':'';const banner=document.getElementById('settingsRestartBanner');if(banner)banner.innerHTML=restart?'<div class="restart-banner">Some changed settings require a NordRelay restart.</div>':''}
1146
+ document.getElementById('saveSettingsBtn').onclick=()=>safe(async()=>{document.querySelectorAll('.setting-error').forEach(e=>e.textContent='');const patch={};document.querySelectorAll('[data-setting]').forEach(el=>{const original=el.dataset.originalValue??'';if(el.value!==original)patch[el.dataset.setting]=el.value});const r=await api('/api/settings',{method:'PATCH',body:JSON.stringify({settings:patch})});(r.errors||[]).forEach(err=>{const box=document.querySelector('[data-setting-box="'+cssEscape(err.key)+'"] .setting-error');if(box)box.textContent=err.message});document.getElementById('settingsStatus').textContent=(r.errors&&r.errors.length)?'Fix '+r.errors.length+' setting error(s)':(r.changedKeys.length?'Saved '+r.changedKeys.length+' setting(s)'+(r.restartRequired?' - restart required':''):'No changes');toast((r.errors&&r.errors.length)?'Settings need attention':'Settings saved');if(!(r.errors&&r.errors.length))await loadSettings()});
1147
+ document.getElementById('restartBtn').onclick=()=>safe(async()=>{if(confirm('Restart NordRelay now?')){await api('/api/runtime/restart',{method:'POST'});toast('Restart requested')}});
1148
+ async function loadAccess(){const d=await api('/api/permissions');document.getElementById('accessPanel').innerHTML=['TELEGRAM_ADMIN_USER_IDS','TELEGRAM_ALLOWED_USER_IDS','TELEGRAM_READONLY_USER_IDS','TELEGRAM_ALLOWED_CHAT_IDS','TELEGRAM_ALLOW_ANY_CHAT','TELEGRAM_ROLE_POLICIES_JSON'].map(key=>{const value=key==='TELEGRAM_ADMIN_USER_IDS'?d.telegramAdminUserIds.join(','):key==='TELEGRAM_ALLOWED_USER_IDS'?d.telegramAllowedUserIds.join(','):key==='TELEGRAM_READONLY_USER_IDS'?d.telegramReadOnlyUserIds.join(','):key==='TELEGRAM_ALLOWED_CHAT_IDS'?d.telegramAllowedChatIds.join(','):key==='TELEGRAM_ALLOW_ANY_CHAT'?String(d.telegramAllowAnyChat):JSON.stringify(d.telegramRolePolicies||{},null,2);return '<div class="setting"><label>'+esc(key)+'</label>'+(key.endsWith('_JSON')?'<textarea rows="5" data-access-setting="'+key+'">'+esc(value)+'</textarea>':'<input data-access-setting="'+key+'" value="'+esc(value)+'">')+'<small>Access control setting. Restart required after saving.</small></div>'}).join('');await loadLocks();await loadAudit()}
1149
+ document.getElementById('loadAccessBtn').onclick=()=>loadAccess();
1150
+ document.getElementById('saveAccessBtn').onclick=()=>safe(async()=>{const settings={};document.querySelectorAll('[data-access-setting]').forEach(el=>settings[el.dataset.accessSetting]=el.value);const r=await api('/api/settings',{method:'PATCH',body:JSON.stringify({settings})});toast((r.errors&&r.errors.length)?'Access settings need attention':'Access settings saved. Restart required.');if(r.errors&&r.errors.length)document.getElementById('accessPanel').insertAdjacentHTML('afterbegin','<div class="restart-banner">'+esc(r.errors.map(e=>e.key+': '+e.message).join(' / '))+'</div>')});
1151
+ async function loadLocks(){const d=await api('/api/locks');document.getElementById('locksList').innerHTML=(d.locks||[]).map(l=>'<div class="item"><strong>'+esc(l.contextKey)+'</strong><small>'+esc((l.ownerName||'owner')+' / '+l.ownerId+' / expires '+fmtDate(l.expiresAt))+'</small></div>').join('')||'<div class="item">No active locks.</div>'}
1152
+ document.getElementById('lockSessionBtn').onclick=()=>safe(async()=>{await api('/api/locks',{method:'POST',body:JSON.stringify({ownerName:'Web dashboard'})});toast('Web session locked');loadLocks()});
1153
+ document.getElementById('unlockSessionBtn').onclick=()=>safe(async()=>{await api('/api/locks',{method:'DELETE'});toast('Web session unlocked');loadLocks()});
1154
+ async function loadAudit(){const d=await api('/api/audit?limit='+encodeURIComponent(val('auditLimit')||'50'));document.getElementById('auditList').innerHTML=(d.events||[]).map(e=>'<div class="item"><strong>'+esc(fmtDate(e.timestamp)+' / '+(e.channelId||'-')+' / '+e.status+' / '+e.action)+'</strong><small>'+esc((e.contextKey||'-')+' / '+(e.agentId||'-')+' / '+(e.threadId||'-'))+'</small><small>'+esc(e.description||e.detail||'')+'</small></div>').join('')||'<div class="item">No audit events.</div>'}
1155
+ document.getElementById('loadAuditBtn').onclick=()=>loadAudit();
1156
+ 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);state.logsPlain=data.plain||'';renderLogs();if(document.getElementById('logFollow').checked)document.getElementById('logs').scrollTop=document.getElementById('logs').scrollHeight}document.getElementById('loadLogsBtn').onclick=loadLogs;
1157
+ function logLevelOf(line){if(line.includes(' ERROR '))return'ERROR';if(line.includes(' WARN '))return'WARN';if(line.includes(' INFO '))return'INFO';return''}
1158
+ function logTimeOf(line){const m=line.match(/^(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})/);return m?new Date(m[1].replace(' ','T')).getTime():0}
1159
+ function renderLogs(){const level=val('logLevel');const query=val('logSearch').toLowerCase();const since=val('logSince')?new Date(val('logSince')).getTime():0;const lines=state.logsPlain.split(/\\n/).filter(line=>(level==='all'||line.includes(level))&&(!query||line.toLowerCase().includes(query))&&(!since||!logTimeOf(line)||logTimeOf(line)>=since));document.getElementById('logs').innerHTML=lines.map(line=>'<span class="log-line '+logLevelOf(line)+'">'+esc(line)+'</span>').join('\\n')||'(empty)'}
1160
+ document.getElementById('logLevel').onchange=renderLogs;document.getElementById('logSearch').oninput=renderLogs;document.getElementById('logSince').onchange=renderLogs;document.getElementById('logAutoRefresh').onchange=e=>{clearInterval(state.logTimer);state.logTimer=null;if(e.target.checked)state.logTimer=setInterval(loadLogs,5000)};document.getElementById('downloadLogsBtn').onclick=()=>{const blob=new Blob([state.logsPlain||''],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='nordrelay-log.txt';a.click();URL.revokeObjectURL(a.href)};
1161
+ async function loadAdapterHealth(){const d=await api('/api/adapters/health');document.getElementById('adapterHealth').innerHTML=(d.adapters||[]).map(a=>'<div class="item"><strong>'+esc(a.label)+' <span class="adapter-status '+esc(a.status)+'">'+esc(a.status)+'</span></strong><small>'+esc('CLI: '+(a.cli.label||'-')+' / path '+(a.cli.path||'-')+' / version '+(a.cli.version||'-'))+'</small><small>'+esc('Auth: '+(a.auth.supported?(a.auth.authenticated?'authenticated':'not authenticated'):'not managed')+' '+(a.auth.detail||''))+'</small><small>'+esc('Version: '+a.version.installed+' / latest '+(a.version.latest||'-')+' / '+a.version.status)+'</small><div class="row"><button data-auth-status="'+attr(a.id)+'">Auth status</button><button data-auth-login="'+attr(a.id)+'" class="secondary" '+(!a.capabilities.login?'disabled':'')+'>Login</button><button data-auth-logout="'+attr(a.id)+'" class="secondary" '+(!a.capabilities.logout?'disabled':'')+'>Logout</button></div></div>').join('')||'<div class="item">No adapters.</div>';document.querySelectorAll('[data-auth-status]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/auth/status?agent='+encodeURIComponent(b.dataset.authStatus));toast(r.agentLabel+': '+r.detail,{duration:6000})}));document.querySelectorAll('[data-auth-login]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/auth/login',{method:'POST',body:JSON.stringify({agentId:b.dataset.authLogin})});toast((r.result?.message||r.detail),{duration:8000});loadAdapterHealth()}));document.querySelectorAll('[data-auth-logout]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/auth/logout',{method:'POST',body:JSON.stringify({agentId:b.dataset.authLogout})});toast((r.result?.message||r.detail),{duration:8000});loadAdapterHealth()}))}
1162
+ document.getElementById('reloadAdaptersBtn').onclick=()=>loadAdapterHealth();
1163
+ async function loadVersion(){const d=await api('/api/version');const checks=d.versionChecks||{};document.getElementById('versionPanel').innerHTML=Object.values(checks).map(v=>'<div class="item"><strong>'+esc((v.status==='current'?'OK ':v.status==='outdated'?'WARN ':'')+v.label)+'</strong><small>'+esc('Installed: '+(v.installedLabel||'-'))+'</small><small>'+esc('Latest: '+(v.latestVersion||'-')+' / '+v.status)+'</small><small>'+esc(v.detail||'')+'</small></div>').join('')+card('Runtime',[['Status',d.state?.status],['Version',d.health?.version],['Codex CLI',d.health?.codexCli],['Pi CLI',d.health?.piCli],['Hermes CLI',d.health?.hermesCli],['OpenClaw CLI',d.health?.openClawCli],['Claude Code CLI',d.health?.claudeCodeCli]])}
1164
+ document.getElementById('loadVersionBtn').onclick=()=>loadVersion();
1165
+ document.getElementById('updateBtn').onclick=()=>safe(async()=>{if(confirm('Start NordRelay self-update now?')){const r=await api('/api/update',{method:'POST'});toast('Update started via '+r.method+'. Log: '+r.logPath,{duration:8000});page('logs');document.getElementById('logTarget').value='update';loadLogs()}});
1166
+ async function loadDiagnostics(){const data=await api('/api/diagnostics');document.getElementById('diagnostics').innerHTML=diagnosticsHtml(data)}
1167
+ function diagnosticsHtml(d){const h=d.health||{};const s=d.snapshot?.session||{};const vc=d.versionChecks||{};const caps=s.capabilities||{};const agentDiag=d.runtime?.agentDiagnostics;return '<div class="list">'+card('Runtime',[['Status',h.state?.status],['PID',h.state?.pid],['App PID',h.state?.appPid],['State',h.stateFile],['Log',h.logFile],['State backend',d.runtime?.stateBackend],['Uptime',h.uptimeSeconds+'s']])+card('Agent',[['Agent',s.agentLabel],['Thread',s.threadId],['Workspace',s.workspace],['Model',s.model],['Reasoning',s.reasoningEffort],['Fast',caps.fastMode?(s.fastMode?'on':'off'):'n/a']])+card('Agent State',(agentDiag?.lines||[]).map(x=>[x.label,x.value]))+card('CLI Versions',Object.values(vc).map(v=>[v.label,(v.status==='current'?'OK ':'WARN ')+(v.installedLabel||'-')+' latest '+(v.latestVersion||'-')]))+card('External Mirror',d.runtime?.externalMirror?Object.entries(d.runtime.externalMirror):[['Status','idle']])+'</div>'}
1168
+ function card(title,rows){return '<div class="item"><strong>'+esc(title)+'</strong>'+rows.map(r=>'<small>'+esc(r[0])+': '+esc(r[1]??'-')+'</small>').join('')+'</div>'}
1169
+ function safe(fn,event){if(event&&event.preventDefault)event.preventDefault();Promise.resolve().then(fn).catch(err=>toast(err.message||String(err)))}
1170
+ loadBootstrap().then(()=>{connectEvents();loadChatHistory();loadSessions();loadArtifacts();loadSettings();loadLogs();loadDiagnostics();loadActivity()}).catch(err=>toast(err.message));
763
1171
  `;
764
1172
  }