@nordbyte/nordrelay 0.8.0 → 0.8.2

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 (173) hide show
  1. package/.env.example +9 -0
  2. package/README.md +81 -1197
  3. package/dist/{access-control.js → access/access-control.js} +1 -1
  4. package/dist/{audit-log.js → access/audit-log.js} +2 -2
  5. package/dist/{session-locks.js → access/session-locks.js} +1 -1
  6. package/dist/{user-management.js → access/user-management.js} +1 -1
  7. package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
  8. package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
  9. package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
  10. package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
  11. package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
  12. package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
  13. package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
  14. package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
  15. package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
  16. package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
  17. package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
  18. package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
  19. package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
  20. package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
  21. package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
  22. package/dist/agents/shared/agent-auth-commands.js +30 -0
  23. package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
  24. package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
  25. package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
  26. package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
  27. package/dist/{discord-bot.js → channels/discord/discord-bot.js} +164 -424
  28. package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
  29. package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
  30. package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
  31. package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
  32. package/dist/channels/shared/channel-bridge-controller.js +69 -0
  33. package/dist/channels/shared/channel-cli-artifacts.js +51 -0
  34. package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
  35. package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
  36. package/dist/channels/shared/channel-external-monitor.js +52 -0
  37. package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
  38. package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
  39. package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +2 -2
  40. package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
  41. package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
  42. package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
  43. package/dist/{slack-bot.js → channels/slack/slack-bot.js} +159 -294
  44. package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
  45. package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
  46. package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
  47. package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
  48. package/dist/{bot.js → channels/telegram/bot.js} +178 -427
  49. package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
  50. package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
  51. package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
  52. package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
  53. package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
  54. package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
  55. package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
  56. package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
  57. package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
  58. package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
  59. package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
  60. package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
  61. package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
  62. package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
  63. package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
  64. package/dist/{config.js → core/config.js} +11 -3
  65. package/dist/index.js +27 -23
  66. package/dist/{peer-client.js → peers/peer-client.js} +57 -1
  67. package/dist/peers/peer-discovery-jobs.js +206 -0
  68. package/dist/peers/peer-discovery.js +223 -0
  69. package/dist/peers/peer-health-monitor.js +49 -0
  70. package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
  71. package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
  72. package/dist/{peer-server.js → peers/peer-server.js} +23 -6
  73. package/dist/{peer-store.js → peers/peer-store.js} +84 -11
  74. package/dist/{peer-types.js → peers/peer-types.js} +9 -0
  75. package/dist/peers/peer-web-proxy-contract.js +127 -0
  76. package/dist/{metrics.js → runtime/metrics.js} +5 -3
  77. package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
  78. package/dist/runtime/relay-auth-service.js +63 -0
  79. package/dist/runtime/relay-dashboard-service.js +139 -0
  80. package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +140 -53
  81. package/dist/runtime/relay-runtime-active-sessions.js +387 -0
  82. package/dist/runtime/relay-runtime-dashboard.js +201 -0
  83. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +307 -0
  84. package/dist/runtime/relay-runtime-sessions.js +623 -0
  85. package/dist/runtime/relay-runtime-types.js +1 -0
  86. package/dist/runtime/relay-runtime-updates-jobs.js +360 -0
  87. package/dist/runtime/relay-runtime.js +451 -0
  88. package/dist/runtime/runtime-cache.js +117 -0
  89. package/dist/{session-registry.js → state/session-registry.js} +3 -3
  90. package/dist/{operations.js → support/operations.js} +7 -7
  91. package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
  92. package/dist/{web-api-contract.js → web/web-api-contract.js} +17 -3
  93. package/dist/web/web-api-types.js +1 -0
  94. package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +2 -2
  95. package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +24 -2
  96. package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
  97. package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +37 -10
  98. package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +102 -7
  99. package/dist/web/web-dashboard-security.js +14 -0
  100. package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +12 -1
  101. package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
  102. package/dist/web/web-performance.js +60 -0
  103. package/dist/web/web-rate-limit.js +19 -0
  104. package/dist/{web-state.js → web/web-state.js} +74 -5
  105. package/dist/webui-assets/dashboard.css +171 -10
  106. package/dist/webui-assets/dashboard.js +515 -48
  107. package/dist/webui-assets/favicon.ico +0 -0
  108. package/dist/webui-assets/favicon.png +0 -0
  109. package/dist/webui-assets/logo.png +0 -0
  110. package/package.json +4 -3
  111. package/plugins/nordrelay/scripts/nordrelay.mjs +17 -5
  112. package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
  113. package/dist/relay-runtime.js +0 -1916
  114. package/dist/runtime-cache.js +0 -57
  115. /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
  116. /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
  117. /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
  118. /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
  119. /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
  120. /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
  121. /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
  122. /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
  123. /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
  124. /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
  125. /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
  126. /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
  127. /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
  128. /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
  129. /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
  130. /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
  131. /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
  132. /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
  133. /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
  134. /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
  135. /package/dist/{agent.js → agents/shared/agent.js} +0 -0
  136. /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
  137. /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
  138. /package/dist/{voice.js → artifacts/voice.js} +0 -0
  139. /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
  140. /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
  141. /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
  142. /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
  143. /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
  144. /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
  145. /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
  146. /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
  147. /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
  148. /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
  149. /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
  150. /package/dist/{activity-events.js → core/activity-events.js} +0 -0
  151. /package/dist/{error-messages.js → core/error-messages.js} +0 -0
  152. /package/dist/{format.js → core/format.js} +0 -0
  153. /package/dist/{logger.js → core/logger.js} +0 -0
  154. /package/dist/{redaction.js → core/redaction.js} +0 -0
  155. /package/dist/{settings-service.js → core/settings-service.js} +0 -0
  156. /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
  157. /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
  158. /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
  159. /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
  160. /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
  161. /package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +0 -0
  162. /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
  163. /package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +0 -0
  164. /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
  165. /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
  166. /package/dist/{job-store.js → state/job-store.js} +0 -0
  167. /package/dist/{persistence.js → state/persistence.js} +0 -0
  168. /package/dist/{prompt-store.js → state/prompt-store.js} +0 -0
  169. /package/dist/{state-backend.js → state/state-backend.js} +0 -0
  170. /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
  171. /package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +0 -0
  172. /package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +0 -0
  173. /package/dist/{web-dashboard-ui.js → web/web-dashboard-ui.js} +0 -0
@@ -25,8 +25,16 @@
25
25
  { path: "/api/peers/invite", methods: ["POST"] },
26
26
  { path: "/api/peers/pair", methods: ["POST"] },
27
27
  { path: "/api/peers/probe", methods: ["POST"] },
28
+ { path: "/api/peers/discover", methods: ["GET"] },
29
+ { path: "/api/peers/discovery-jobs", methods: ["GET", "POST"] },
30
+ { re: /^\/api\/peers\/discovery-jobs\/[^\/]+$/, methods: ["GET"] },
31
+ { re: /^\/api\/peers\/discovery-jobs\/[^\/]+\/cancel$/, methods: ["POST"] },
32
+ { re: /^\/api\/peers\/discovery-jobs\/[^\/]+\/log$/, methods: ["GET"] },
33
+ { path: "/api/peers/identity/backup", methods: ["GET"] },
34
+ { path: "/api/peers/identity/restore", methods: ["POST"] },
28
35
  { path: "/api/peers/global-sessions", methods: ["GET"] },
29
36
  { re: /^\/api\/peers\/invitations\/[^\/]+$/, methods: ["DELETE"] },
