@nordbyte/nordrelay 0.5.1 → 0.6.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 (57) hide show
  1. package/.env.example +65 -11
  2. package/README.md +97 -23
  3. package/dist/access-control.js +1 -0
  4. package/dist/activity-events.js +44 -0
  5. package/dist/agent-updates.js +18 -2
  6. package/dist/audit-log.js +40 -2
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +492 -7
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +34 -7
  11. package/dist/channel-command-service.js +156 -0
  12. package/dist/channel-turn-service.js +237 -0
  13. package/dist/codex-cli.js +1 -1
  14. package/dist/config-metadata.js +80 -13
  15. package/dist/config.js +77 -7
  16. package/dist/context-key.js +77 -5
  17. package/dist/discord-artifacts.js +165 -0
  18. package/dist/discord-bot.js +2014 -0
  19. package/dist/discord-channel-runtime.js +133 -0
  20. package/dist/discord-command-surface.js +119 -0
  21. package/dist/discord-rate-limit.js +141 -0
  22. package/dist/index.js +16 -5
  23. package/dist/job-store.js +127 -0
  24. package/dist/metrics.js +41 -0
  25. package/dist/operations.js +176 -119
  26. package/dist/relay-external-activity-monitor.js +47 -6
  27. package/dist/relay-runtime.js +1003 -268
  28. package/dist/runtime-cache.js +57 -0
  29. package/dist/session-locks.js +10 -7
  30. package/dist/state-backend.js +3 -0
  31. package/dist/support-bundle.js +18 -1
  32. package/dist/telegram-access-commands.js +15 -2
  33. package/dist/telegram-access-middleware.js +16 -3
  34. package/dist/telegram-agent-commands.js +25 -0
  35. package/dist/telegram-artifact-commands.js +46 -0
  36. package/dist/telegram-diagnostics-command.js +5 -50
  37. package/dist/telegram-general-commands.js +2 -6
  38. package/dist/telegram-operational-commands.js +14 -6
  39. package/dist/telegram-queue-commands.js +74 -4
  40. package/dist/telegram-support-command.js +7 -0
  41. package/dist/telegram-update-commands.js +27 -0
  42. package/dist/user-management.js +208 -0
  43. package/dist/web-api-contract.js +9 -0
  44. package/dist/web-dashboard-access-routes.js +74 -1
  45. package/dist/web-dashboard-artifact-routes.js +3 -3
  46. package/dist/web-dashboard-assets.js +2 -0
  47. package/dist/web-dashboard-pages.js +97 -13
  48. package/dist/web-dashboard-runtime-routes.js +53 -8
  49. package/dist/web-dashboard-session-routes.js +27 -20
  50. package/dist/web-dashboard-ui.js +1 -0
  51. package/dist/web-dashboard.js +149 -6
  52. package/dist/web-state.js +33 -2
  53. package/dist/webui-assets/dashboard.css +75 -1
  54. package/dist/webui-assets/dashboard.js +358 -47
  55. package/package.json +3 -1
  56. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  57. package/plugins/nordrelay/scripts/nordrelay.mjs +468 -22
@@ -20,6 +20,7 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
20
20
  groupIds: arrayStringField(body, "groupIds"),
21
21
  active: optionalBooleanField(body, "active") ?? true,
22
22
  telegramUserId: optionalNumberField(body, "telegramUserId"),
23
+ discordUserId: optionalStringField(body, "discordUserId"),
23
24
  });
24
25
  options.auditUserAction(authUser, "user_created", user.user.email);
25
26
  sendJson(res, 201, { user: publicUser(user.user), groups: user.groups });
@@ -93,6 +94,33 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
93
94
  sendJson(res, 200, { removed });
94
95
  return true;
95
96
  }
