@nordbyte/nordrelay 0.3.1 → 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 (48) hide show
  1. package/.env.example +45 -2
  2. package/README.md +204 -30
  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 +328 -159
  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 +12 -1
  17. package/dist/config.js +113 -9
  18. package/dist/hermes-api.js +150 -0
  19. package/dist/hermes-auth.js +96 -0
  20. package/dist/hermes-cli.js +19 -0
  21. package/dist/hermes-launch.js +57 -0
  22. package/dist/hermes-session.js +477 -0
  23. package/dist/hermes-state.js +609 -0
  24. package/dist/index.js +51 -8
  25. package/dist/openclaw-auth.js +27 -0
  26. package/dist/openclaw-cli.js +19 -0
  27. package/dist/openclaw-gateway.js +285 -0
  28. package/dist/openclaw-launch.js +65 -0
  29. package/dist/openclaw-session.js +549 -0
  30. package/dist/openclaw-state.js +409 -0
  31. package/dist/operations.js +83 -2
  32. package/dist/pi-auth.js +59 -0
  33. package/dist/pi-launch.js +61 -0
  34. package/dist/pi-rpc.js +18 -0
  35. package/dist/pi-session.js +103 -15
  36. package/dist/pi-state.js +253 -0
  37. package/dist/relay-runtime.js +673 -51
  38. package/dist/session-format.js +28 -18
  39. package/dist/session-registry.js +40 -15
  40. package/dist/settings-service.js +35 -4
  41. package/dist/web-dashboard-ui.js +18 -0
  42. package/dist/web-dashboard.js +329 -47
  43. package/package.json +8 -3
  44. package/plugins/nordrelay/.codex-plugin/plugin.json +7 -4
  45. package/plugins/nordrelay/commands/remote.md +2 -2
  46. package/plugins/nordrelay/scripts/nordrelay.mjs +131 -3
  47. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +2 -2
  48. package/CHANGELOG.md +0 -26
@@ -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
+ import { renderDashboardNav } from "./web-dashboard-ui.js";
15
16
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
16
- const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
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)) {
@@ -87,15 +88,66 @@ async function handleApi(req, res, url) {
87
88
  return;
88
89
  }
89
90
  if (req.method === "GET" && url.pathname === "/api/control-options") {
90
- sendJson(res, 200, await runtime.controlOptions());
91
+ sendJson(res, 200, await runtime.controlOptions(parseAgentId(url.searchParams.get("agent") ?? undefined)));
91
92
  return;
92
93
  }
93
94
  if (req.method === "GET" && url.pathname === "/api/health") {
94
95
  sendJson(res, 200, await runtime.status());
95
96
  return;
96
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
+ }
97
149
  if (req.method === "GET" && url.pathname === "/api/settings") {
98
- sendJson(res, 200, await settings.snapshot());
150
+ sendJson(res, 200, await settings.snapshot(process.env, activeSettingsValues(config)));
99
151
  return;
100
152
  }
101
153
  if (req.method === "PATCH" && url.pathname === "/api/settings") {
@@ -144,6 +196,10 @@ async function handleApi(req, res, url) {
144
196
  sendJson(res, 200, { session: await runtime.attachSession(stringField(body, "threadId")) });
145
197
  return;
146
198
  }
199
+ if (req.method === "GET" && url.pathname === "/api/sessions/detail") {
200
+ sendJson(res, 200, await runtime.sessionDetail(requiredSearch(url, "threadId")));
201
+ return;
202
+ }
147
203
  if (req.method === "GET" && url.pathname === "/api/models") {
148
204
  sendJson(res, 200, { models: await runtime.listModels() });
149
205
  return;
@@ -181,7 +237,7 @@ async function handleApi(req, res, url) {
181
237
  }));
182
238
  return;
183
239
  }