37
+ { re: /^\/api\/peers\/[^\/]+\/repin$/, methods: ["POST"] },
30
38
  { re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
31
39
  { re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
32
40
  { re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
@@ -79,6 +87,7 @@
79
87
  { path: "/api/sync", methods: ["POST"] },
80
88
  { path: "/api/queue", methods: ["GET", "POST"] },
81
89
  { path: "/api/chat/history", methods: ["GET", "DELETE"] },
90
+ { path: "/api/chat/mirror", methods: ["GET", "POST"] },
82
91
  { path: "/api/activity", methods: ["GET"] },
83
92
  { path: "/api/artifacts", methods: ["GET", "DELETE"] },
84
93
  { path: "/api/artifacts/bulk", methods: ["POST"] },
@@ -102,6 +111,10 @@
102
111
  assertApiRoute(url.pathname, method);
103
112
  if (!options.local && shouldProxyApi(url.pathname)) {
104
113
  const peerId = selectedPeerTarget();
114
+ const csrfToken2 = (
115
+ /** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { csrfToken?: string | null } }} */
116
+ globalThis.NORDRELAY_WEBUI_RUNTIME_STATE?.csrfToken
117
+ );
105
118
  const proxyBody = JSON.stringify({
106
119
  method,
107
120
  path: url.pathname,
@@ -111,7 +124,7 @@
111
124
  });
112
125
  const res2 = await fetch("/api/peers/" + encodeURIComponent(peerId) + "/proxy", {
113
126
  method: "POST",
114
- headers: { "content-type": "application/json" },
127
+ headers: { "content-type": "application/json", ...csrfToken2 ? { "x-nordrelay-csrf": csrfToken2 } : {} },
115
128
  body: proxyBody
116
129
  });
117
130
  if (res2.status === 401) {
@@ -127,8 +140,13 @@
127
140
  return data2;
128
141
  }
129
142
  const body = normalizeBody(options.body);
143
+ const csrfToken = (
144
+ /** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { csrfToken?: string | null } }} */
145
+ globalThis.NORDRELAY_WEBUI_RUNTIME_STATE?.csrfToken
146
+ );
130
147
  const headers = {
131
148
  ...body !== void 0 && shouldSendJsonHeader(options.body) ? { "content-type": "application/json" } : {},
149
+ ...method !== "GET" && csrfToken ? { "x-nordrelay-csrf": csrfToken } : {},
132
150
  ...options.headers || {}
133
151
  };
134
152
  const res = await fetch(url.pathname + url.search, { method, headers, body });
@@ -148,7 +166,7 @@
148
166
  const peerId = selectedPeerTarget();
149
167
  if (!peerId || peerId === "local") return false;
150
168
  if (!path.startsWith("/api/")) return false;
151
- return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
169
+ return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || path === "/api/peers/discover" || path === "/api/peers/discovery-jobs" || path === "/api/peers/global-sessions" || path === "/api/peers/identity/backup" || path === "/api/peers/identity/restore" || path === "/api/settings/wizard/test" || /^\/api\/peers\/discovery-jobs\//.test(path) || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || /^\/api\/peers\/[^/]+\/repin$/.test(path) || isLocalAdminApi(path));
152
170
  }
153
171
  function isLocalAdminApi(path) {
154
172
  return path === "/api/permissions" || path === "/api/settings" || path === "/api/audit" || path === "/api/locks" || path === "/api/users" || path === "/api/groups" || path === "/api/telegram-chats" || path === "/api/discord-channels" || path === "/api/slack-channels" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(path) || /^\/api\/slack-channels\//.test(path);
@@ -230,20 +248,37 @@
230
248
  throw new Error("Unsupported WebUI API method: " + method + " " + path);
231
249
  }
232
250
  }
233
- const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
251
+ const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, csrfToken: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, stickyToastActive: false, stickyToastText: "", cliStatusActive: false, webMirror: null, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, toolsVisible: false, agentUpdateJobs: [], sessionsRequestId: 0, chatHistoryRequestId: 0, chatRenderVersion: 0, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, peerDiscoveryJobs: [], selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
234
252
  globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
235
253
  function toast(msg, options = {}) {
236
254
  const el = document.getElementById("toast");
237
- el.textContent = msg;
238
- el.style.display = "block";
255
+ const text = String(msg ?? "");
239
256
  if (state.toastTimer) clearTimeout(state.toastTimer);
240
257
  state.toastTimer = null;
241
- if (!options.sticky) {
242
- state.toastTimer = setTimeout(() => {
243
- el.style.display = "none";
244
- state.toastTimer = null;
245
- }, options.duration || 3500);
258
+ if (options.sticky) {
259
+ state.stickyToastActive = true;
260
+ state.stickyToastText = text;
261
+ if (el.textContent !== text) el.textContent = text;
262
+ if (el.style.display !== "block") el.style.display = "block";
263
+ return;
246
264
  }
265
+ el.textContent = text;
266
+ el.style.display = "block";
267
+ state.toastTimer = setTimeout(() => {
268
+ state.toastTimer = null;
269
+ if (state.stickyToastActive) {
270
+ el.textContent = state.stickyToastText;
271
+ el.style.display = "block";
272
+ return;
273
+ }
274
+ el.style.display = "none";
275
+ }, options.duration || 3500);
276
+ }
277
+ function clearStickyToast() {
278
+ state.stickyToastActive = false;
279
+ state.stickyToastText = "";
280
+ if (state.toastTimer) clearTimeout(state.toastTimer);
281
+ state.toastTimer = null;
247
282
  }
248
283
  function esc(s) {
249
284
  return String(s ?? "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
@@ -325,6 +360,7 @@
325
360
  ["#newSessionBtn,#attachBtn,#createSessionBtn", "sessions.write"],
326
361
  ["#retryBtn", "prompt.send"],
327
362
  ["#syncBtn,#handbackBtn", "sessions.write"],
363
+ ["#mirrorModeSelect", "settings.write"],
328
364
  ["#abortBtn", "prompt.abort"],
329
365
  ["#clearChatBtn", "sessions.write"],
330
366
  ["#saveSettingsBtn", "settings.write"],
@@ -334,7 +370,8 @@
334
370
  ["#clearLogsBtn", "logs.clear"],
335
371
  ["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn,#createSlackChannelBtn", "users.write"],
336
372
  ["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke],[data-peer-invite-delete]", "peers.write"],
337
- ["#checkPeerReachabilityBtn,[data-peer-probe]", "peers.connect"],
373
+ ["#checkPeerReachabilityBtn,#discoverPeersBtn,#cancelPeerDiscoveryBtn,[data-peer-probe]", "peers.connect"],
374
+ ["#exportPeerIdentityBtn,#restorePeerIdentityBtn,[data-peer-repin]", "peers.write"],
338
375
  ["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
339
376
  ["[data-switch]", "sessions.write"],
340
377
  ["[data-queue],[data-q]", "queue.write"],
@@ -360,10 +397,10 @@
360
397
  return Math.floor(min / 60) + "h ago";
361
398
  }
362
399
  function isCliRunningStatus(msg) {
363
- return / CLI running\\b/.test(String(msg || ""));
400
+ return / CLI running\b/.test(String(msg || ""));
364
401
  }
365
402
  function isCliDoneStatus(msg) {
366
- return / CLI task\\b/.test(String(msg || ""));
403
+ return / CLI task (?:finished|completed|failed|aborted)\b/i.test(String(msg || ""));
367
404
  }
368
405
  function applyTheme(theme) {
369
406
  document.documentElement.dataset.theme = theme;
@@ -373,6 +410,21 @@
373
410
  function toggleTheme() {
374
411
  applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
375
412
  }
413
+ function setToolsVisible(visible) {
414
+ state.toolsVisible = Boolean(visible);
415
+ const layout = document.getElementById("chatLayout");
416
+ const panel = document.getElementById("toolPanel");
417
+ const button = document.getElementById("toggleToolsBtn");
418
+ layout?.classList.toggle("tools-hidden", !state.toolsVisible);
419
+ if (panel) panel.hidden = !state.toolsVisible;
420
+ if (button) {
421
+ button.textContent = state.toolsVisible ? "Hide Tools" : "Show Tools";
422
+ button.setAttribute("aria-expanded", state.toolsVisible ? "true" : "false");
423
+ }
424
+ }
425
+ function toggleTools() {
426
+ setToolsVisible(!state.toolsVisible);
427
+ }
376
428
  function page(name) {
377
429
  state.currentPage = name;
378
430
  document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
@@ -385,8 +437,8 @@
385
437
  const name = state.currentPage;
386
438
  if (name === "overview") await loadActiveSessions();
387
439
  if (name === "chat") {
388
- await loadChatHistory();
389
- scrollChatToBottom();
440
+ const [historyRendered] = await Promise.all([loadChatHistory({ forceScroll: true }), loadMirrorPreference()]);
441
+ if (historyRendered) scrollChatToBottom({ force: true });
390
442
  }
391
443
  if (name === "sessions") await loadSessions(true, options.agentId);
392
444
  if (name === "settings") await loadSettings();
@@ -405,11 +457,36 @@
405
457
  document.getElementById("menuBtn").onclick = () => document.getElementById("sidebar").classList.toggle("open");
406
458
  document.getElementById("refreshBtn").onclick = () => loadBootstrap();
407
459
  document.getElementById("themeBtn").onclick = toggleTheme;
460
+ document.getElementById("toggleToolsBtn").onclick = toggleTools;
408
461
  document.getElementById("logoutBtn").onclick = () => safe(async () => {
409
462
  await api("/api/dashboard/logout", { method: "POST" });
410
463
  location.href = "/";
411
464
  });
412
465
  applyTheme(localStorage.getItem("nordrelayTheme") || "light");
466
+ setToolsVisible(false);
467
+ function uiBadge(text, status = "enabled") {
468
+ return '<span class="adapter-status ' + esc(status) + '">' + esc(text) + "</span>";
469
+ }
470
+ function uiRows(rows = []) {
471
+ return rows.filter(Boolean).map((row) => Array.isArray(row) ? "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>" : "<small>" + esc(row) + "</small>").join("");
472
+ }
473
+ function uiItem(title, options = {}) {
474
+ const badge = options.badge ? uiBadge(options.badge.text, options.badge.status) : "";
475
+ const rows = uiRows(options.rows || []);
476
+ const body = options.body || "";
477
+ const actions = options.actions ? '<div class="row">' + options.actions + "</div>" : "";
478
+ const titleAttr = options.title ? ' title="' + attr(options.title) + '"' : "";
479
+ return '<div class="item ' + (options.className ? attr(options.className) : "") + '"><strong' + titleAttr + ">" + esc(title) + " " + badge + "</strong>" + rows + body + actions + "</div>";
480
+ }
481
+ function uiEmpty(text) {
482
+ return '<div class="item">' + esc(text) + "</div>";
483
+ }
484
+ function uiCopyButton(value, label = "Copied", className = "copy-id") {
485
+ return value ? '<button type="button" class="' + attr(className) + '" data-copy-value="' + attr(value) + '" data-copy-label="' + attr(label) + '">' + esc(value) + "</button>" : "-";
486
+ }
487
+ function bindUiCopyButtons(root = document) {
488
+ root.querySelectorAll?.("[data-copy-value]").forEach((b) => b.onclick = () => copyText(b.dataset.copyValue || "", b.dataset.copyLabel || "Copied"));
489
+ }
413
490
  function createPaginator(containerId, onChange, pageSize = 50) {
414
491
  const container = document.getElementById(containerId);
415
492
  return {
@@ -443,6 +520,7 @@
443
520
  async function loadBootstrap() {
444
521
  const local = await api("/api/bootstrap", { local: true });
445
522
  state.auth = local.auth || null;
523
+ state.csrfToken = local.auth?.csrfToken || state.csrfToken || null;
446
524
  state.permissions = local.auth?.permissions || [];
447
525
  await loadPeerSelector();
448
526
  const data = state.selectedPeer && state.selectedPeer !== "local" ? await api("/api/bootstrap") : local;
@@ -562,14 +640,14 @@
562
640
  }
563
641
  function activeSessionCard(s) {
564
642
  const thread = s.threadId || "not started";
565
- const prompt = s.prompt ? "<small>" + esc(short(s.prompt, 250)) + "</small>" : "";
643
+ const prompt2 = s.prompt ? "<small>" + esc(short(s.prompt, 250)) + "</small>" : "";
566
644
  const tool2 = s.currentTool || s.lastTool || "-";
567
645
  const queue = s.queueLength ? " \xB7 " + s.queueLength + " queued" + (s.queuePaused ? " paused" : "") : "";
568
646
  const sourceLabel = activeSourceLabel(s.source);
569
647
  const mirrors = (s.mirrorChannels || []).map((m) => activeSourceLabel(m.source) + " " + m.mode + (m.queueLength ? " \xB7 " + m.queueLength + " queued" + (m.queuePaused ? " paused" : "") : "")).join(", ");
570
648
  const meta = ["Source " + sourceLabel, s.workspace, fmtDuration(s.durationMs), tool2 && tool2 !== "-" ? "tool " + tool2 : ""].filter(Boolean).join(" | ");
571
649
  const mirrorLine = mirrors ? "<small>Mirroring: " + esc(mirrors) + "</small>" : s.source === "cli" ? "<small>Mirroring: none</small>" : "";
572
- return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine + prompt + '<div class="row"><button data-active-switch="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + disabledAttr("sessions.write") + '>Switch</button><button class="secondary" data-active-detail="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + ">Details</button></div></div>";
650
+ return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine + prompt2 + '<div class="row"><button data-active-switch="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + disabledAttr("sessions.write") + '>Switch</button><button class="secondary" data-active-detail="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + ">Details</button></div></div>";
573
651
  }
574
652
  function activeSourceLabel(source) {
575
653
  if (source === "cli") return "CLI";
@@ -672,9 +750,19 @@
672
750
  if (status === "running") return "planned";
673
751
  return "disabled";
674
752
  }
675
- function scrollChatToBottom() {
753
+ const CHAT_CODE_BLOCK_PREFIX = "\uE010C";
754
+ const CHAT_CODE_BLOCK_SUFFIX = "\uE010";
755
+ const CHAT_INLINE_CODE_PREFIX = "\uE011I";
756
+ const CHAT_INLINE_CODE_SUFFIX = "\uE011";
757
+ function isChatNearBottom() {
758
+ const box = document.getElementById("messages");
759
+ if (!box) return true;
760
+ return box.scrollHeight - box.scrollTop - box.clientHeight < 80;
761
+ }
762
+ function scrollChatToBottom(options = {}) {
676
763
  const box = document.getElementById("messages");
677
764
  if (!box) return;
765
+ if (!options.force && !isChatNearBottom()) return;
678
766
  requestAnimationFrame(() => {
679
767
  box.scrollTop = box.scrollHeight;
680
768
  requestAnimationFrame(() => {
@@ -682,39 +770,251 @@
682
770
  });
683
771
  });
684
772
  }
685
- function appendMessage(cls, text) {
773
+ function markChatRendered() {
774
+ state.chatRenderVersion = (state.chatRenderVersion || 0) + 1;
775
+ }
776
+ function appendMessage(cls, text, options = {}) {
686
777
  const box = document.getElementById("messages");
778
+ const stick = options.forceScroll || isChatNearBottom();
779
+ const previousTop = box?.scrollTop ?? 0;
687
780
  const div = document.createElement("div");
688
781
  div.className = "message " + cls;
689
- div.textContent = text;
782
+ const body = document.createElement("div");
783
+ body.className = "message-body";
784
+ div.appendChild(body);
785
+ setMessageText(div, text);
690
786
  box.appendChild(div);
691
- scrollChatToBottom();
787
+ if (stick) scrollChatToBottom({ force: true });
788
+ else box.scrollTop = previousTop;
789
+ return div;
790
+ }
791
+ function messageBody(div) {
792
+ let body = div.querySelector?.(".message-body");
793
+ if (!body) {
794
+ body = document.createElement("div");
795
+ body.className = "message-body";
796
+ div.textContent = "";
797
+ div.appendChild(body);
798
+ }
799
+ return body;
800
+ }
801
+ function setMessageText(div, text) {
802
+ div.__rawText = String(text ?? "");
803
+ const body = messageBody(div);
804
+ body.innerHTML = renderChatMarkdown(div.__rawText);
805
+ bindChatCopyButtons(body);
806
+ markChatRendered();
692
807
  return div;
693
808
  }
694
809
  function appendQueuedMessage(id) {
695
810
  const div = appendMessage("system", "Queued prompt " + id);
811
+ const body = messageBody(div);
696
812
  const btn = document.createElement("button");
697
813
  btn.textContent = "Cancel queued message";
698
814
  btn.className = "danger";
699
815
  btn.onclick = () => safe(async () => {
700
816
  const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: "cancel", id }) });
701
817
  renderQueue(r.queue, r.paused);
702
- div.textContent = "Cancelled queued prompt " + id;
818
+ setMessageText(div, "Cancelled queued prompt " + id);
703
819
  });
704
- div.appendChild(document.createElement("br"));
705
- div.appendChild(btn);
820
+ body.appendChild(document.createElement("br"));
821
+ body.appendChild(btn);
706
822
  }
707
- function renderChatMessages(messages) {
823
+ function renderChatMessages(messages, options = {}) {
708
824
  state.chatMessages = messages || [];
709
825
  const box = document.getElementById("messages");
710
- 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("");
711
- scrollChatToBottom();
712
- }
713
- async function loadChatHistory() {
826
+ const stick = options.forceScroll || isChatNearBottom();
827
+ const previousTop = box?.scrollTop ?? 0;
828
+ box.innerHTML = (messages || []).map((m) => '<div class="message ' + esc(m.role) + '"><small>' + esc((m.source || "web") + " / " + fmtDate(m.timestamp)) + '</small><br><div class="message-body">' + renderChatMarkdown(m.text) + "</div></div>").join("");
829
+ bindChatCopyButtons(box);
830
+ markChatRendered();
831
+ if (stick) scrollChatToBottom({ force: true });
832
+ else box.scrollTop = previousTop;
833
+ }
834
+ async function loadChatHistory(options = {}) {
835
+ const requestId = (state.chatHistoryRequestId || 0) + 1;
836
+ state.chatHistoryRequestId = requestId;
837
+ const renderVersion = state.chatRenderVersion || 0;
714
838
  const data = await api("/api/chat/history");
715
- renderChatMessages(data.messages || []);
839
+ if (requestId !== state.chatHistoryRequestId) return false;
840
+ if (options.skipIfRendered !== false && (state.chatRenderVersion || 0) !== renderVersion) return false;
841
+ renderChatMessages(data.messages || [], options);
842
+ return true;
716
843
  }
717
844
  let currentAgentMessage = null;
845
+ function renderChatMarkdown(text) {
846
+ let output = esc(String(text ?? ""));
847
+ const codeBlocks = [];
848
+ const inlineCode = [];
849
+ output = extractChatCodeBlocks(output, codeBlocks);
850
+ output = extractChatInlineCode(output, inlineCode);
851
+ output = formatChatBold(output);
852
+ output = formatChatItalic(output);
853
+ output = formatChatLinks(output);
854
+ output = formatChatBlockquotes(output);
855
+ output = formatChatLists(output);
856
+ output = formatChatHeadings(output);
857
+ output = restoreChatMarkdown(output, CHAT_INLINE_CODE_PREFIX, CHAT_INLINE_CODE_SUFFIX, inlineCode);
858
+ output = restoreChatMarkdown(output, CHAT_CODE_BLOCK_PREFIX, CHAT_CODE_BLOCK_SUFFIX, codeBlocks);
859
+ return output;
860
+ }
861
+ function extractChatCodeBlocks(text, blocks) {
862
+ return text.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_match, rawLanguage, rawCode) => {
863
+ const language = String(rawLanguage || "").trim().replace(/[^a-zA-Z0-9_+-]/g, "");
864
+ const className = language ? ' class="language-' + attr(language) + '"' : "";
865
+ const label = language ? ' data-code-language="' + attr(language) + '"' : "";
866
+ const block = '<pre class="chat-code-block" tabindex="0" title="Copy code" data-chat-copy="code-block"' + label + "><code" + className + ">" + rawCode + "</code></pre>";
867
+ const index = blocks.push(block) - 1;
868
+ return CHAT_CODE_BLOCK_PREFIX + index + CHAT_CODE_BLOCK_SUFFIX;
869
+ });
870
+ }
871
+ function extractChatInlineCode(text, inline) {
872
+ let result = "";
873
+ let index = 0;
874
+ while (index < text.length) {
875
+ if (text[index] !== "`") {
876
+ result += text[index];
877
+ index += 1;
878
+ continue;
879
+ }
880
+ let tickCount = 1;
881
+ while (text[index + tickCount] === "`") tickCount += 1;
882
+ const fence = "`".repeat(tickCount);
883
+ const start = index + tickCount;
884
+ const end = text.indexOf(fence, start);
885
+ if (end === -1) {
886
+ result += fence;
887
+ index += tickCount;
888
+ continue;
889
+ }
890
+ const content = text.slice(start, end);
891
+ if (content.includes("\n")) {
892
+ result += fence;
893
+ index += tickCount;
894
+ continue;
895
+ }
896
+ const button = '<button type="button" class="chat-inline-code copy-id" title="Copy code" data-chat-copy="inline-code">' + content + "</button>";
897
+ result += CHAT_INLINE_CODE_PREFIX + (inline.push(button) - 1) + CHAT_INLINE_CODE_SUFFIX;
898
+ index = end + tickCount;
899
+ }
900
+ return result;
901
+ }
902
+ function formatChatBold(text) {
903
+ return text.replace(/(?<!\*)\*\*(?!\s)([^\n]*?\S)\*\*(?!\*)/g, "<strong>$1</strong>");
904
+ }
905
+ function formatChatItalic(text) {
906
+ return text.replace(/(?<![\w_])_(?!\s)([^_\n]*?\S)_(?![\w_])/g, "<em>$1</em>").replace(/(?<![\w*])\*(?!\s)([^*\n]*?\S)\*(?![\w*])/g, "<em>$1</em>");
907
+ }
908
+ function formatChatLinks(text) {
909
+ return text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, url) => {
910
+ const safeUrl = sanitizeChatUrl(String(url).replace(/&amp;/g, "&"));
911
+ return '<a class="chat-link" href="' + attr(safeUrl) + '" target="_blank" rel="noreferrer noopener">' + label + "</a>";
912
+ });
913
+ }
914
+ function sanitizeChatUrl(url) {
915
+ const trimmed = String(url || "").trim().replace(/"/g, "%22");
916
+ return /^(https?|mailto):/i.test(trimmed) ? trimmed : "#";
917
+ }
918
+ function formatChatBlockquotes(text) {
919
+ const lines = text.split("\n");
920
+ const out = [];
921
+ let quote = [];
922
+ const flush = () => {
923
+ if (quote.length) {
924
+ out.push('<blockquote class="chat-blockquote">' + quote.join("\n") + "</blockquote>");
925
+ quote = [];
926
+ }
927
+ };
928
+ for (const line of lines) {
929
+ const match = line.match(/^&gt; ?(.*)$/);
930
+ if (match) {
931
+ quote.push(match[1]);
932
+ continue;
933
+ }
934
+ flush();
935
+ out.push(line);
936
+ }
937
+ flush();
938
+ return out.join("\n");
939
+ }
940
+ function formatChatLists(text) {
941
+ const lines = text.split("\n");
942
+ const out = [];
943
+ let list = null;
944
+ const flush = () => {
945
+ if (list) {
946
+ out.push("</" + list + ">");
947
+ list = null;
948
+ }
949
+ };
950
+ for (const line of lines) {
951
+ const task = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.+)$/);
952
+ const bullet = line.match(/^\s*[-*]\s+(.+)$/);
953
+ const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
954
+ if (task) {
955
+ if (list !== "ul") {
956
+ flush();
957
+ out.push('<ul class="chat-list chat-task-list">');
958
+ list = "ul";
959
+ }
960
+ const checked = task[1].toLowerCase() === "x" ? "[x]" : "[ ]";
961
+ out.push('<li><span class="chat-task-mark">' + checked + "</span> " + task[2] + "</li>");
962
+ continue;
963
+ }
964
+ if (bullet) {
965
+ if (list !== "ul") {
966
+ flush();
967
+ out.push('<ul class="chat-list">');
968
+ list = "ul";
969
+ }
970
+ out.push("<li>" + bullet[1] + "</li>");
971
+ continue;
972
+ }
973
+ if (ordered) {
974
+ if (list !== "ol") {
975
+ flush();
976
+ out.push('<ol class="chat-list">');
977
+ list = "ol";
978
+ }
979
+ out.push("<li>" + ordered[1] + "</li>");
980
+ continue;
981
+ }
982
+ flush();
983
+ out.push(line);
984
+ }
985
+ flush();
986
+ return out.join("\n");
987
+ }
988
+ function formatChatHeadings(text) {
989
+ return text.split("\n").map((line) => {
990
+ const match = line.match(/^(#{1,4})\s+(.+)$/);
991
+ return match ? '<strong class="chat-heading chat-heading-' + match[1].length + '">' + match[2] + "</strong>" : line;
992
+ }).join("\n");
993
+ }
994
+ function restoreChatMarkdown(text, prefix, suffix, values) {
995
+ const pattern = new RegExp(escapeChatRegExp(prefix) + "(\\d+)" + escapeChatRegExp(suffix), "g");
996
+ return text.replace(pattern, (_match, rawIndex) => values[Number.parseInt(rawIndex, 10)] ?? "");
997
+ }
998
+ function escapeChatRegExp(text) {
999
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1000
+ }
1001
+ function bindChatCopyButtons(root) {
1002
+ root.querySelectorAll?.("[data-chat-copy]").forEach((el) => {
1003
+ el.onclick = (event) => {
1004
+ event.preventDefault();
1005
+ event.stopPropagation();
1006
+ const code = el.dataset.chatCopy === "code-block" ? el.querySelector("code")?.textContent : el.textContent;
1007
+ copyText(code || "", "Copied code");
1008
+ };
1009
+ el.onkeydown = (event) => {
1010
+ if (el.tagName === "BUTTON") return;
1011
+ if (event.key === "Enter" || event.key === " ") {
1012
+ event.preventDefault();
1013
+ el.click();
1014
+ }
1015
+ };
1016
+ });
1017
+ }
718
1018
  function connectEvents() {
719
1019
  if (state.events) state.events.close();
720
1020
  const eventsUrl = state.selectedPeer && state.selectedPeer !== "local" ? "/api/peers/" + encodeURIComponent(state.selectedPeer) + "/events?contextKey=" + encodeURIComponent("web:dashboard") : "/api/events";
@@ -765,9 +1065,10 @@
765
1065
  });
766
1066
  events.addEventListener("text_delta", (e) => {
767
1067
  const d = JSON.parse(e.data);
1068
+ const stick = isChatNearBottom();
768
1069
  if (!currentAgentMessage) currentAgentMessage = appendMessage("agent", "");
769
- currentAgentMessage.textContent += d.delta;
770
- scrollChatToBottom();
1070
+ setMessageText(currentAgentMessage, (currentAgentMessage.__rawText || "") + d.delta);
1071
+ if (stick) scrollChatToBottom({ force: true });
771
1072
  if (state.currentPage === "tasks") loadTasks();
772
1073
  });
773
1074
  events.addEventListener("tool_start", (e) => {
@@ -785,7 +1086,7 @@
785
1086
  });
786
1087
  events.addEventListener("todo_update", (e) => {
787
1088
  const d = JSON.parse(e.data);
788
- tool("tool", "Plan:\\n" + d.items.map((i) => (i.completed ? "[x] " : "[ ] ") + i.text).join("\\n"));
1089
+ tool("tool", "Plan:\n" + d.items.map((i) => (i.completed ? "[x] " : "[ ] ") + i.text).join("\n"));
789
1090
  });
790
1091
  events.addEventListener("turn_error", (e) => {
791
1092
  const d = JSON.parse(e.data);
@@ -806,7 +1107,10 @@
806
1107
  toast(msg, { sticky: true });
807
1108
  return;
808
1109
  }
809
- if (isCliDoneStatus(msg)) state.cliStatusActive = false;
1110
+ if (isCliDoneStatus(msg)) {
1111
+ state.cliStatusActive = false;
1112
+ clearStickyToast();
1113
+ }
810
1114
  toast(msg);
811
1115
  });
812
1116
  events.onerror = () => {
@@ -971,6 +1275,27 @@
971
1275
  });
972
1276
  document.getElementById("promptForm").onsubmit = (e) => safe(async () => {
973
1277
  e.preventDefault();
1278
+ const input = document.getElementById("promptInput");
1279
+ const text = input.value.trim();
1280
+ if (/^\/mirror\b/i.test(text)) {
1281
+ if (selectedFiles.length) {
1282
+ toast("/mirror cannot be sent with attachments");
1283
+ return;
1284
+ }
1285
+ const argument = text.replace(/^\/mirror\b/i, "").trim();
1286
+ if (argument && !can("settings.write")) {
1287
+ toast("Permission required: settings.write");
1288
+ return;
1289
+ }
1290
+ if (!argument && !can("sessions.read")) {
1291
+ toast("Permission required: sessions.read");
1292
+ return;
1293
+ }
1294
+ input.value = "";
1295
+ const data = argument ? await setMirrorPreference(argument) : await loadMirrorPreference();
1296
+ appendMessage("system", data?.response?.plain || "CLI mirroring: " + (data?.mode || "-"));
1297
+ return;
1298
+ }
974
1299
  if (!can("prompt.send")) {
975
1300
  toast("Permission required: prompt.send");
976
1301
  return;
@@ -979,8 +1304,6 @@
979
1304
  toast("Permission required: files.write");
980
1305
  return;
981
1306
  }
982
- const input = document.getElementById("promptInput");
983
- const text = input.value.trim();
984
1307
  if (!text && selectedFiles.length === 0) return;
985
1308
  const files = selectedFiles;
986
1309
  input.value = "";
@@ -989,7 +1312,7 @@
989
1312
  renderSelectedFiles();
990
1313
  const payloadFiles = files.length ? await Promise.all(files.map(filePayload)) : [];
991
1314
  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 }) });