97
+ const discordLinkMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/discord$/);
98
+ if (discordLinkMatch?.[1] && req.method === "POST") {
99
+ const body = await readJsonBody(req);
100
+ if (body.createCode === true) {
101
+ const userId = decodeURIComponent(discordLinkMatch[1]);
102
+ const linkCode = users.createDiscordLinkCode(userId);
103
+ options.auditUserAction(authUser, "discord_link_created", userId);
104
+ sendJson(res, 201, { linkCode });
105
+ return true;
106
+ }
107
+ const identity = users.linkDiscordUser(decodeURIComponent(discordLinkMatch[1]), {
108
+ discordUserId: stringField(body, "discordUserId"),
109
+ username: optionalStringField(body, "username"),
110
+ globalName: optionalStringField(body, "globalName"),
111
+ });
112
+ options.auditUserAction(authUser, "discord_linked", identity.discordUserId);
113
+ sendJson(res, 201, { identity });
114
+ return true;
115
+ }
116
+ const discordUnlinkMatch = url.pathname.match(/^\/api\/users\/[^/]+\/discord\/([^/]+)$/);
117
+ if (discordUnlinkMatch?.[1] && req.method === "DELETE") {
118
+ const identityId = decodeURIComponent(discordUnlinkMatch[1]);
119
+ const removed = users.unlinkDiscordIdentity(identityId);
120
+ options.auditUserAction(authUser, "discord_unlinked", identityId);
121
+ sendJson(res, 200, { removed });
122
+ return true;
123
+ }
96
124
  if (req.method === "GET" && url.pathname === "/api/groups") {
97
125
  sendJson(res, 200, { groups: users.listGroups() });
98
126
  return true;
@@ -106,6 +134,7 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
106
134
  agentIds: arrayStringField(body, "agentIds"),
107
135
  workspaceRoots: arrayStringField(body, "workspaceRoots"),
108
136
  telegramChatIds: arrayNumberField(body, "telegramChatIds"),
137
+ discordChannelIds: arrayStringField(body, "discordChannelIds"),
109
138
  });
110
139
  options.auditUserAction(authUser, "group_created", group.id);
111
140
  sendJson(res, 201, { group });
@@ -121,6 +150,7 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
121
150
  agentIds: body.agentIds === undefined ? undefined : arrayStringField(body, "agentIds"),
122
151
  workspaceRoots: body.workspaceRoots === undefined ? undefined : arrayStringField(body, "workspaceRoots"),
123
152
  telegramChatIds: body.telegramChatIds === undefined ? undefined : arrayNumberField(body, "telegramChatIds"),
153
+ discordChannelIds: body.discordChannelIds === undefined ? undefined : arrayStringField(body, "discordChannelIds"),
124
154
  });
125
155
  options.auditUserAction(authUser, "group_updated", group.id);
126
156
  sendJson(res, 200, { group });
@@ -155,8 +185,51 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
155
185
  sendJson(res, 200, { chat });
156
186
  return true;
157
187
  }
188
+ if (req.method === "GET" && url.pathname === "/api/discord-channels") {
189
+ sendJson(res, 200, { channels: users.snapshot().discordChannels });
190
+ return true;
191
+ }
192
+ if (req.method === "POST" && url.pathname === "/api/discord-channels") {
193
+ const body = await readJsonBody(req);
194
+ const channel = users.registerDiscordChannel({
195
+ guildId: optionalStringField(body, "guildId"),
196
+ channelId: stringField(body, "channelId"),
197
+ title: optionalStringField(body, "title"),
198
+ type: optionalStringField(body, "type"),
199
+ enabled: optionalBooleanField(body, "enabled") ?? true,
200
+ allowedGroupIds: arrayStringField(body, "allowedGroupIds"),
201
+ });
202
+ options.auditUserAction(authUser, "discord_channel_updated", channel.channelId);
203
+ sendJson(res, 201, { channel });
204
+ return true;
205
+ }
206
+ const discordChannelMatch = url.pathname.match(/^\/api\/discord-channels\/([^/]+)$/);
207
+ if (discordChannelMatch?.[1] && req.method === "PATCH") {
208
+ const body = await readJsonBody(req);
209
+ const channel = users.updateDiscordChannel(decodeURIComponent(discordChannelMatch[1]), {
210
+ enabled: optionalBooleanField(body, "enabled"),
211
+ title: optionalStringField(body, "title"),
212
+ allowedGroupIds: body.allowedGroupIds === undefined ? undefined : arrayStringField(body, "allowedGroupIds"),
213
+ });
214
+ options.auditUserAction(authUser, "discord_channel_updated", channel.channelId);
215
+ sendJson(res, 200, { channel });
216
+ return true;
217
+ }
158
218
  if (req.method === "GET" && url.pathname === "/api/audit") {
159
- sendJson(res, 200, { events: runtime.audit(numberParam(url, "limit", 50)) });
219
+ sendJson(res, 200, {
220
+ events: runtime.audit({
221
+ limit: numberParam(url, "limit", 50),
222
+ channelId: (url.searchParams.get("channel") || "all"),
223
+ category: (url.searchParams.get("category") || "all"),
224
+ status: (url.searchParams.get("status") || "all"),
225
+ action: url.searchParams.get("action") || "all",
226
+ actor: url.searchParams.get("actor") || undefined,
227
+ agentId: url.searchParams.get("agent") || "all",
228
+ threadId: url.searchParams.get("thread") || undefined,
229
+ workspace: url.searchParams.get("workspace") || undefined,
230
+ since: url.searchParams.get("since") || undefined,
231
+ }),
232
+ });
160
233
  return true;
161
234
  }