184
- if (req.method === "POST" && url.pathname === "/api/abort") {
240
+ if (req.method === "POST" && (url.pathname === "/api/abort" || url.pathname === "/api/stop")) {
185
241
  await runtime.abort();
186
242
  sendJson(res, 200, { ok: true });
187
243
  return;
@@ -190,6 +246,14 @@ async function handleApi(req, res, url) {
190
246
  sendJson(res, 200, await runtime.handback());
191
247
  return;
192
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
+ }
193
257
  if (req.method === "GET" && url.pathname === "/api/queue") {
194
258
  sendJson(res, 200, { queue: runtime.queue(), paused: runtime.queuePaused() });
195
259
  return;
@@ -225,6 +289,22 @@ async function handleApi(req, res, url) {
225
289
  sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
226
290
  return;
227
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
+ }
228
308
  if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
229
309
  const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
230
310
  if (!bundle) {
@@ -427,7 +507,7 @@ function sendJson(res, status, value) {
427
507
  res.end(`${JSON.stringify(value)}\n`);
428
508
  }
429
509
  function sendText(res, status, text, contentType) {
430
- res.writeHead(status, { "content-type": contentType });
510
+ res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
431
511
  res.end(text);
432
512
  }
433
513
  function sendFile(res, filePath, filename) {
@@ -504,6 +584,110 @@ function optionalEnv(key) {
504
584
  const value = process.env[key]?.trim();
505
585
  return value || undefined;
506
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
+ }
507
691
  function requireArg(argv, index, flag) {
508
692
  const value = argv[index];
509
693
  if (!value || value.startsWith("--")) {
@@ -577,15 +761,7 @@ function renderDashboardApp(options) {
577
761
  <aside class="sidebar" id="sidebar">
578
762
  <div class="brand"><span class="mark">NR</span><div><strong>NordRelay</strong><small>Remote control</small></div></div>
579
763
  <nav>
580
- <button data-page="overview" class="active">Overview</button>
581
- <button data-page="chat">Chat</button>
582
- <button data-page="sessions">Sessions</button>
583
- <button data-page="queue">Queue</button>
584
- <button data-page="activity">Activity</button>
585
- <button data-page="artifacts">Artifacts</button>
586
- <button data-page="settings">Settings</button>
587
- <button data-page="logs">Logs</button>
588
- <button data-page="diagnostics">Diagnostics</button>
764
+ ${renderDashboardNav()}
589
765
  </nav>
590
766
  </aside>
591
767
  <main>
@@ -596,6 +772,7 @@ function renderDashboardApp(options) {
596
772
  <p id="sessionLine">Loading session...</p>
597
773
  </div>
598
774
  <div class="header-actions">
775
+ <span id="connectionStatus" class="badge">Connecting</span>
599
776
  <select id="agentSelect"></select>
600
777
  <button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
601
778
  <button id="refreshBtn">Refresh</button>
@@ -615,6 +792,10 @@ function renderDashboardApp(options) {
615
792
  <div class="panel chat-panel">
616
793
  <div class="chat-toolbar">
617
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>
618
799
  <button id="clearChatBtn" class="secondary">Clear history</button>
619
800
  <button id="abortBtn">Abort</button>
620
801
  <button id="handbackBtn">Handback</button>
@@ -627,6 +808,7 @@ function renderDashboardApp(options) {
627
808
  <div class="attachment-row">
628
809
  <label class="file-button" for="fileInput">Attach files</label>
629
810
  <input id="fileInput" type="file" multiple>
811
+ <button type="button" id="recordBtn" class="secondary">Record voice</button>
630
812
  <span id="fileSummary">No files selected</span>
631
813
  <button type="button" id="clearFilesBtn" class="secondary">Clear</button>
632
814
  </div>
@@ -638,6 +820,13 @@ function renderDashboardApp(options) {
638
820
  </div>
639
821
  </section>
640
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
+
641
830
  <section class="page" id="page-sessions">
642
831
  <div class="panel">
643
832
  <div class="sessions-toolbar">
@@ -658,19 +847,45 @@ function renderDashboardApp(options) {
658
847
 
659
848
  <section class="page" id="page-activity">
660
849
  <div class="panel">
661
- <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="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button></div>
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>
662
851
  <div id="activityList" class="list"></div>
663
852
  </div>
664
853
  </section>
665
854
 
666
855
  <section class="page" id="page-artifacts">
667
856
  <div class="panel">
668
- <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>
669
858
  <div id="artifactList" class="list"></div>
670
859
  <div id="artifactPreview" class="preview"></div>
671
860
  </div>
672
861
  </section>
673
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>
886
+ </div>
887
+ </section>
888
+
674
889
  <section class="page" id="page-settings">
675
890
  <div class="panel">
676
891
  <div class="row"><button id="saveSettingsBtn">Save settings</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
@@ -681,7 +896,7 @@ function renderDashboardApp(options) {
681
896
 
682
897
  <section class="page" id="page-logs">
683
898
  <div class="panel">
684
- <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="logLines" type="number" value="120" min="1" max="300"><label class="checkbox"><input id="logAutoRefresh" type="checkbox"> Auto</label><button id="loadLogsBtn">Load logs</button><button id="downloadLogsBtn" class="secondary">Download</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>
685
900
  <pre id="logs"></pre>
686
901
  </div>
687
902
  </section>
@@ -712,6 +927,10 @@ function renderDashboardApp(options) {
712
927
  <div class="row dialog-actions"><button type="button" id="cancelSessionBtn" class="secondary">Cancel</button><button id="createSessionBtn" value="default">Create session</button></div>
713
928
  </form>
714
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>
715
934
  <div id="toast"></div>
716
935
  <script>${dashboardJs()}</script>
717
936
  </body>
@@ -721,13 +940,15 @@ function dashboardCss() {
721
940
  return `
722
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}
723
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}
724
- *{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{min-height:calc(100vh - 170px);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: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}.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{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:2px 8px;color:var(--muted);font-size:12px}.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}.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}}
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}}
725
946
  `;
726
947
  }
727
948
  function dashboardJs() {
728
949
  return `
729
950
  const token = localStorage.getItem('nordrelayDashboardToken') || '';
730
- const state = { snapshot:null, controls:null, enabledAgents:[], settings:[], currentPage:'overview', settingsGroup:null, logsPlain:'', logTimer:null, toastTimer:null, cliStatusActive:false };
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 };
731
952
  const authHeaders = () => token ? { authorization: 'Bearer ' + token } : {};
732
953
  async function api(path, options={}) {
733
954
  const headers = { ...(options.body ? {'content-type':'application/json'} : {}), ...authHeaders(), ...(options.headers||{}) };
@@ -743,16 +964,18 @@ function esc(s){return String(s??'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;
743
964
  function attr(s){return esc(s).replace(/"/g,'&quot;')}
744
965
  function cssEscape(s){return window.CSS&&CSS.escape?CSS.escape(s):String(s).replace(/[^a-zA-Z0-9_-]/g,'\\\\$&')}
745
966
  function short(s,max=250){const text=String(s??'');return text.length>max?text.slice(0,max-1)+'...':text}
746
- 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)}
747
968
  function fmtDate(s){return s?new Date(s).toLocaleString(): '-'}
748
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'}
749
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:'')}
750
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'}
751
- function isCliRunningStatus(msg){return /^Codex CLI running\\b/.test(String(msg||''))}
752
- function isCliDoneStatus(msg){return /^Codex CLI task\\b/.test(String(msg||''))}
974
+ function isCliRunningStatus(msg){return / CLI running\\b/.test(String(msg||''))}
975
+ function isCliDoneStatus(msg){return / CLI task\\b/.test(String(msg||''))}
753
976
  function applyTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('nordrelayTheme',theme);document.getElementById('themeBtn').textContent=theme==='dark'?'Light':'Dark'}
754
977
  function toggleTheme(){applyTheme(document.documentElement.dataset.theme==='dark'?'light':'dark')}
755
- 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();}
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();}
756
979
  document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>page(b.dataset.page));
757
980
  document.getElementById('menuBtn').onclick=()=>document.getElementById('sidebar').classList.toggle('open');
758
981
  document.getElementById('refreshBtn').onclick=()=>loadBootstrap();
@@ -798,13 +1021,13 @@ function renderSnapshot(s){
798
1021
  document.getElementById('sessionLine').textContent=(s.session.agentLabel||'Agent')+' / '+(s.session.model||'default')+' / '+(s.session.threadId||'not started');
799
1022
  document.getElementById('sessionText').textContent=s.sessionText||'';
800
1023
  document.getElementById('metrics').innerHTML=[
801
- ['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.fastMode?'on':'off']
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']
802
1025
  ].map(([k,v])=>'<div class="metric"><div class="label">'+esc(k)+'</div><div class="value">'+esc(v)+'</div></div>').join('');
803
1026
  renderQueue(s.queue,s.queuePaused);
804
1027
  }
805
1028
  function renderSessionControls(){
806
1029
  const c=state.controls||{};const s=state.snapshot?.session||{};const caps=c.capabilities||{};
807
- const modelOptions=['<option value="">Default</option>'].concat((c.models||[]).map(m=>'<option value="'+attr(m.slug)+'" '+(m.slug===s.model?'selected':'')+'>'+esc(m.displayName||m.slug)+'</option>')).join('');
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('');
808
1031
  const reasoningOptions=(c.reasoningOptions||[]).map(v=>'<option value="'+attr(v)+'" '+(v===s.reasoningEffort?'selected':'')+'>'+esc(v)+'</option>').join('');
809
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('');
810
1033
  document.getElementById('sessionControls').innerHTML=[
@@ -819,70 +1042,129 @@ function renderSessionControls(){
819
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()});
820
1043
  }
821
1044
  function renderAdapters(channels, agents){
822
- 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>';
823
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>'}
824
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}
825
- function renderChatMessages(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}
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}
826
1053
  async function loadChatHistory(){const data=await api('/api/chat/history');renderChatMessages(data.messages||[])}
827
1054
  let currentAgentMessage=null;
828
1055
  function connectEvents(){
1056
+ if(state.events) state.events.close();
829
1057
  const qs = token ? '?token='+encodeURIComponent(token) : '';
830
1058
  const events = new EventSource('/api/events'+qs);
1059
+ state.events=events;
1060
+ setConnection('Connecting','warn');
1061
+ events.onopen=()=>{if(state.reconnectTimer){clearTimeout(state.reconnectTimer);state.reconnectTimer=null}setConnection('Live','ok')};
831
1062
  events.addEventListener('snapshot', e=>{const d=JSON.parse(e.data).data;state.snapshot=d;renderSnapshot(d);renderSessionControls()});
832
1063
  events.addEventListener('chat_history', e=>renderChatMessages(JSON.parse(e.data).messages||[]));
833
1064
  events.addEventListener('activity_update', e=>renderActivity(JSON.parse(e.data).events||[]));
834
1065
  events.addEventListener('session_update', e=>{loadBootstrap();loadChatHistory()});
835
1066
  events.addEventListener('queue_update', e=>{const d=JSON.parse(e.data);renderQueue(d.queue,d.paused)});
836
- events.addEventListener('turn_start', e=>{const d=JSON.parse(e.data);appendMessage('user',d.prompt);currentAgentMessage=appendMessage('agent','')});
837
- events.addEventListener('text_delta', e=>{const d=JSON.parse(e.data);if(!currentAgentMessage)currentAgentMessage=appendMessage('agent','');currentAgentMessage.textContent+=d.delta;currentAgentMessage.scrollIntoView({block:'end'})});
838
- events.addEventListener('tool_start', e=>{const d=JSON.parse(e.data);tool('tool','Started '+d.toolName)});
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()});
839
1070
  events.addEventListener('tool_update', e=>{const d=JSON.parse(e.data);if(d.partialResult)tool('tool',d.partialResult.slice(-600))});
840
1071
  events.addEventListener('tool_end', e=>{const d=JSON.parse(e.data);tool(d.isError?'danger':'tool','Finished '+d.toolCallId+(d.isError?' with error':''))});
841
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'))});
842
1073
  events.addEventListener('turn_error', e=>{const d=JSON.parse(e.data);appendMessage('system','Error: '+d.error);currentAgentMessage=null});
843
- events.addEventListener('turn_complete', ()=>{currentAgentMessage=null;loadBootstrap()});
1074
+ events.addEventListener('turn_complete', ()=>{currentAgentMessage=null;notify('NordRelay turn finished','The active task completed.');loadBootstrap();if(state.currentPage==='tasks')loadTasks()});
844
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)});
845
- events.onerror=()=>{};
1076
+ events.onerror=()=>{setConnection('Reconnecting','error');if(!state.reconnectTimer)state.reconnectTimer=setTimeout(()=>{state.reconnectTimer=null;connectEvents()},5000)};
846
1077
  }
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})}
847
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)})}
848
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()}
849
1083
  setInterval(updateToolAgeTitles,30000);
850
1084
  let selectedFiles=[];
851
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()}
852
1087
  async function filePayload(file){return {name:file.name || 'upload',mimeType:file.type || 'application/octet-stream',dataBase64:await fileToBase64(file)}}
853
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)}
854
- document.getElementById('fileInput').onchange=e=>{selectedFiles=Array.from(e.target.files||[]);renderSelectedFiles()};
1089
+ document.getElementById('fileInput').onchange=e=>{addFiles(e.target.files)};
855
1090
  document.getElementById('clearFilesBtn').onclick=()=>{selectedFiles=[];document.getElementById('fileInput').value='';renderSelectedFiles()};
856
- 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)appendMessage('system','Queued prompt '+r.queueId)},e);
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);
857
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();
858
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')}});
859
1102
  document.getElementById('abortBtn').onclick=()=>safe(async()=>{await api('/api/abort',{method:'POST'});toast('Abort sent')});
860
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'))});
861
- function populateNewSessionForm(agents){const c=state.controls||{};const s=state.snapshot?.session||{};document.getElementById('newAgent').innerHTML=(agents||[]).map(a=>'<option value="'+attr(a)+'" '+(a===s.agentId?'selected':'')+'>'+esc(a)+'</option>').join('');document.getElementById('newWorkspace').value=s.workspace||'';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(m.displayName||m.slug)+'</option>').join(''));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);document.getElementById('newLaunchWrap').style.display=(c.capabilities&&c.capabilities.launchProfiles)?'grid':'none';document.getElementById('newFastWrap').style.display=(c.capabilities&&c.capabilities.fastMode)?'inline-flex':'none'}
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)})}
862
1107
  function openNewSessionDialog(){populateNewSessionForm(state.enabledAgents);document.getElementById('newSessionDialog').showModal()}
863
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);
864
1109
  document.getElementById('cancelSessionBtn').onclick=()=>document.getElementById('newSessionDialog').close();
865
1110
  function val(id){return document.getElementById(id).value.trim()}
866
- 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()})}
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();
867
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()}};
868
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)}})})}
869
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)}));
870
- 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)+' / '+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>'+r.artifacts.slice(0,12).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)+' <button class="secondary" data-preview-turn="'+attr(r.turnId)+'" data-preview-path="'+attr(a.relativePath)+'">Preview</button></small>').join('')+'</div>').join('')||'<div class="item">No artifacts.</div>';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'});loadArtifacts()}}));document.querySelectorAll('[data-preview-turn]').forEach(b=>b.onclick=()=>previewArtifact(b.dataset.previewTurn,b.dataset.previewPath))}
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))}
871
1124
  document.getElementById('reloadArtifactsBtn').onclick=loadArtifacts;
872
- 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>'+esc(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>'}
873
- 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);renderActivity(data.events||[])}
874
- function renderActivity(events){document.getElementById('activityList').innerHTML=(events||[]).map(e=>'<div class="item"><strong>'+esc(fmtDate(e.timestamp)+' / '+e.source+' / '+e.status+' / '+e.type)+'</strong><small>'+esc(short(e.prompt||e.detail||'',220))+'</small><small>'+esc((e.threadId||'-')+' / '+(e.workspace||'-')+' / '+fmtDuration(e.durationMs))+'</small></div>').join('')||'<div class="item">No activity.</div>'}
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'))}
875
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)};
876
1136
  async function loadSettings(){const data=await api('/api/settings');state.settings=data.settings;renderSettings()}
877
- 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" data-setting-box="'+attr(s.key)+'"><label>'+esc(s.label)+'</label>'+settingInput(s)+'<small>'+esc(s.key)+' - '+esc(s.description)+(s.restartRequired?' Restart required.':'')+(s.configured?' Configured.':' Inherited/default.')+'</small><div class="setting-error"></div></div>').join('')+'</div>'}
878
- function settingInput(s){const value=esc(s.value||''); if(s.options)return '<select data-setting="'+s.key+'"><option value=""></option>'+s.options.map(o=>'<option value="'+attr(o)+'" '+(s.value===o?'selected':'')+'>'+esc(o)+'</option>').join('')+'</select>'; if(s.kind==='boolean')return '<select data-setting="'+s.key+'"><option value=""></option><option value="true" '+(s.value==='true'?'selected':'')+'>true</option><option value="false" '+(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"':'')+'>'}
879
- document.getElementById('saveSettingsBtn').onclick=()=>safe(async()=>{document.querySelectorAll('.setting-error').forEach(e=>e.textContent='');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})});(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')});
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()});
880
1147
  document.getElementById('restartBtn').onclick=()=>safe(async()=>{if(confirm('Restart NordRelay now?')){await api('/api/runtime/restart',{method:'POST'});toast('Restart requested')}});
881
- 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()}document.getElementById('loadLogsBtn').onclick=loadLogs;
882
- function renderLogs(){const level=val('logLevel');const query=val('logSearch').toLowerCase();const lines=state.logsPlain.split(/\\n/).filter(line=>(level==='all'||line.includes(level))&&(!query||line.toLowerCase().includes(query)));document.getElementById('logs').textContent=lines.join('\\n')||'(empty)'}
883
- document.getElementById('logLevel').onchange=renderLogs;document.getElementById('logSearch').oninput=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([document.getElementById('logs').textContent||''],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='nordrelay-log.txt';a.click();URL.revokeObjectURL(a.href)};
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()}});
884
1166
  async function loadDiagnostics(){const data=await api('/api/diagnostics');document.getElementById('diagnostics').innerHTML=diagnosticsHtml(data)}
885
- function diagnosticsHtml(d){const h=d.health||{};const s=d.snapshot?.session||{};const vc=d.versionChecks||{};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',s.fastMode?'on':'off']])+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>'}
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>'}
886
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>'}
887
1169
  function safe(fn,event){if(event&&event.preventDefault)event.preventDefault();Promise.resolve().then(fn).catch(err=>toast(err.message||String(err)))}
888
1170
  loadBootstrap().then(()=>{connectEvents();loadChatHistory();loadSessions();loadArtifacts();loadSettings();loadLogs();loadDiagnostics();loadActivity()}).catch(err=>toast(err.message));