992
- if (r.transcribeOnly) appendMessage("system", "Transcribed audio:\\n" + (r.transcript || "(empty)"));
1315
+ if (r.transcribeOnly) appendMessage("system", "Transcribed audio:\n" + (r.transcript || "(empty)"));
993
1316
  else if (r.queued) appendQueuedMessage(r.queueId);
994
1317
  }, e);
995
1318
  document.getElementById("newSessionBtn").onclick = () => {
@@ -1024,6 +1347,14 @@
1024
1347
  loadBootstrap();
1025
1348
  });
1026
1349
  document.getElementById("notifyBtn").onclick = () => enableNotifications();
1350
+ document.getElementById("mirrorModeSelect").onchange = () => safe(async () => {
1351
+ if (!can("settings.write")) {
1352
+ toast("Permission required: settings.write");
1353
+ renderMirrorPreference(state.webMirror);
1354
+ return;
1355
+ }
1356
+ await setMirrorPreference(document.getElementById("mirrorModeSelect").value);
1357
+ });
1027
1358
  document.getElementById("clearChatBtn").onclick = () => safe(async () => {
1028
1359
  if (!can("sessions.write")) {
1029
1360
  toast("Permission required: sessions.write");
@@ -1049,7 +1380,7 @@
1049
1380
  return;
1050
1381
  }
1051
1382
  const r = await api("/api/handback", { method: "POST" });
1052
- appendMessage("system", "Handback command:\\n" + (r.command || "No command available"));
1383
+ appendMessage("system", "Handback command:\n" + (r.command || "No command available"));
1053
1384
  });