162
235
  return false;
@@ -8,7 +8,7 @@ export async function handleDashboardArtifactRoute(req, res, url, options) {
8
8
  }
9
9
  if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
10
10
  await options.assertCurrentSessionScope(authUser);
11
- sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
11
+ sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId"), options.activityActor) });
12
12
  return true;
13
13
  }
14
14
  if (req.method === "POST" && url.pathname === "/api/artifacts/bulk") {
@@ -21,7 +21,7 @@ export async function handleDashboardArtifactRoute(req, res, url, options) {
21
21
  }
22
22
  const removed = [];
23
23
  for (const turnId of turnIds) {
24
- if (await runtime.deleteArtifact(turnId)) {
24
+ if (await runtime.deleteArtifact(turnId, options.activityActor)) {
25
25
  removed.push(turnId);
26
26
  }
27
27
  }
@@ -30,7 +30,7 @@ export async function handleDashboardArtifactRoute(req, res, url, options) {
30
30
  }
31
31
  if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
32
32
  await options.assertCurrentSessionScope(authUser);
33
- const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
33
+ const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"), options.activityActor);
34
34
  if (!bundle) {
35
35
  sendJson(res, 404, { error: "Artifact turn not found or ZIP could not be created" });
36
36
  return true;
@@ -9,6 +9,8 @@ const clientSources = [
9
9
  "client/overview.js",
10
10
  "client/events.js",
11
11
  "client/workflows.js",
12
+ "client/jobs.js",
13
+ "client/metrics.js",
12
14
  "client/admin.js",
13
15
  ];
14
16
  const styleSources = [
@@ -44,6 +44,58 @@ export function renderLoginPage(options) {
44
44
  </body>
45
45
  </html>`;
46
46
  }
47
+ export function renderFirstRunSetupPage(options) {
48
+ return `<!doctype html>
49
+ <html lang="en">
50
+ <head>
51
+ <meta charset="utf-8">
52
+ <meta name="viewport" content="width=device-width, initial-scale=1">
53
+ <title>NordRelay First Run</title>
54
+ <style>
55
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f5f2;color:#181c19;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}
56
+ form{width:min(460px,calc(100vw - 32px));background:white;border:1px solid #dfe3dc;border-radius:8px;padding:24px;box-shadow:0 20px 60px rgba(20,30,24,.08)}
57
+ h1{font-size:24px;margin:0 0 8px}
58
+ p{color:#5d665d;margin:0 0 18px;line-height:1.45}
59
+ label{display:block;font-size:13px;color:#4b544d;margin:14px 0 6px}
60
+ input{box-sizing:border-box;width:100%;height:40px;border:1px solid #cfd6ce;border-radius:6px;padding:0 10px;font:inherit}
61
+ button{margin-top:18px;width:100%;height:42px;border:0;border-radius:6px;background:#205c43;color:white;font-weight:650;cursor:pointer}
62
+ .error{color:#9b1c1c;min-height:22px;margin-top:12px}
63
+ small{display:block;color:#667267;margin-top:8px;line-height:1.4}
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <form id="setup">
68
+ <h1>NordRelay Setup</h1>
69
+ <p>Create the first admin account. After this, every dashboard page and API route requires login.</p>
70
+ <label>Email</label><input id="email" name="email" type="email" autocomplete="username" required>
71
+ <label>Name</label><input id="displayName" name="displayName" autocomplete="name" required>
72
+ <label>Password</label><input id="password" name="password" type="password" autocomplete="new-password" minlength="12" required>
73
+ <label>Setup token</label><input id="setupToken" name="setupToken" autocomplete="one-time-code" ${options.tokenRequired ? "required" : ""}>
74
+ <small>${options.tokenRequired ? "Use the token printed in the NordRelay console." : "Local setup does not require the token, but the console token also works."}</small>
75
+ <button>Create admin</button>
76
+ <div class="error" id="error"></div>
77
+ </form>
78
+ <script>
79
+ document.getElementById('setup').addEventListener('submit', async (event) => {
80
+ event.preventDefault();
81
+ const payload = {
82
+ email: document.getElementById('email')?.value || undefined,
83
+ displayName: document.getElementById('displayName')?.value || undefined,
84
+ password: document.getElementById('password')?.value || undefined,
85
+ setupToken: document.getElementById('setupToken')?.value || undefined,
86
+ };
87
+ const res = await fetch('/api/setup/admin', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });
88
+ if (!res.ok) {
89
+ const data = await res.json().catch(() => ({}));
90
+ document.getElementById('error').textContent = data.error || 'Setup failed';
91
+ return;
92
+ }
93
+ location.href = '/';
94
+ });
95
+ </script>
96
+ </body>
97
+ </html>`;
98
+ }
47
99
  export function renderDashboardApp() {
48
100
  return `<!doctype html>
49
101
  <html lang="en">
@@ -81,7 +133,7 @@ export function renderDashboardApp() {
81
133
  <section class="page active" id="page-overview">
82
134
  <div class="metrics" id="metrics"></div>
83
135
  <div class="stack">
84
- <div class="panel"><h2>Current Session</h2><pre id="sessionText"></pre></div>
136
+ <div class="panel"><h2>Active Sessions</h2><div id="activeSessions" class="list"></div></div>
85
137
  <div class="overview-adapter-grid">
86
138
  <div class="panel"><h2>Agent Adapters</h2><div id="agentAdapters"></div></div>
87
139
  <div class="panel"><h2>Chat Adapters</h2><div id="chatAdapters"></div></div>
@@ -129,6 +181,13 @@ export function renderDashboardApp() {
129
181
  </div>
130
182
  </section>
131
183
 
184
+ <section class="page" id="page-metrics">
185
+ <div class="panel">
186
+ <div class="row"><button id="reloadMetricsBtn">Reload metrics</button></div>
187
+ <div id="metricsPanel" class="list"></div>
188
+ </div>
189
+ </section>
190
+
132
191
  <section class="page" id="page-sessions">
133
192
  <div class="panel">
134
193
  <div class="sessions-toolbar">
@@ -149,7 +208,7 @@ export function renderDashboardApp() {
149
208
 
150
209
  <section class="page" id="page-activity">
151
210
  <div class="panel">
152
- <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>
211
+ <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="cli">CLI</option></select><select id="activityCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</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="activityActor" placeholder="Actor"><input id="activityAgent" placeholder="Agent"><input id="activityThread" placeholder="Thread ID"><input id="activityWorkspace" placeholder="Workspace"><input id="activityType" placeholder="Type"><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>
153
212
  <div id="activityList" class="list"></div>
154
213
  </div>
155
214
  </section>
@@ -171,17 +230,42 @@ export function renderDashboardApp() {
171
230
 
172
231
  <section class="page" id="page-access">
173
232
  <div class="panel">
174
- <div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
175
- <div id="accessPanel" class="settings-grid"></div>
176
- <h2>Groups</h2>
177
- <div id="groupsList" class="list"></div>
178
- <h2>Telegram chats</h2>
179
- <div id="telegramChatsList" class="list"></div>
180
- <h2>Locks</h2>
181
- <div id="locksList" class="list"></div>
182
- <h2>Audit</h2>
183
- <div class="row"><input id="auditLimit" type="number" value="50" min="1" max="200"><button id="loadAuditBtn">Load audit</button></div>
184
- <div id="auditList" class="list"></div>
233
+ <div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="createDiscordChannelBtn" class="secondary">Add Discord channel</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
234
+ <div id="accessTabs" class="tabs access-tabs">
235
+ <button type="button" data-access-tab="users" class="active">Users</button>
236
+ <button type="button" data-access-tab="groups">Groups</button>
237
+ <button type="button" data-access-tab="telegram">Telegram</button>
238
+ <button type="button" data-access-tab="discord">Discord</button>
239
+ <button type="button" data-access-tab="locks">Locks</button>
240
+ <button type="button" data-access-tab="audit">Audit</button>
241
+ </div>
242
+ <div class="access-tab active" data-access-tab-panel="users">
243
+ <div id="accessPanel" class="settings-grid"></div>
244
+ </div>
245
+ <div class="access-tab" data-access-tab-panel="groups">
246
+ <h2>Groups</h2>
247
+ <div id="groupsList" class="list"></div>
248
+ </div>
249
+ <div class="access-tab" data-access-tab-panel="telegram">
250
+ <h2>Telegram chats</h2>
251
+ <div id="telegramChatsList" class="list"></div>
252
+ </div>
253
+ <div class="access-tab" data-access-tab-panel="discord">
254
+ <div class="access-tab-heading">
255
+ <h2>Discord channels</h2>
256
+ <input id="discordChannelSearch" placeholder="Search Discord channels">
257
+ </div>
258
+ <div id="discordChannelsList" class="list"></div>
259
+ </div>
260
+ <div class="access-tab" data-access-tab-panel="locks">
261
+ <h2>Locks</h2>
262
+ <div id="locksList" class="list"></div>
263
+ </div>
264
+ <div class="access-tab" data-access-tab-panel="audit">
265
+ <h2>Audit</h2>
266
+ <div class="row"><select id="auditChannel"><option value="all">All channels</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option></select><select id="auditCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="auditStatus"><option value="all">All statuses</option><option value="ok">OK</option><option value="failed">Failed</option><option value="denied">Denied</option></select><input id="auditActor" placeholder="Actor"><input id="auditAgent" placeholder="Agent"><input id="auditThread" placeholder="Thread ID"><input id="auditWorkspace" placeholder="Workspace"><input id="auditSince" type="datetime-local"><input id="auditLimit" type="number" value="50" min="1" max="500"><button id="loadAuditBtn">Load audit</button><button id="exportAuditBtn" class="secondary">Export</button></div>
267
+ <div id="auditList" class="list"></div>
268
+ </div>
185
269
  </div>
186
270
  </section>
187
271
 
@@ -11,7 +11,7 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
11
11
  return true;
12
12
  }
13
13
  if (req.method === "POST" && url.pathname === "/api/update") {
14
- sendJson(res, 202, runtime.updateConnector());
14
+ sendJson(res, 202, runtime.updateConnector(options.activityActor));
15
15
  return true;
16
16
  }
17
17
  if (req.method === "GET" && url.pathname === "/api/agent-updates") {
@@ -23,7 +23,7 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
23
23
  const agentId = options.parseAgentIdRequired(stringField(body, "agentId"));
24
24
  const operation = parseAgentUpdateOperation(optionalStringField(body, "operation"));
25
25
  options.assertScopedAgent(authUser, agentId);
26
- sendJson(res, 202, { job: runtime.startAgentUpdate(agentId, operation) });
26
+ sendJson(res, 202, { job: runtime.startAgentUpdate(agentId, operation, options.activityActor) });
27
27
  return true;
28
28
  }
29
29
  const agentUpdateLogMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/log$/);
@@ -36,7 +36,7 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
36
36
  if (req.method === "DELETE" && agentUpdateLogMatch?.[1]) {
37
37
  const id = decodeURIComponent(agentUpdateLogMatch[1]);
38
38
  options.assertAgentUpdateJobScope(authUser, id);
39
- sendJson(res, 200, { deletedId: id, job: runtime.deleteAgentUpdateLog(id) });
39
+ sendJson(res, 200, { deletedId: id, job: runtime.deleteAgentUpdateLog(id, options.activityActor) });
40
40
  return true;
41
41
  }
42
42
  const agentUpdateInputMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/input$/);
@@ -44,20 +44,53 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
44
44
  const body = await readJsonBody(req);
45
45
  const id = decodeURIComponent(agentUpdateInputMatch[1]);
46
46
  options.assertAgentUpdateJobScope(authUser, id);
47
- sendJson(res, 200, { job: runtime.sendAgentUpdateInput(id, stringField(body, "input")) });
47
+ sendJson(res, 200, { job: runtime.sendAgentUpdateInput(id, stringField(body, "input"), options.activityActor) });
48
48
  return true;
49
49
  }
50
50
  const agentUpdateCancelMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/cancel$/);
51
51
  if (req.method === "POST" && agentUpdateCancelMatch?.[1]) {
52
52
  const id = decodeURIComponent(agentUpdateCancelMatch[1]);
53
53
  options.assertAgentUpdateJobScope(authUser, id);
54
- sendJson(res, 200, { job: runtime.cancelAgentUpdate(id) });
54
+ sendJson(res, 200, { job: runtime.cancelAgentUpdate(id, options.activityActor) });
55
55
  return true;
56
56
  }
57
57
  if (req.method === "GET" && (url.pathname === "/api/tasks" || url.pathname === "/api/progress")) {
58
58
  sendJson(res, 200, await options.scopedTasks(authUser, runtime.tasks()));
59
59
  return true;
60
60
  }
61
+ if (req.method === "GET" && url.pathname === "/api/metrics") {
62
+ sendJson(res, 200, await runtime.metrics());
63
+ return true;
64
+ }
65
+ if (req.method === "GET" && url.pathname === "/api/jobs") {
66
+ sendJson(res, 200, await runtime.jobs());
67
+ return true;
68
+ }
69
+ const jobLogMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/log$/);
70
+ if (req.method === "GET" && jobLogMatch?.[1]) {
71
+ sendJson(res, 200, await runtime.jobLog(decodeURIComponent(jobLogMatch[1])));
72
+ return true;
73
+ }
74
+ const jobActionMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/action$/);
75
+ if (req.method === "POST" && jobActionMatch?.[1]) {
76
+ const body = await readJsonBody(req);
77
+ const id = decodeURIComponent(jobActionMatch[1]);
78
+ const action = stringField(body, "action");
79
+ if (action !== "cancel" && action !== "retry") {
80
+ throw new Error("Unsupported job action.");
81
+ }
82
+ const permission = permissionForJobAction(id, action);
83
+ if (!users.hasPermission(authUser, permission)) {
84
+ sendJson(res, 403, { error: `Access denied: ${permission} permission required.` });
85
+ return true;
86
+ }
87
+ sendJson(res, 200, await runtime.jobAction(id, action, options.activityActor));
88
+ return true;
89
+ }
90
+ if (req.method === "GET" && url.pathname === "/api/active-sessions") {
91
+ sendJson(res, 200, options.scopedActiveSessions(authUser, await runtime.activeSessions()));
92
+ return true;
93
+ }
61
94
  if (req.method === "GET" && url.pathname === "/api/adapters/health") {
62
95
  sendJson(res, 200, { adapters: (await runtime.adapterHealth()).filter((adapter) => users.canUseAgent(authUser, adapter.id)) });
63
96
  return true;
@@ -70,7 +103,7 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
70
103
  if (req.method === "POST" && url.pathname === "/api/logs/clear") {
71
104
  const body = await readJsonBody(req);
72
105
  const target = parseLogTarget(optionalStringField(body, "target"));
73
- sendJson(res, 200, runtime.clearLogs(target));
106
+ sendJson(res, 200, runtime.clearLogs(target, options.activityActor));
74
107
  return true;
75
108
  }
76
109
  if (req.method === "GET" && url.pathname === "/api/diagnostics") {
@@ -80,13 +113,25 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
80
113
  }
81
114
  if (req.method === "GET" && url.pathname === "/api/diagnostics/bundle") {
82
115
  await options.assertCurrentSessionScope(authUser);
83
- const bundle = await runtime.supportBundle();
116
+ const bundle = await runtime.supportBundle(options.activityActor);
84
117
  sendFile(res, bundle.path, bundle.name);
85
118
  return true;
86
119
  }
87
120
  if (req.method === "POST" && url.pathname === "/api/runtime/restart") {
88
- sendJson(res, 202, runtime.restartConnector());
121
+ sendJson(res, 202, runtime.restartConnector(options.activityActor));
89
122
  return true;
90
123
  }
91
124
  return false;
92
125
  }
126
+ function permissionForJobAction(id, action) {
127
+ if (id === "web:current" && action === "cancel") {
128
+ return "prompt.abort";
129
+ }
130
+ if (id.startsWith("queue:")) {
131
+ return "queue.write";
132
+ }
133
+ if (id.startsWith("support-bundle:")) {
134
+ return "diagnostics.read";
135
+ }
136
+ return "updates.run";
137
+ }
@@ -10,12 +10,12 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
10
10
  if (req.method === "POST" && url.pathname === "/api/locks") {
11
11
  const body = await readJsonBody(req);
12
12
  await options.assertCurrentSessionScope(authUser);
13
- sendJson(res, 200, { lock: runtime.lockWebSession(optionalStringField(body, "ownerName")), locks: runtime.locks() });
13
+ sendJson(res, 200, { lock: runtime.lockWebSession(optionalStringField(body, "ownerName"), options.activityActor), locks: runtime.locks() });
14
14
  return true;
15
15
  }
16
16
  if (req.method === "DELETE" && url.pathname === "/api/locks") {
17
17
  await options.assertCurrentSessionScope(authUser);
18
- sendJson(res, 200, runtime.unlockWebSession());
18
+ sendJson(res, 200, runtime.unlockWebSession(options.activityActor));
19
19
  return true;
20
20
  }
21
21
  if (req.method === "GET" && url.pathname === "/api/auth/status") {
@@ -28,14 +28,14 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
28
28
  const body = await readJsonBody(req);
29
29
  const agentId = options.parseAgentId(optionalStringField(body, "agentId"));
30
30
  options.assertScopedAgent(authUser, agentId);
31
- sendJson(res, 200, await runtime.login(agentId));
31
+ sendJson(res, 200, await runtime.login(agentId, options.activityActor));
32
32
  return true;
33
33
  }
34
34
  if (req.method === "POST" && url.pathname === "/api/auth/logout") {
35
35
  const body = await readJsonBody(req);
36
36
  const agentId = options.parseAgentId(optionalStringField(body, "agentId"));
37
37
  options.assertScopedAgent(authUser, agentId);
38
- sendJson(res, 200, await runtime.logout(agentId));
38
+ sendJson(res, 200, await runtime.logout(agentId, options.activityActor));
39
39
  return true;
40
40
  }
41
41
  if (req.method === "GET" && url.pathname === "/api/snapshot") {
@@ -62,7 +62,7 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
62
62
  throw new Error(`Invalid agent: ${agentId}`);
63
63
  }
64
64
  options.assertScopedAgent(authUser, agentId);
65
- sendJson(res, 200, { session: await runtime.setAgent(agentId) });
65
+ sendJson(res, 200, { session: await runtime.setAgent(agentId, options.activityActor) });
66
66
  return true;
67
67
  }
68
68
  if (req.method === "POST" && url.pathname === "/api/sessions/new") {
@@ -79,7 +79,7 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
79
79
  reasoningEffort: optionalStringField(body, "reasoningEffort"),
80
80
  launchProfileId: optionalStringField(body, "launchProfileId"),
81
81
  fastMode: optionalBooleanField(body, "fastMode"),
82
- }),
82
+ }, options.activityActor),
83
83
  });
84
84
  return true;
85
85
  }
@@ -90,14 +90,14 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
90
90
  if (detail.record && typeof detail.record === "object") {
91
91
  options.assertSessionScope(authUser, detail.record);
92
92
  }
93
- const session = await runtime.switchSession(threadId);
93
+ const session = await runtime.switchSession(threadId, options.activityActor);
94
94
  options.assertSessionScope(authUser, session);
95
95
  sendJson(res, 200, { session });
96
96
  return true;
97
97
  }
98
98
  if (req.method === "POST" && url.pathname === "/api/sessions/attach") {
99
99
  const body = await readJsonBody(req);
100
- const session = await runtime.attachSession(stringField(body, "threadId"));
100
+ const session = await runtime.attachSession(stringField(body, "threadId"), options.activityActor);
101
101
  options.assertSessionScope(authUser, session);
102
102
  sendJson(res, 200, { session });
103
103
  return true;
@@ -117,31 +117,31 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
117
117
  if (req.method === "POST" && url.pathname === "/api/session/model") {
118
118
  const body = await readJsonBody(req);
119
119
  await options.assertCurrentSessionScope(authUser);
120
- sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model")) });
120
+ sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model"), options.activityActor) });
121
121
  return true;
122
122
  }
123
123
  if (req.method === "POST" && url.pathname === "/api/session/reasoning") {
124
124
  const body = await readJsonBody(req);
125
125
  await options.assertCurrentSessionScope(authUser);
126
- sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning")) });
126
+ sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning"), options.activityActor) });
127
127
  return true;
128
128
  }
129
129
  if (req.method === "POST" && url.pathname === "/api/session/fast") {
130
130
  const body = await readJsonBody(req);
131
131
  await options.assertCurrentSessionScope(authUser);
132
- sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled)) });
132
+ sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled), options.activityActor) });
133
133
  return true;