1054
1385
  document.getElementById("recordBtn").onclick = () => safe(async () => {
1055
1386
  if (!can("files.write")) {
@@ -1077,6 +1408,24 @@
1077
1408
  state.mediaRecorder.start();
1078
1409
  btn.textContent = "Stop recording";
1079
1410
  });
1411
+ function renderMirrorPreference(data) {
1412
+ if (!data) return;
1413
+ state.webMirror = data;
1414
+ const select = document.getElementById("mirrorModeSelect");
1415
+ if (select && data.mode) select.value = data.mode;
1416
+ }
1417
+ async function loadMirrorPreference() {
1418
+ if (!can("sessions.read")) return null;
1419
+ const data = await api("/api/chat/mirror");
1420
+ renderMirrorPreference(data);
1421
+ return data;
1422
+ }
1423
+ async function setMirrorPreference(argument) {
1424
+ const data = await api("/api/chat/mirror", { method: "POST", body: JSON.stringify({ argument }) });
1425
+ renderMirrorPreference(data);
1426
+ toast("Mirror " + data.mode);
1427
+ return data;
1428
+ }
1080
1429
  function renderNewSessionControls(c) {
1081
1430
  const s = state.snapshot?.session || {};
1082
1431
  const caps = c.capabilities || {};
@@ -1120,7 +1469,7 @@
1120
1469
  document.getElementById("newSessionDialog").close();
1121
1470
  toast("New session started");
1122
1471
  await loadBootstrap();
1123
- await loadChatHistory();
1472
+ await loadChatHistory({ forceScroll: true });
1124
1473
  }, e);