134
134
  }
135
135
  if (req.method === "POST" && url.pathname === "/api/session/launch") {
136
136
  const body = await readJsonBody(req);
137
137
  await options.assertCurrentSessionScope(authUser);
138
- sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId")) });
138
+ sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId"), options.activityActor) });
139
139
  return true;
140
140
  }
141
141
  if (req.method === "POST" && url.pathname === "/api/prompt") {
142
142
  const body = await readJsonBody(req);
143
143
  await options.assertCurrentSessionScope(authUser);
144
- sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text")));
144
+ sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text"), options.activityActor));
145
145
  return true;
146
146
  }
147
147
  if (req.method === "POST" && url.pathname === "/api/prompt/upload") {
@@ -150,28 +150,28 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
150
150
  sendJson(res, 202, await runtime.sendUploadPrompt({
151
151
  text: optionalStringField(body, "text"),
152
152
  files: parseUploadFiles(body.files),
153
- }));
153
+ }, options.activityActor));
154
154
  return true;
155
155
  }
156
156
  if (req.method === "POST" && (url.pathname === "/api/abort" || url.pathname === "/api/stop")) {
157
157
  await options.assertCurrentSessionScope(authUser);
158
- await runtime.abort();
158
+ await runtime.abort(options.activityActor);
159
159
  sendJson(res, 200, { ok: true });
160
160
  return true;
161
161
  }
162
162
  if (req.method === "POST" && url.pathname === "/api/handback") {
163
163
  await options.assertCurrentSessionScope(authUser);
164
- sendJson(res, 200, await runtime.handback());
164
+ sendJson(res, 200, await runtime.handback(options.activityActor));
165
165
  return true;
166
166
  }
167
167
  if (req.method === "POST" && url.pathname === "/api/retry") {
168
168
  await options.assertCurrentSessionScope(authUser);
169
- sendJson(res, 202, await runtime.retry());
169
+ sendJson(res, 202, await runtime.retry(options.activityActor));
170
170
  return true;
171
171
  }
172
172
  if (req.method === "POST" && url.pathname === "/api/sync") {
173
173
  await options.assertCurrentSessionScope(authUser);
174
- sendJson(res, 200, await runtime.sync());
174
+ sendJson(res, 200, await runtime.sync(options.activityActor));
175
175
  return true;
176
176
  }
177
177
  if (req.method === "GET" && url.pathname === "/api/queue") {
@@ -182,7 +182,7 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
182
182
  if (req.method === "POST" && url.pathname === "/api/queue") {
183
183
  const body = await readJsonBody(req);
184
184
  await options.assertCurrentSessionScope(authUser);
185
- sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")), paused: runtime.queuePaused() });
185
+ sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id"), options.activityActor), paused: runtime.queuePaused() });
186
186
  return true;
187
187
  }
188
188
  if (req.method === "GET" && url.pathname === "/api/chat/history") {
@@ -192,7 +192,7 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
192
192
  }
193
193
  if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
194
194
  await options.assertCurrentSessionScope(authUser);
195
- sendJson(res, 200, await runtime.clearChatHistory());
195
+ sendJson(res, 200, await runtime.clearChatHistory(options.activityActor));
196
196
  return true;
197
197
  }