1125
1474
  document.getElementById("cancelSessionBtn").onclick = () => document.getElementById("newSessionDialog").close();
1126
1475
  function val(id) {
@@ -1488,10 +1837,16 @@
1488
1837
  ["Buckets", (rate?.buckets || []).length]
1489
1838
  ].map(([k, v]) => [name + " " + k, v]);
1490
1839
  }
1840
+ function webRouteRows(d) {
1841
+ return (d.web?.routes || []).slice(0, 8).map((route) => [route.method + " " + route.path, route.averageMs + "ms avg / " + route.maxMs + "ms max / " + route.count + " hit(s)"]);
1842
+ }
1843
+ function webSlowRows(d) {
1844
+ return (d.web?.slowest || []).slice(0, 8).map((sample) => [sample.method + " " + sample.path, sample.durationMs + "ms / " + sample.statusCode + " / " + fmtDate(sample.at)]);
1845
+ }
1491
1846
  function renderMetrics(d) {
1492
1847
  const adapters = d.adapters || {};
1493
1848
  const adapterCards = Object.entries(adapters).map(([name, rate]) => card(name.charAt(0).toUpperCase() + name.slice(1) + " rate limits", rateRows("", rate).map(([k, v]) => [String(k).trim(), v]))).join("");
1494
- document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + adapterCards + "</div>";
1849
+ document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + card("Web API latency", webRouteRows(d)) + card("Slow Web API calls", webSlowRows(d)) + adapterCards + "</div>";
1495
1850
  }
1496
1851
  document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
1497
1852
  function activityQuery() {
@@ -2151,14 +2506,17 @@
2151
2506
  return;
2152
2507
  }
2153
2508
  setLoading("peersList", "Loading peers...");
2154
- const d = await api("/api/peers", { local: true });
2509
+ const [d, jobsData] = await Promise.all([api("/api/peers", { local: true }), can("peers.connect") ? api("/api/peers/discovery-jobs", { local: true }).catch(() => ({ jobs: [] })) : Promise.resolve({ jobs: [] })]);
2155
2510
  state.peers = d;
2511
+ state.peerDiscoveryJobs = jobsData.jobs || [];
2156
2512
  const inviteIds = new Set((d.invitations || []).map((i) => i.id));
2157
2513
  Object.keys(state.peerInviteSecrets || {}).forEach((id) => {
2158
2514
  if (!inviteIds.has(id)) delete state.peerInviteSecrets[id];
2159
2515
  });
2160
2516
  document.getElementById("peerStatus").innerHTML = peerStatusHtml(d);
2161
2517
  document.getElementById("peersList").innerHTML = (d.peers || []).map(peerCard).join("") || '<div class="item">No peers configured.</div>';
2518
+ document.getElementById("peerDiscovery").innerHTML = peerDiscoveryJobsHtml(state.peerDiscoveryJobs);
2519
+ bindUiCopyButtons(document.getElementById("peerDiscovery"));
2162
2520
  document.getElementById("peerInvites").innerHTML = (d.invitations || []).map(peerInviteCard).join("") || '<div class="item">No open invitations.</div>';
2163
2521
  ensureGlobalPeerSessionsPanel();
2164
2522
  bindPeerButtons();
@@ -2166,8 +2524,10 @@
2166
2524
  applyPermissions();
2167
2525
  }