198
198
  if (req.method === "GET" && url.pathname === "/api/activity") {
@@ -201,6 +201,13 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
201
201
  limit: numberParam(url, "limit", 100),
202
202
  source: (url.searchParams.get("source") || "all"),
203
203
  status: (url.searchParams.get("status") || "all"),
204
+ category: (url.searchParams.get("category") || "all"),
205
+ actor: url.searchParams.get("actor") || undefined,
206
+ agentId: url.searchParams.get("agent") || "all",
207
+ threadId: url.searchParams.get("thread") || undefined,
208
+ workspace: url.searchParams.get("workspace") || undefined,
209
+ type: url.searchParams.get("type") || undefined,
210
+ since: url.searchParams.get("since") || undefined,
204
211
  })),
205
212
  });
206
213
  return true;
@@ -4,6 +4,7 @@ export const DASHBOARD_PAGES = [
4
4
  { id: "sessions", label: "Sessions", permission: "sessions.read" },
5
5
  { id: "queue", label: "Queue", permission: "queue.read" },
6
6
  { id: "tasks", label: "Tasks", permission: "inspect" },
7
+ { id: "metrics", label: "Metrics", permission: "inspect" },
7
8
  { id: "activity", label: "Activity", permission: "sessions.read" },
8
9
  { id: "artifacts", label: "Artifacts", permission: "files.read" },
9
10
  { id: "adapters", label: "Adapters", permission: "inspect" },