2168
2526
  function openPeerAddDialog() {
2169
- adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional"></label>', async () => {
2527
+ const publicUrl = state.peers?.enabled ? state.peers?.listenUrl || "" : "";
2528
+ adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Group<input id="dlgPeerAddGroup" placeholder="LAN, Servers, Workstations"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional" value="' + attr(publicUrl) + '"></label>', async () => {
2170
2529
  const r = await api("/api/peers/pair", { method: "POST", body: JSON.stringify({ url: val("dlgPeerAddUrl"), code: val("dlgPeerAddCode"), name: val("dlgPeerAddName") || void 0, publicUrl: val("dlgPeerAddPublicUrl") || void 0 }), local: true });
2530
+ if (val("dlgPeerAddGroup") && r.peer?.id) await api("/api/peers/" + encodeURIComponent(r.peer.id), { method: "PATCH", body: JSON.stringify({ group: val("dlgPeerAddGroup") }), local: true });
2171
2531
  toast("Added peer " + (r.peer?.name || ""));
2172
2532
  await loadPeers();
2173
2533
  });
@@ -2187,6 +2547,10 @@
2187
2547
  }
2188
2548
  openPeerAddDialog();
2189
2549
  };
2550
+ document.getElementById("discoverPeersBtn").onclick = () => safe(discoverPeers);
2551
+ document.getElementById("cancelPeerDiscoveryBtn").onclick = () => safe(cancelPeerDiscovery);
2552
+ document.getElementById("exportPeerIdentityBtn").onclick = () => safe(exportPeerIdentity);
2553
+ document.getElementById("restorePeerIdentityBtn").onclick = () => safe(restorePeerIdentity);
2190
2554
  async function loadAdapterHealth() {
2191
2555
  setLoading("adapterHealth", "Loading adapters...");
2192
2556
  setLoading("adapterConformance", "Loading conformance...");
@@ -2349,6 +2713,21 @@
2349
2713
  }));
2350
2714
  }
2351
2715
  document.getElementById("loadVersionBtn").onclick = () => loadVersion();
2716
+ document.addEventListener("click", (e) => {
2717
+ const b = e.target.closest?.("[data-peer-repin]");
2718
+ if (!b) return;
2719
+ safe(async () => {
2720
+ if (!can("peers.write")) {
2721
+ toast("Permission required: peers.write");
2722
+ return;
2723
+ }
2724
+ if (confirm("Re-pin TLS fingerprint for this peer?")) {
2725
+ const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerRepin) + "/repin", { method: "POST", local: true });
2726
+ toast("TLS fingerprint updated: " + (r.peer?.tlsFingerprint || "-"), { duration: 8e3 });
2727
+ loadPeers();
2728
+ }
2729
+ });
2730
+ });
2352
2731
  document.getElementById("updateBtn").onclick = () => safe(async () => {
2353
2732
  if (!can("updates.run")) {
2354
2733
  toast("Permission required: updates.run");
@@ -2428,6 +2807,92 @@
2428
2807
  const command = r.manualCheckCommand || "nordrelay peer check " + (d.listenUrl || "");
2429
2808
  return '<div class="item"><strong>Local peer identity <span class="adapter-status ' + (r.enabled && r.localListening ? "enabled" : r.enabled ? "planned" : "disabled") + '">' + esc(r.enabled ? r.localListening ? "ready" : "not listening" : "disabled") + "</span></strong>" + rows.map((row) => "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>").join("") + peerWarningsHtml(r.warnings || [], "Peer readiness") + '<div class="peer-invite-details"><small>Manual reachability check</small><button type="button" class="copy-id peer-invite-command" data-peer-invite-copy="' + attr(command) + '" data-peer-invite-copy-label="Peer check command copied">' + esc(command) + '</button><small>Run this on another machine to verify LAN, port-forward, or firewall reachability.</small></div><div class="row"><button id="checkPeerReachabilityBtn" class="secondary"' + disabledAttr("peers.connect") + '>Check local endpoint</button></div><div id="peerProbeResult">' + peerProbeResultHtml(state.peerProbeResult) + "</div></div>";
2430
2809
  }
2810
+ async function discoverPeers() {
2811
+ if (!can("peers.connect")) {
2812
+ toast("Permission required: peers.connect");
2813
+ return;
2814
+ }
2815
+ const target = document.getElementById("peerDiscovery");
2816
+ target.innerHTML = loadingHtml("Starting LAN peer discovery...");
2817
+ const body = { targets: csvToList(val("peerDiscoveryTargets")), maxHosts: Number(val("peerDiscoveryMaxHosts") || 512), concurrency: Number(val("peerDiscoveryConcurrency") || 32) };
2818
+ const data = await api("/api/peers/discovery-jobs", { method: "POST", body: JSON.stringify(body), local: true });
2819
+ state.activePeerDiscoveryJobId = data.job?.id;
2820
+ await pollPeerDiscoveryJob(data.job?.id);
2821
+ }
2822
+ async function pollPeerDiscoveryJob(id) {
2823
+ if (!id) return;
2824
+ const target = document.getElementById("peerDiscovery");
2825
+ for (; ; ) {
2826
+ const data = await api("/api/peers/discovery-jobs/" + encodeURIComponent(id), { local: true });
2827
+ const job = data.job;
2828
+ if (!job) {
2829
+ target.innerHTML = uiEmpty("Discovery job not found.");
2830
+ return;
2831
+ }
2832
+ target.innerHTML = peerDiscoveryJobHtml(job);
2833
+ bindUiCopyButtons(target);
2834
+ applyPermissions();
2835
+ if (!["queued", "running"].includes(job.status)) break;
2836
+ await new Promise((r) => setTimeout(r, 1e3));
2837
+ }
2838
+ }
2839
+ async function cancelPeerDiscovery() {
2840
+ const id = state.activePeerDiscoveryJobId;
2841
+ if (!id) {
2842
+ toast("No active discovery job");
2843
+ return;
2844
+ }
2845
+ const data = await api("/api/peers/discovery-jobs/" + encodeURIComponent(id) + "/cancel", { method: "POST", local: true });
2846
+ if (data.job) {
2847
+ document.getElementById("peerDiscovery").innerHTML = peerDiscoveryJobHtml(data.job);
2848
+ }
2849
+ toast("Discovery cancelled");
2850
+ }
2851
+ async function exportPeerIdentity() {
2852
+ if (!can("peers.write")) {
2853
+ toast("Permission required: peers.write");
2854
+ return;
2855
+ }
2856
+ const data = await api("/api/peers/identity/backup", { local: true });
2857
+ downloadJson("nordrelay-peer-identity-backup.json", data.backup);
2858
+ toast("Peer identity backup exported");
2859
+ }
2860
+ async function restorePeerIdentity() {
2861
+ if (!can("peers.write")) {
2862
+ toast("Permission required: peers.write");
2863
+ return;
2864
+ }
2865
+ const text = prompt("Paste peer identity backup JSON");
2866
+ if (!text) return;
2867
+ const backup = JSON.parse(text);
2868
+ await api("/api/peers/identity/restore", { method: "POST", body: JSON.stringify({ backup }), local: true });
2869
+ toast("Peer identity restored. Restart peer server to use it.");
2870
+ await loadPeers();
2871
+ }
2872
+ function downloadJson(name, value) {
2873
+ const blob = new Blob([JSON.stringify(value, null, 2) + "\n"], { type: "application/json" });
2874
+ const a = document.createElement("a");
2875
+ a.href = URL.createObjectURL(blob);
2876
+ a.download = name;
2877
+ a.click();
2878
+ URL.revokeObjectURL(a.href);
2879
+ }
2880
+ function peerDiscoveryJobsHtml(jobs) {
2881
+ const list = (jobs || []).slice(0, 5);
2882
+ return list.map(peerDiscoveryJobHtml).join("") || uiEmpty("No LAN discovery jobs yet.");
2883
+ }
2884
+ function peerDiscoveryJobHtml(job) {
2885
+ const progress = job.total ? Math.round(job.scanned / job.total * 100) : 0;
2886
+ return uiItem("Discovery job " + job.id, { badge: { text: job.status, status: job.status === "completed" ? "enabled" : job.status === "failed" ? "disabled" : "planned" }, rows: [["Progress", job.scanned + " / " + job.total + " (" + progress + "%)"], ["Targets", (job.options?.targets || []).join(", ") || "local LAN + mDNS"], ["Started", fmtDate(job.startedAt)], ["Completed", fmtDate(job.completedAt)]], body: peerDiscoveryHtml(job) + '<details class="peer-health-history"><summary>Discovery log (' + (job.log || []).length + ")</summary>" + (job.log || []).slice(-30).map((line) => "<small>" + esc(line) + "</small>").join("") + "</details>" });
2887
+ }
2888
+ function peerDiscoveryHtml(data) {
2889
+ const warnings = (data.warnings || []).length ? peerWarningsHtml(data.warnings, "Discovery warning") : "";
2890
+ const cards = (data.candidates || []).map((c) => {
2891
+ const command = "nordrelay peer add " + c.url + " --code <pairing-code>";
2892
+ return uiItem(c.name || c.host, { badge: { text: "found", status: "enabled" }, rows: [["URL", c.url], ["Node", c.nodeId], ["Fingerprint", c.fingerprint], ["TLS", c.tlsFingerprint || "-"], ["Latency", c.latencyMs !== void 0 ? c.latencyMs + "ms" : "-"]], body: '<div class="peer-invite-details"><small>Pairing command template</small>' + uiCopyButton(command, "Peer add command copied", "copy-id peer-invite-command") + "</div>" });
2893
+ }).join("");
2894
+ return warnings + (cards || uiEmpty("No LAN peers found. Scanned " + (data.scanned || 0) + " endpoint candidates."));
2895
+ }
2431
2896
  function peerWarningsHtml(warnings, title) {
2432
2897
  return (warnings || []).length ? '<div class="peer-warning full-span"><strong>' + esc(title || "Warning") + "</strong>" + warnings.map((w) => "<small>" + esc(w) + "</small>").join("") + "</div>" : "";
2433
2898
  }
@@ -2445,18 +2910,20 @@
2445
2910
  function peerInviteCard(i) {
2446
2911
  const open = new Date(i.expiresAt) > /* @__PURE__ */ new Date() && !i.usedAt;
2447
2912
  const readiness = state.peers?.readiness;
2448
- return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "open" : "closed") + "</span></strong><small>" + esc("Expires: " + fmtDate(i.expiresAt)) + "</small><small>" + esc("Scopes: " + (i.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((i.allowedAgents || []).join(", ") || "all")) + "</small>" + (i.usedAt ? "<small>" + esc("Used: " + fmtDate(i.usedAt) + " by " + (i.usedByNodeId || "-")) + "</small>" : "") + (open ? peerWarningsHtml(readiness?.warnings || [], "Pairing warning") + peerInviteDetails(i) + '<div class="row"><button class="danger" data-peer-invite-delete="' + attr(i.id) + '"' + disabledAttr("peers.write") + ">Delete invite</button></div>" : "") + "</div>";
2913
+ return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "open" : "closed") + "</span></strong>" + (i.group ? "<small>" + esc("Group: " + i.group) + "</small>" : "") + "<small>" + esc("Expires: " + fmtDate(i.expiresAt)) + "</small><small>" + esc("Scopes: " + (i.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((i.allowedAgents || []).join(", ") || "all")) + "</small>" + (i.usedAt ? "<small>" + esc("Used: " + fmtDate(i.usedAt) + " by " + (i.usedByNodeId || "-")) + "</small>" : "") + (open ? peerWarningsHtml(readiness?.warnings || [], "Pairing warning") + peerInviteDetails(i) + '<div class="row"><button class="danger" data-peer-invite-delete="' + attr(i.id) + '"' + disabledAttr("peers.write") + ">Delete invite</button></div>" : "") + "</div>";
2449
2914
  }
2450
2915
  function peerCard(p) {
2451
2916
  const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
2452
2917
  const health = p.remoteStatus || p.lastSeenAt ? "Health: " + (p.remoteStatus || "seen") + (p.lastLatencyMs !== void 0 ? " / " + p.lastLatencyMs + "ms" : "") + (p.remoteVersion ? " / v" + p.remoteVersion : "") : "Health: unchecked";
2453
2918
  const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2454
- return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong><small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (aliases ? "<small>" + esc("Aliases: " + aliases) + "</small>" : "") + "<small>" + esc(health) + "</small>" + (p.lastCheckedAt ? "<small>" + esc("Checked: " + fmtDate(p.lastCheckedAt)) + "</small>" : "") + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
2919
+ const effective = "Effective access: " + (p.scopes || []).length + " scope(s), agents " + ((p.allowedAgents || []).join(", ") || "all") + ", workspaces " + ((p.allowedWorkspaceRoots || []).join(", ") || "all");
2920
+ const history = (p.healthHistory || []).slice(-5).reverse().map((h) => "<small>" + esc(fmtDate(h.checkedAt) + " | " + h.status + (h.latencyMs !== void 0 ? " | " + h.latencyMs + "ms" : "") + (h.error ? " | " + h.error : "")) + "</small>").join("");
2921
+ return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong>" + (p.group ? "<small>" + esc("Group: " + p.group) + "</small>" : "") + "<small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (aliases ? "<small>" + esc("Aliases: " + aliases) + "</small>" : "") + "<small>" + esc(effective) + "</small><small>" + esc(health) + "</small>" + (p.lastCheckedAt ? "<small>" + esc("Checked: " + fmtDate(p.lastCheckedAt)) + "</small>" : "") + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + (history ? '<details class="peer-health-history"><summary>Health history (' + (p.healthHistory || []).length + ")</summary>" + history + "</details>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</button><button class="secondary" data-peer-repin="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Re-pin TLS</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
2455
2922
  }
2456
2923
  function openPeerDialog(p) {
2457
2924
  const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2458
- adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
2459
- await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
2925
+ adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>Group<input id="dlgPeerGroup" value="' + attr(p.group || "") + '" placeholder="LAN, Servers, Workstations"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
2926
+ await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), group: val("dlgPeerGroup"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
2460
2927
  toast("Peer updated");
2461
2928
  await loadPeers();
2462
2929
  });
@@ -2464,8 +2931,8 @@
2464
2931
  function openPeerInviteDialog() {
2465
2932
  const warnings = state.peers?.readiness?.warnings || [];
2466
2933
  const warningHtml = peerWarningsHtml(warnings, "Pairing warning") + (warnings.length ? '<small class="full-span">The invite can still be created, but pairing may fail until the peer endpoint is reachable.</small>' : "");
2467
- adminDialog("Create peer invite", warningHtml + '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
2468
- const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
2934
+ adminDialog("Create peer invite", warningHtml + '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Group<input id="dlgPeerInviteGroup" placeholder="LAN, Servers, Workstations"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
2935
+ const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), group: val("dlgPeerInviteGroup") || void 0, expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
2469
2936
  if (r.invitation?.id) state.peerInviteSecrets[r.invitation.id] = { code: r.code || "", command: r.command || "" };
2470
2937
  toast("Peer invite created. Pairing details are shown under Open invitations.", { duration: 8e3 });
2471
2938
  await loadPeers();