@nordbyte/nordrelay 0.5.2 → 0.7.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 (71) hide show
  1. package/.env.example +80 -11
  2. package/README.md +154 -22
  3. package/dist/access-control.js +7 -1
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +535 -11
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +40 -7
  11. package/dist/channel-command-catalog.js +88 -0
  12. package/dist/channel-command-service.js +369 -0
  13. package/dist/channel-mirror-registry.js +77 -0
  14. package/dist/channel-peer-prompt.js +95 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-service.js +237 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +93 -13
  19. package/dist/config.js +103 -8
  20. package/dist/context-key.js +87 -5
  21. package/dist/discord-artifacts.js +165 -0
  22. package/dist/discord-bot.js +2073 -0
  23. package/dist/discord-channel-runtime.js +133 -0
  24. package/dist/discord-command-surface.js +57 -0
  25. package/dist/discord-rate-limit.js +141 -0
  26. package/dist/index.js +36 -5
  27. package/dist/job-store.js +127 -0
  28. package/dist/metrics.js +87 -0
  29. package/dist/peer-auth.js +85 -0
  30. package/dist/peer-client.js +256 -0
  31. package/dist/peer-context.js +21 -0
  32. package/dist/peer-identity.js +127 -0
  33. package/dist/peer-runtime-service.js +636 -0
  34. package/dist/peer-server.js +220 -0
  35. package/dist/peer-store.js +294 -0
  36. package/dist/peer-types.js +52 -0
  37. package/dist/relay-external-activity-monitor.js +47 -6
  38. package/dist/relay-runtime-helpers.js +208 -0
  39. package/dist/relay-runtime.js +897 -394
  40. package/dist/remote-prompt.js +98 -0
  41. package/dist/runtime-cache.js +57 -0
  42. package/dist/session-locks.js +10 -7
  43. package/dist/support-bundle.js +1 -0
  44. package/dist/telegram-access-commands.js +15 -2
  45. package/dist/telegram-access-middleware.js +16 -3
  46. package/dist/telegram-agent-commands.js +25 -0
  47. package/dist/telegram-artifact-commands.js +46 -0
  48. package/dist/telegram-command-menu.js +3 -53
  49. package/dist/telegram-diagnostics-command.js +5 -50
  50. package/dist/telegram-general-commands.js +16 -6
  51. package/dist/telegram-operational-commands.js +14 -6
  52. package/dist/telegram-preference-commands.js +23 -127
  53. package/dist/telegram-queue-commands.js +74 -4
  54. package/dist/telegram-support-command.js +7 -0
  55. package/dist/telegram-update-commands.js +27 -0
  56. package/dist/user-management.js +208 -0
  57. package/dist/web-api-contract.js +17 -0
  58. package/dist/web-dashboard-access-routes.js +74 -1
  59. package/dist/web-dashboard-artifact-routes.js +3 -3
  60. package/dist/web-dashboard-assets.js +2 -0
  61. package/dist/web-dashboard-pages.js +109 -13
  62. package/dist/web-dashboard-peer-routes.js +204 -0
  63. package/dist/web-dashboard-runtime-routes.js +53 -8
  64. package/dist/web-dashboard-session-routes.js +27 -20
  65. package/dist/web-dashboard-ui.js +2 -0
  66. package/dist/web-dashboard.js +160 -6
  67. package/dist/web-state.js +33 -2
  68. package/dist/webui-assets/dashboard.css +75 -1
  69. package/dist/webui-assets/dashboard.js +779 -55
  70. package/package.json +5 -2
  71. package/plugins/nordrelay/scripts/nordrelay.mjs +578 -19
@@ -1,24 +1,31 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import path from "node:path";
3
2
  import { ensureOutDir } from "./artifacts.js";
4
3
  import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
5
- import { CODEX_AGENT_CAPABILITIES, agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
4
+ import { CODEX_AGENT_CAPABILITIES, agentLabel, agentReasoningLabel, agentReasoningOptions, isAgentId, } from "./agent.js";
6
5
  import { getAgentDiagnostics, getExternalSnapshotForSession, } from "./agent-activity.js";
7
6
  import { listAgentAdapterDescriptors } from "./agent-adapter.js";
8
7
  import { AgentUpdateManager } from "./agent-updates.js";
9
8
  import { createAgentSessionService, enabledAgents } from "./agent-factory.js";
10
9
  import { AuditLogStore } from "./audit-log.js";
10
+ import { BotPreferencesStore } from "./bot-preferences.js";
11
+ import { ChannelTurnService } from "./channel-turn-service.js";
12
+ import { activeSessionSourceForContextKey, ChannelMirrorRegistry } from "./channel-mirror-registry.js";
11
13
  import { checkAuthStatus, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
12
14
  import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "./claude-code-auth.js";
15
+ import { listThreads as listCodexThreads } from "./codex-state.js";
13
16
  import { friendlyErrorText } from "./error-messages.js";
14
17
  import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
15
18
  import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
16
19
  import { clearLogFile, getAgentUpdateLogPath, getConnectorHealth, getConnectorLogPath, getPackageVersion, getUpdateLogPath, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate } from "./operations.js";
17
20
  import { checkPiAuthStatus } from "./pi-auth.js";
18
21
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
22
+ import { UnifiedJobStore } from "./job-store.js";
23
+ import { buildRuntimeMetrics } from "./metrics.js";
19
24
  import { RelayArtifactService } from "./relay-artifact-service.js";
20
25
  import { RelayExternalActivityMonitor } from "./relay-external-activity-monitor.js";
21
26
  import { RelayQueueService } from "./relay-queue-service.js";
27
+ import { RuntimeSnapshotCache } from "./runtime-cache.js";
28
+ import { activeSessionPriority, activityToUnifiedJob, agentUpdateStatusToUnified, cliHealthForAgent, dedupeJobs, hostLoginCommand, hostLogoutCommand, isPromptTerminalActivity, normalizeMimeType, promptActivityToUnifiedJob, shouldRefreshActiveSessions, taskToUnifiedJob, uploadFileDtos, versionCheckForAgent, } from "./relay-runtime-helpers.js";
22
29
  import { renderSessionInfoPlain, renderSessionUsageRows } from "./session-format.js";
23
30
  import { SessionLockStore } from "./session-locks.js";
24
31
  import { SessionRegistry } from "./session-registry.js";
@@ -26,11 +33,14 @@ import { createSupportBundle } from "./support-bundle.js";
26
33
  import { transcribeAudio } from "./voice.js";
27
34
  import { WebActivityStore, WebChatStore, } from "./web-state.js";
28
35
  import { evaluateWorkspacePolicy, filterAllowedWorkspaces } from "./workspace-policy.js";
29
- const WEB_CONTEXT_KEY = "web:dashboard";
36
+ export const WEB_CONTEXT_KEY = "web:dashboard";
37
+ const ACTIVE_CODEX_DISCOVERY_LIMIT = 200;
38
+ const ACTIVE_ACTIVITY_TTL_MS = 6 * 60 * 60 * 1000;
30
39
  const MAX_WEB_SESSION_PAGE_SIZE = 50;
31
40
  const MAX_CHAT_HISTORY = 250;
32
41
  export class RelayRuntime {
33
42
  config;
43
+ contextKey;
34
44
  registry;
35
45
  promptStore;
36
46
  chatStore;
@@ -39,30 +49,44 @@ export class RelayRuntime {
39
49
  lockStore;
40
50
  agentUpdates;
41
51
  queueService;
52
+ jobStore;
42
53
  artifactService;
54
+ mirrorRegistry;
43
55
  externalActivityMonitor;
56
+ cache = new RuntimeSnapshotCache();
57
+ turnService;
44
58
  subscribers = new Set();
59
+ agentUpdateActors = new Map();
60
+ agentUpdateStates = new Map();
45
61
  externalMonitor;
62
+ activeSessionsBroadcastTimer = null;
63
+ activeSessionsLastBroadcastAt = 0;
46
64
  draining = false;
47
65
  currentTurnId = null;
48
66
  accumulatedText = "";
49
67
  currentTurnStartedAt = 0;
50
68
  currentProgress = null;
51
- constructor(config) {
69
+ constructor(config, options = {}) {
52
70
  this.config = config;
71
+ this.contextKey = options.contextKey ?? WEB_CONTEXT_KEY;
53
72
  this.registry = new SessionRegistry(config, {
54
- fileName: "web-contexts.json",
55
- sqliteKey: "web-contexts",
73
+ fileName: options.registryFileName ?? "web-contexts.json",
74
+ sqliteKey: options.registrySqliteKey ?? "web-contexts",
56
75
  });
57
76
  this.promptStore = new PromptStore(config.workspace, config.stateBackend);
58
77
  this.chatStore = new WebChatStore(config.workspace, config.stateBackend, MAX_CHAT_HISTORY);
59
78
  this.activityStore = new WebActivityStore(config.workspace, config.stateBackend, config.auditMaxEvents);
60
79
  this.auditStore = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
61
80
  this.lockStore = new SessionLockStore(config.workspace, config.stateBackend);
62
- this.queueService = new RelayQueueService(this.promptStore, WEB_CONTEXT_KEY);
81
+ this.queueService = new RelayQueueService(this.promptStore, this.contextKey);
82
+ this.jobStore = new UnifiedJobStore(config.workspace, config.stateBackend, config.unifiedJobMaxItems);
63
83
  this.artifactService = new RelayArtifactService(config);
84
+ this.mirrorRegistry = new ChannelMirrorRegistry(config, this.promptStore);
64
85
  this.agentUpdates = new AgentUpdateManager({
65
- onUpdate: (job) => this.broadcast({ type: "agent_update", job }),
86
+ onUpdate: (job) => {
87
+ this.broadcast({ type: "agent_update", job });
88
+ this.recordAgentUpdateLifecycle(job);
89
+ },
66
90
  });
67
91
  this.externalActivityMonitor = new RelayExternalActivityMonitor({
68
92
  config,
@@ -83,11 +107,42 @@ export class RelayRuntime {
83
107
  }, config.codexExternalBusyCheckMs);
84
108
  this.externalMonitor.unref?.();
85
109
  }
110
+ this.turnService = new ChannelTurnService({
111
+ source: "web",
112
+ contextKey: this.contextKey,
113
+ chatStore: this.chatStore,
114
+ artifactService: this.artifactService,
115
+ checkAuth: (info) => this.checkAgentAuth(info),
116
+ ensureActiveThread: (session) => this.ensureActiveThread(session),
117
+ updateSession: (session) => this.updateSession(session),
118
+ appendActivity: (input) => this.appendActivity(input),
119
+ appendAudit: (input) => this.appendAudit(input),
120
+ broadcast: (event) => this.broadcast(event),
121
+ chatHistory: () => this.chatHistory(),
122
+ setLastPrompt: (envelope) => this.queueService.setLastPrompt(envelope),
123
+ getCurrentProgress: () => this.currentProgress,
124
+ setCurrentProgress: (progress) => {
125
+ this.currentProgress = progress;
126
+ },
127
+ setCurrentTurn: (id, startedAt, accumulatedText) => {
128
+ this.currentTurnId = id;
129
+ if (startedAt !== undefined)
130
+ this.currentTurnStartedAt = startedAt;
131
+ if (accumulatedText !== undefined)
132
+ this.accumulatedText = accumulatedText;
133
+ },
134
+ getCurrentTurnStartedAt: () => this.currentTurnStartedAt,
135
+ getAccumulatedText: () => this.accumulatedText,
136
+ setAccumulatedText: (text) => {
137
+ this.accumulatedText = text;
138
+ },
139
+ });
86
140
  }
87
141
  subscribe(callback) {
88
142
  this.subscribers.add(callback);
89
143
  void this.snapshot().then((data) => callback({ type: "snapshot", data })).catch(() => { });
90
144
  void this.chatHistory().then((messages) => callback({ type: "chat_history", messages })).catch(() => { });
145
+ void this.activeSessions().then((active) => callback({ type: "active_sessions_update", active })).catch(() => { });
91
146
  callback({ type: "activity_update", events: this.activity({ limit: 50 }) });
92
147
  return () => this.subscribers.delete(callback);
93
148
  }
@@ -127,19 +182,22 @@ export class RelayRuntime {
127
182
  };
128
183
  }
129
184
  async version() {
130
- const cliOptions = this.cliPathOptions();
131
- const [health, state, versionChecks] = await Promise.all([
132
- getConnectorHealth(cliOptions),
133
- readConnectorState(),
134
- getVersionChecks(cliOptions),
135
- ]);
136
- return {
137
- health,
138
- state,
139
- versionChecks,
140
- };
185
+ return this.cached("version", async () => {
186
+ const cliOptions = this.cliPathOptions();
187
+ const [health, state, versionChecks] = await Promise.all([
188
+ getConnectorHealth(cliOptions),
189
+ readConnectorState(),
190
+ getVersionChecks(cliOptions),
191
+ ]);
192
+ return {
193
+ health,
194
+ state,
195
+ versionChecks,
196
+ };
197
+ });
141
198
  }
142
- updateConnector() {
199
+ updateConnector(actor) {
200
+ this.cache.invalidate("version");
143
201
  const update = spawnSelfUpdate();
144
202
  this.broadcastStatus(`Update started with ${update.method}. Log: ${update.logPath}`, "warn");
145
203
  this.appendActivity({
@@ -148,12 +206,14 @@ export class RelayRuntime {
148
206
  type: "update_started",
149
207
  threadId: null,
150
208
  workspace: this.config.workspace,
209
+ actor,
151
210
  detail: `${update.method}: ${update.summary}`,
152
211
  });
153
212
  this.appendAudit({
154
213
  action: "command",
155
214
  status: "ok",
156
- contextKey: WEB_CONTEXT_KEY,
215
+ contextKey: this.contextKey,
216
+ actor,
157
217
  description: "update",
158
218
  detail: update.summary,
159
219
  });
@@ -162,13 +222,19 @@ export class RelayRuntime {
162
222
  agentUpdateJobs() {
163
223
  return this.agentUpdates.list();
164
224
  }
165
- startAgentUpdate(agentId, operation = "update") {
225
+ startAgentUpdate(agentId, operation = "update", actor) {
226
+ this.cache.invalidate("adapterHealth");
227
+ this.cache.invalidate("version");
166
228
  const job = this.agentUpdates.start(agentId, {
167
229
  piCliPath: this.config.piCliPath,
168
230
  hermesCliPath: this.config.hermesCliPath,
169
231
  openClawCliPath: this.config.openClawCliPath,
170
232
  claudeCodeCliPath: this.config.claudeCodeCliPath,
171
233
  }, operation);
234
+ if (actor) {
235
+ this.agentUpdateActors.set(job.id, actor);
236
+ }
237
+ this.agentUpdateStates.set(job.id, { status: job.status, needsInput: job.needsInput });
172
238
  this.broadcastStatus(`${job.agentLabel} ${operation} started. Log: ${job.logPath}`, "warn");
173
239
  this.appendActivity({
174
240
  source: "web",
@@ -177,13 +243,15 @@ export class RelayRuntime {
177
243
  agentId,
178
244
  threadId: null,
179
245
  workspace: this.config.workspace,
246
+ actor,
180
247
  detail: `${job.method}: ${job.summary}`,
181
248
  });
182
249
  this.appendAudit({
183
250
  action: "command",
184
251
  status: "ok",
185
- contextKey: WEB_CONTEXT_KEY,
252
+ contextKey: this.contextKey,
186
253
  agentId,
254
+ actor,
187
255
  description: `${operation} ${agentId}`,
188
256
  detail: job.summary,
189
257
  });
@@ -192,93 +260,130 @@ export class RelayRuntime {
192
260
  agentUpdateLog(id) {
193
261
  return this.agentUpdates.readLog(id);
194
262
  }
195
- deleteAgentUpdateLog(id) {
263
+ deleteAgentUpdateLog(id, actor) {
196
264
  const job = this.agentUpdates.deleteLog(id);
265
+ this.appendActivity({
266
+ source: "web",
267
+ status: "info",
268
+ type: "agent_update_log_deleted",
269
+ agentId: job.agentId,
270
+ threadId: null,
271
+ workspace: this.config.workspace,
272
+ actor,
273
+ detail: job.logPath,
274
+ });
197
275
  this.appendAudit({
198
276
  action: "command",
199
277
  status: "ok",
200
- contextKey: WEB_CONTEXT_KEY,
278
+ contextKey: this.contextKey,
201
279
  agentId: job.agentId,
280
+ actor,
202
281
  description: `delete update log ${id}`,
203
282
  detail: job.logPath,
204
283
  });
205
284
  return job;
206
285
  }
207
- sendAgentUpdateInput(id, input) {
208
- return this.agentUpdates.sendInput(id, input);
286
+ sendAgentUpdateInput(id, input, actor) {
287
+ const job = this.agentUpdates.sendInput(id, input);
288
+ this.appendActivity({
289
+ source: "web",
290
+ status: "info",
291
+ type: "agent_update_input_sent",
292
+ agentId: job.agentId,
293
+ threadId: null,
294
+ workspace: this.config.workspace,
295
+ actor: actor ?? this.agentUpdateActors.get(id),
296
+ detail: `Input sent to ${job.agentLabel} ${job.operation}.`,
297
+ });
298
+ return job;
209
299
  }
210
- cancelAgentUpdate(id) {
211
- return this.agentUpdates.cancel(id);
300
+ cancelAgentUpdate(id, actor) {
301
+ const job = this.agentUpdates.cancel(id);
302
+ this.appendActivity({
303
+ source: "web",
304
+ status: "aborted",
305
+ type: "agent_update_cancel_requested",
306
+ agentId: job.agentId,
307
+ threadId: null,
308
+ workspace: this.config.workspace,
309
+ actor: actor ?? this.agentUpdateActors.get(id),
310
+ detail: `${job.agentLabel} ${job.operation} cancellation requested.`,
311
+ });
312
+ return job;
212
313
  }
213
314
  async diagnostics() {
214
- const cliOptions = this.cliPathOptions();
215
- const [health, versionChecks, snapshot, session] = await Promise.all([
216
- getConnectorHealth(cliOptions),
217
- getVersionChecks(cliOptions),
218
- this.snapshot(),
219
- this.getSession(true),
220
- ]);
221
- return {
222
- health,
223
- versionChecks,
224
- snapshot,
225
- runtime: {
226
- stateBackend: this.config.stateBackend,
227
- sourceWorkspace: this.config.workspace,
228
- queuePaused: this.queueService.isPaused(),
229
- externalMirror: this.externalActivityMonitor.snapshot(),
230
- agentDiagnostics: getAgentDiagnostics(session, this.config),
231
- },
232
- };
233
- }
234
- async adapterHealth() {
235
- const cliOptions = this.cliPathOptions();
236
- const [health, versions] = await Promise.all([
237
- getConnectorHealth(cliOptions),
238
- getVersionChecks(cliOptions),
239
- ]);
240
- return Promise.all(listAgentAdapterDescriptors().map(async (descriptor) => {
241
- const enabled = enabledAgents(this.config).includes(descriptor.id);
242
- const auth = descriptor.capabilities.auth && enabled
243
- ? await this.authStatus(descriptor.id).catch((error) => ({
244
- agentId: descriptor.id,
245
- agentLabel: descriptor.label,
246
- supported: descriptor.capabilities.auth,
247
- authenticated: false,
248
- detail: friendlyErrorText(error),
249
- loginSupported: descriptor.capabilities.login,
250
- logoutSupported: descriptor.capabilities.logout,
251
- }))
252
- : null;
253
- const cli = cliHealthForAgent(descriptor.id, health);
254
- const version = versionCheckForAgent(descriptor.id, versions);
315
+ return this.cached("diagnostics", async () => {
316
+ const cliOptions = this.cliPathOptions();
317
+ const [health, versionChecks, snapshot, session] = await Promise.all([
318
+ getConnectorHealth(cliOptions),
319
+ getVersionChecks(cliOptions),
320
+ this.snapshot(),
321
+ this.getSession(true),
322
+ ]);
255
323
  return {
256
- id: descriptor.id,
257
- label: descriptor.label,
258
- enabled,
259
- status: descriptor.status === "available" ? (enabled ? "enabled" : "disabled") : "planned",
260
- auth: {
261
- supported: descriptor.capabilities.auth,
262
- authenticated: auth ? auth.authenticated : null,
263
- method: auth?.method,
264
- detail: auth?.detail,
265
- },
266
- cli,
267
- version: {
268
- installed: version.installedLabel,
269
- latest: version.latestVersion,
270
- status: version.status,
271
- detail: version.detail,
324
+ health,
325
+ versionChecks,
326
+ snapshot,
327
+ runtime: {
328
+ stateBackend: this.config.stateBackend,
329
+ sourceWorkspace: this.config.workspace,
330
+ queuePaused: this.queueService.isPaused(),
331
+ externalMirror: this.externalActivityMonitor.snapshot(),
332
+ agentDiagnostics: getAgentDiagnostics(session, this.config),
272
333
  },
273
- capabilities: descriptor.capabilities,
274
- notes: descriptor.notes,
275
334
  };
276
- }));
335
+ });
336
+ }
337
+ async adapterHealth() {
338
+ return this.cached("adapterHealth", async () => {
339
+ const cliOptions = this.cliPathOptions();
340
+ const [health, versions] = await Promise.all([
341
+ getConnectorHealth(cliOptions),
342
+ getVersionChecks(cliOptions),
343
+ ]);
344
+ return Promise.all(listAgentAdapterDescriptors().map(async (descriptor) => {
345
+ const enabled = enabledAgents(this.config).includes(descriptor.id);
346
+ const auth = descriptor.capabilities.auth && enabled
347
+ ? await this.authStatus(descriptor.id).catch((error) => ({
348
+ agentId: descriptor.id,
349
+ agentLabel: descriptor.label,
350
+ supported: descriptor.capabilities.auth,
351
+ authenticated: false,
352
+ detail: friendlyErrorText(error),
353
+ loginSupported: descriptor.capabilities.login,
354
+ logoutSupported: descriptor.capabilities.logout,
355
+ }))
356
+ : null;
357
+ const cli = cliHealthForAgent(descriptor.id, health);
358
+ const version = versionCheckForAgent(descriptor.id, versions);
359
+ return {
360
+ id: descriptor.id,
361
+ label: descriptor.label,
362
+ enabled,
363
+ status: descriptor.status === "available" ? (enabled ? "enabled" : "disabled") : "planned",
364
+ auth: {
365
+ supported: descriptor.capabilities.auth,
366
+ authenticated: auth ? auth.authenticated : null,
367
+ method: auth?.method,
368
+ detail: auth?.detail,
369
+ },
370
+ cli,
371
+ version: {
372
+ installed: version.installedLabel,
373
+ latest: version.latestVersion,
374
+ status: version.status,
375
+ detail: version.detail,
376
+ },
377
+ capabilities: descriptor.capabilities,
378
+ notes: descriptor.notes,
379
+ };
380
+ }));
381
+ });
277
382
  }
278
383
  permissions() {
279
384
  return {
280
385
  mode: "users",
281
- message: "Access is managed by NordRelay users, groups, Telegram identities, and Telegram chat access records.",
386
+ message: "Access is managed by NordRelay users, groups, Telegram identities, Telegram chat access records, Discord identities, and Discord channel access records.",
282
387
  };
283
388
  }
284
389
  tasks() {
@@ -290,10 +395,229 @@ export class RelayRuntime {
290
395
  recent: this.activity({ limit: 20 }),
291
396
  };
292
397
  }
293
- audit(limit = 50) {
294
- return this.auditStore.list(limit);
398
+ async jobs() {
399
+ const jobs = [];
400
+ const current = this.currentProgress;
401
+ if (current) {
402
+ jobs.push(taskToUnifiedJob("web:current", "web-turn", "Current WebUI turn", current, {
403
+ canCancel: current.status === "running",
404
+ canRetry: false,
405
+ canReadLog: false,
406
+ }));
407
+ }
408
+ const external = this.externalActivityMonitor.task();
409
+ if (external) {
410
+ jobs.push(taskToUnifiedJob(`external:${external.agentId ?? "agent"}:${external.threadId ?? "pending"}`, "external-turn", "External CLI turn", external, {
411
+ canCancel: false,
412
+ canRetry: false,
413
+ canReadLog: false,
414
+ }));
415
+ }
416
+ for (const item of this.queueService.rawList()) {
417
+ const createdAt = new Date(item.createdAt).toISOString();
418
+ jobs.push({
419
+ id: `queue:${item.id}`,
420
+ kind: "queued-prompt",
421
+ title: `Queued prompt ${item.id}`,
422
+ status: "queued",
423
+ source: "web",
424
+ threadId: null,
425
+ workspace: this.config.workspace,
426
+ owner: item.activityActor,
427
+ startedAt: createdAt,
428
+ updatedAt: createdAt,
429
+ summary: item.description,
430
+ queueId: item.id,
431
+ logTail: item.lastError,
432
+ canCancel: true,
433
+ canRetry: true,
434
+ canReadLog: true,
435
+ });
436
+ }
437
+ for (const job of this.agentUpdates.list()) {
438
+ jobs.push({
439
+ id: `agent-update:${job.id}`,
440
+ kind: "agent-update",
441
+ title: `${job.agentLabel} ${job.operation}`,
442
+ status: agentUpdateStatusToUnified(job.status),
443
+ source: "web",
444
+ agentId: job.agentId,
445
+ agentLabel: job.agentLabel,
446
+ threadId: null,
447
+ workspace: this.config.workspace,
448
+ owner: this.agentUpdateActors.get(job.id),
449
+ startedAt: job.startedAt,
450
+ updatedAt: job.updatedAt,
451
+ finishedAt: job.finishedAt,
452
+ summary: job.error || job.summary,
453
+ logPath: job.logPath,
454
+ logTail: job.outputTail,
455
+ updateJobId: job.id,
456
+ canCancel: job.status === "running",
457
+ canRetry: job.status !== "running",
458
+ canReadLog: true,
459
+ });
460
+ }
461
+ for (const event of this.activity({ limit: 100 })) {
462
+ if (event.type === "diagnostics_bundle_exported") {
463
+ jobs.push(activityToUnifiedJob(event, "support-bundle", "Diagnostics support bundle", {
464
+ canCancel: false,
465
+ canRetry: true,
466
+ canReadLog: Boolean(event.detail),
467
+ }));
468
+ }
469
+ else if (event.type === "update_started") {
470
+ jobs.push(activityToUnifiedJob(event, "connector-update", "NordRelay update", {
471
+ canCancel: false,
472
+ canRetry: true,
473
+ canReadLog: Boolean(event.detail),
474
+ }));
475
+ }
476
+ else if (event.category === "prompt" && event.type.startsWith("prompt_")) {
477
+ jobs.push(promptActivityToUnifiedJob(event));
478
+ }
479
+ }
480
+ const liveJobs = dedupeJobs(jobs);
481
+ const storedJobs = this.jobStore.upsertMany(liveJobs);
482
+ return {
483
+ jobs: dedupeJobs([...liveJobs, ...storedJobs]).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)),
484
+ updatedAt: new Date().toISOString(),
485
+ };
295
486
  }
296
- async supportBundle() {
487
+ async jobLog(id) {
488
+ if (id.startsWith("agent-update:")) {
489
+ const updateId = id.slice("agent-update:".length);
490
+ const log = this.agentUpdates.readLog(updateId);
491
+ return { job: (await this.jobs()).jobs.find((job) => job.id === id) ?? null, plain: log.plain };
492
+ }
493
+ if (id.startsWith("queue:")) {
494
+ const queueId = id.slice("queue:".length);
495
+ const item = this.queueService.rawList().find((candidate) => candidate.id === queueId);
496
+ return {
497
+ job: (await this.jobs()).jobs.find((job) => job.id === id) ?? null,
498
+ plain: item ? [
499
+ `Queued prompt: ${item.id}`,
500
+ `Created: ${new Date(item.createdAt).toISOString()}`,
501
+ `Attempts: ${item.attempts ?? 0}`,
502
+ `Description: ${item.description}`,
503
+ item.lastError ? `Last error: ${item.lastError}` : "",
504
+ ].filter(Boolean).join("\n") : "Queued prompt not found.",
505
+ };
506
+ }
507
+ const job = (await this.jobs()).jobs.find((candidate) => candidate.id === id) ?? null;
508
+ return { job, plain: job?.logTail || job?.logPath || job?.summary || this.jobStore.get(id)?.summary || "No log available for this job." };
509
+ }
510
+ async jobAction(id, action, actor) {
511
+ if (id === "web:current" && action === "cancel") {
512
+ await this.abort(actor);
513
+ return this.jobs();
514
+ }
515
+ if (id.startsWith("queue:")) {
516
+ const queueId = id.slice("queue:".length);
517
+ this.queueService.apply(action === "cancel" ? "cancel" : "run", queueId);
518
+ this.jobStore.patch(id, {
519
+ status: action === "cancel" ? "aborted" : "queued",
520
+ summary: action === "cancel" ? `Cancelled queued prompt ${queueId}.` : `Queued prompt ${queueId} moved to the front.`,
521
+ canCancel: action !== "cancel",
522
+ canRetry: action === "cancel",
523
+ finishedAt: action === "cancel" ? new Date().toISOString() : undefined,
524
+ });
525
+ this.broadcast({ type: "queue_update", queue: this.queue(), paused: this.queuePaused() });
526
+ this.appendActivity({
527
+ source: "web",
528
+ status: action === "cancel" ? "aborted" : "queued",
529
+ type: action === "cancel" ? "job_cancelled" : "job_retried",
530
+ threadId: null,
531
+ workspace: this.config.workspace,
532
+ actor,
533
+ detail: `queue:${queueId}`,
534
+ });
535
+ if (action === "retry") {
536
+ void this.drainQueue();
537
+ }
538
+ return this.jobs();
539
+ }
540
+ if (id.startsWith("agent-update:")) {
541
+ const updateId = id.slice("agent-update:".length);
542
+ const current = this.agentUpdates.get(updateId);
543
+ if (!current) {
544
+ throw new Error("Unknown agent update job.");
545
+ }
546
+ if (action === "cancel") {
547
+ this.cancelAgentUpdate(updateId, actor);
548
+ }
549
+ else {
550
+ this.startAgentUpdate(current.agentId, current.operation, actor);
551
+ }
552
+ return this.jobs();
553
+ }
554
+ if (id.startsWith("support-bundle:") && action === "retry") {
555
+ await this.supportBundle(actor);
556
+ return this.jobs();
557
+ }
558
+ if (id.startsWith("connector-update:") && action === "retry") {
559
+ this.updateConnector(actor);
560
+ return this.jobs();
561
+ }
562
+ throw new Error(`Unsupported job action: ${action} ${id}`);
563
+ }
564
+ async activeSessions() {
565
+ const sessions = new Map();
566
+ const knownContexts = this.listKnownContextMetadata();
567
+ const preferences = new BotPreferencesStore(this.config.workspace, this.config.stateBackend);
568
+ const addActiveSession = (session) => {
569
+ const key = this.activeSessionKey(session);
570
+ const existing = sessions.get(key);
571
+ sessions.set(key, this.preferredActiveSession(existing, session));
572
+ };
573
+ if (this.currentProgress?.status === "running") {
574
+ addActiveSession({
575
+ ...this.currentProgress,
576
+ contextKey: this.contextKey,
577
+ sourceContextKey: this.contextKey,
578
+ source: "web",
579
+ status: "running",
580
+ queueLength: this.queueService.length(),
581
+ queuePaused: this.queueService.isPaused(),
582
+ });
583
+ }
584
+ for (const active of this.discoverRunningConnectorSessions()) {
585
+ addActiveSession(active);
586
+ }
587
+ for (const active of this.discoverActiveCodexSessions(knownContexts, preferences)) {
588
+ addActiveSession(active);
589
+ }
590
+ for (const meta of knownContexts) {
591
+ if (meta.contextKey === this.contextKey && this.currentProgress?.status === "running") {
592
+ continue;
593
+ }
594
+ const active = this.externalActiveSession(meta, knownContexts, preferences);
595
+ if (active) {
596
+ addActiveSession(active);
597
+ }
598
+ }
599
+ return {
600
+ sessions: [...sessions.values()].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)),
601
+ updatedAt: new Date().toISOString(),
602
+ };
603
+ }
604
+ async metrics() {
605
+ const [active, jobs] = await Promise.all([
606
+ this.activeSessions(),
607
+ this.jobs(),
608
+ ]);
609
+ return buildRuntimeMetrics({
610
+ queueLength: this.queueService.length(),
611
+ queuePaused: this.queueService.isPaused(),
612
+ activeTurnCount: active.sessions.length,
613
+ jobs: jobs.jobs,
614
+ activity: this.activity({ limit: 500 }),
615
+ });
616
+ }
617
+ audit(options = 50) {
618
+ return this.auditStore.list(options);
619
+ }
620
+ async supportBundle(actor) {
297
621
  const bundle = await createSupportBundle({
298
622
  config: this.config,
299
623
  diagnostics: await this.diagnostics(),
@@ -308,12 +632,14 @@ export class RelayRuntime {
308
632
  type: "diagnostics_bundle_exported",
309
633
  threadId: null,
310
634
  workspace: this.config.workspace,
635
+ actor,
311
636
  detail: bundle.path,
312
637
  });
313
638
  this.appendAudit({
314
639
  action: "command",
315
640
  status: "ok",
316
- contextKey: WEB_CONTEXT_KEY,
641
+ contextKey: this.contextKey,
642
+ actor,
317
643
  description: "export diagnostics bundle",
318
644
  detail: bundle.path,
319
645
  });
@@ -322,23 +648,48 @@ export class RelayRuntime {
322
648
  locks() {
323
649
  return this.lockStore.list();
324
650
  }
325
- lockWebSession(ownerName = "Web dashboard") {
326
- const lock = this.lockStore.set(WEB_CONTEXT_KEY, 0, ownerName, this.config.sessionLockTtlMs);
651
+ lockWebSession(ownerName = "Web dashboard", actor) {
652
+ const label = ownerName || actor?.label || "Web dashboard";
653
+ const lock = this.lockStore.set(this.contextKey, {
654
+ userId: actor?.id ?? "web",
655
+ label,
656
+ channel: "web",
657
+ }, this.config.sessionLockTtlMs);
658
+ this.appendActivity({
659
+ source: "web",
660
+ status: "info",
661
+ type: "lock_created",
662
+ threadId: null,
663
+ workspace: this.config.workspace,
664
+ actor,
665
+ detail: `locked by ${label}`,
666
+ });
327
667
  this.appendAudit({
328
668
  action: "lock_updated",
329
669
  status: "ok",
330
- contextKey: WEB_CONTEXT_KEY,
670
+ contextKey: this.contextKey,
671
+ actor,
331
672
  description: "lock",
332
- detail: `locked by ${ownerName}`,
673
+ detail: `locked by ${label}`,
333
674
  });
334
675
  return lock;
335
676
  }
336
- unlockWebSession() {
337
- const removed = this.lockStore.clear(WEB_CONTEXT_KEY);
677
+ unlockWebSession(actor) {
678
+ const removed = this.lockStore.clear(this.contextKey);
679
+ this.appendActivity({
680
+ source: "web",
681
+ status: "info",
682
+ type: "lock_removed",
683
+ threadId: null,
684
+ workspace: this.config.workspace,
685
+ actor,
686
+ detail: removed ? "unlocked" : "no lock",
687
+ });
338
688
  this.appendAudit({
339
689
  action: "lock_updated",
340
690
  status: "ok",
341
- contextKey: WEB_CONTEXT_KEY,
691
+ contextKey: this.contextKey,
692
+ actor,
342
693
  description: "unlock",
343
694
  detail: removed ? "unlocked" : "no lock",
344
695
  });
@@ -407,7 +758,7 @@ export class RelayRuntime {
407
758
  }
408
759
  }
409
760
  }
410
- async login(agentId) {
761
+ async login(agentId, actor) {
411
762
  const { session, dispose } = await this.getControlSession(agentId);
412
763
  try {
413
764
  const info = this.publicInfo(session);
@@ -431,13 +782,24 @@ export class RelayRuntime {
431
782
  };
432
783
  }
433
784
  const result = await this.startAgentLogin(info);
785
+ this.appendActivity({
786
+ source: "web",
787
+ status: result.success ? "info" : "failed",
788
+ type: result.success ? "login_started" : "login_failed",
789
+ threadId: info.threadId,
790
+ workspace: info.workspace,
791
+ agentId: info.agentId,
792
+ actor,
793
+ detail: result.message,
794
+ });
434
795
  this.appendAudit({
435
796
  action: "command",
436
797
  status: result.success ? "ok" : "failed",
437
- contextKey: WEB_CONTEXT_KEY,
798
+ contextKey: this.contextKey,
438
799
  agentId: info.agentId,
439
800
  threadId: info.threadId,
440
801
  workspace: info.workspace,
802
+ actor,
441
803
  description: "login",
442
804
  detail: result.message,
443
805
  });
@@ -449,7 +811,7 @@ export class RelayRuntime {
449
811
  }
450
812
  }
451
813
  }
452
- async logout(agentId) {
814
+ async logout(agentId, actor) {
453
815
  const { session, dispose } = await this.getControlSession(agentId);
454
816
  try {
455
817
  const info = this.publicInfo(session);
@@ -483,13 +845,24 @@ export class RelayRuntime {
483
845
  };
484
846
  }
485
847
  const result = await this.startAgentLogout(info);
848
+ this.appendActivity({
849
+ source: "web",
850
+ status: result.success ? "info" : "failed",
851
+ type: result.success ? "logout_completed" : "logout_failed",
852
+ threadId: info.threadId,
853
+ workspace: info.workspace,
854
+ agentId: info.agentId,
855
+ actor,
856
+ detail: result.message,
857
+ });
486
858
  this.appendAudit({
487
859
  action: "command",
488
860
  status: result.success ? "ok" : "failed",
489
- contextKey: WEB_CONTEXT_KEY,
861
+ contextKey: this.contextKey,
490
862
  agentId: info.agentId,
491
863
  threadId: info.threadId,
492
864
  workspace: info.workspace,
865
+ actor,
493
866
  description: "logout",
494
867
  detail: result.message,
495
868
  });
@@ -517,18 +890,29 @@ export class RelayRuntime {
517
890
  activity: this.activity({ limit: 100 }).filter((event) => event.threadId === threadId),
518
891
  };
519
892
  }
520
- async clearChatHistory() {
893
+ async clearChatHistory(actor) {
521
894
  const session = await this.getSession(true);
522
- const removed = this.chatStore.clear(this.publicInfo(session).threadId);
895
+ const info = this.publicInfo(session);
896
+ const removed = this.chatStore.clear(info.threadId);
523
897
  const messages = await this.chatHistory();
524
898
  this.broadcast({ type: "chat_history", messages });
899
+ this.appendActivity({
900
+ source: "web",
901
+ status: "info",
902
+ type: "chat_history_cleared",
903
+ threadId: info.threadId,
904
+ workspace: info.workspace,
905
+ agentId: info.agentId,
906
+ actor,
907
+ detail: `${removed} messages removed.`,
908
+ });
525
909
  return { removed, messages };
526
910
  }
527
911
  activity(options = {}) {
528
- const currentInfo = this.registry.get(WEB_CONTEXT_KEY)?.getInfo();
912
+ const currentInfo = this.registry.get(this.contextKey)?.getInfo();
529
913
  return this.activityStore.list(options).map((event) => this.enrichActivityEvent(event, currentInfo));
530
914
  }
531
- async retry() {
915
+ async retry(actor) {
532
916
  const cached = this.queueService.getLastPrompt();
533
917
  if (!cached) {
534
918
  throw new Error("Nothing to retry. Send a message first.");
@@ -536,13 +920,14 @@ export class RelayRuntime {
536
920
  this.appendAudit({
537
921
  action: "command",
538
922
  status: "ok",
539
- contextKey: WEB_CONTEXT_KEY,
923
+ contextKey: this.contextKey,
924
+ actor,
540
925
  description: "retry",
541
926
  detail: cached.description,
542
927
  });
543
- return this.sendEnvelope(cached);
928
+ return this.sendEnvelope({ ...cached, activityActor: cached.activityActor ?? actor }, actor);
544
929
  }
545
- async sync() {
930
+ async sync(actor) {
546
931
  const session = await this.getSession(true);
547
932
  const info = this.publicInfo(session);
548
933
  if (!(info.capabilities ?? CODEX_AGENT_CAPABILITIES).externalActivity) {
@@ -559,15 +944,17 @@ export class RelayRuntime {
559
944
  threadId: result.info.threadId,
560
945
  workspace: result.info.workspace,
561
946
  agentId: result.info.agentId,
947
+ actor,
562
948
  detail: result.changedFields.length > 0 ? result.changedFields.join(", ") : "already in sync",
563
949
  });
564
950
  this.appendAudit({
565
951
  action: "command",
566
952
  status: "ok",
567
- contextKey: WEB_CONTEXT_KEY,
953
+ contextKey: this.contextKey,
568
954
  agentId: result.info.agentId,
569
955
  threadId: result.info.threadId,
570
956
  workspace: result.info.workspace,
957
+ actor,
571
958
  description: "sync",
572
959
  detail: result.changedFields.join(", ") || "none",
573
960
  });
@@ -634,16 +1021,27 @@ export class RelayRuntime {
634
1021
  });
635
1022
  return session.listModels();
636
1023
  }
637
- async setAgent(agentId) {
1024
+ async setAgent(agentId, actor) {
638
1025
  if (!enabledAgents(this.config).includes(agentId)) {
639
1026
  throw new Error(`Agent is not enabled: ${agentId}`);
640
1027
  }
641
- const session = await this.registry.switchAgent(WEB_CONTEXT_KEY, agentId);
1028
+ const session = await this.registry.switchAgent(this.contextKey, agentId);
642
1029
  this.updateSession(session);
1030
+ const info = this.publicInfo(session);
1031
+ this.appendActivity({
1032
+ source: "web",
1033
+ status: "info",
1034
+ type: "agent_switch",
1035
+ threadId: info.threadId,
1036
+ workspace: info.workspace,
1037
+ agentId: info.agentId,
1038
+ actor,
1039
+ detail: `Dashboard switched agent to ${info.agentLabel}.`,
1040
+ });
643
1041
  return this.publicInfo(session);
644
1042
  }
645
- async newSession(options = {}) {
646
- const session = options.agentId ? await this.registry.switchAgent(WEB_CONTEXT_KEY, options.agentId) : await this.getSession(true);
1043
+ async newSession(options = {}, actor) {
1044
+ const session = options.agentId ? await this.registry.switchAgent(this.contextKey, options.agentId) : await this.getSession(true);
647
1045
  this.ensureIdle(session);
648
1046
  if (options.reasoningEffort) {
649
1047
  const reasoningOptions = agentReasoningOptions(session.getInfo().agentId);
@@ -667,11 +1065,12 @@ export class RelayRuntime {
667
1065
  threadId: info.threadId,
668
1066
  workspace: info.workspace,
669
1067
  agentId: info.agentId,
1068
+ actor,
670
1069
  detail: "New dashboard session created.",
671
1070
  });
672
1071
  return this.publicInfo(session);
673
1072
  }
674
- async switchSession(threadId) {
1073
+ async switchSession(threadId, actor) {
675
1074
  const session = await this.getSession(true);
676
1075
  this.ensureIdle(session);
677
1076
  const info = await session.switchSession(threadId);
@@ -684,21 +1083,24 @@ export class RelayRuntime {
684
1083
  threadId: info.threadId,
685
1084
  workspace: info.workspace,
686
1085
  agentId: info.agentId,
1086
+ actor,
687
1087
  detail: "Dashboard switched session.",
688
1088
  });
689
1089
  return this.publicInfo(session);
690
1090
  }
691
- async attachSession(threadId) {
692
- return this.switchSession(threadId);
1091
+ async attachSession(threadId, actor) {
1092
+ return this.switchSession(threadId, actor);
693
1093
  }
694
- async setModel(model) {
1094
+ async setModel(model, actor) {
695
1095
  const session = await this.getSession(true);
696
1096
  this.ensureIdle(session);
697
1097
  await session.setModelForCurrentSession(model);
698
1098
  this.updateSession(session);
699
- return this.publicInfo(session);
1099
+ const info = this.publicInfo(session);
1100
+ this.appendActivity({ source: "web", status: "info", type: "model_changed", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: model });
1101
+ return info;
700
1102
  }
701
- async setReasoningEffort(effort) {
1103
+ async setReasoningEffort(effort, actor) {
702
1104
  const session = await this.getSession(true);
703
1105
  this.ensureIdle(session);
704
1106
  const options = agentReasoningOptions(session.getInfo().agentId);
@@ -707,9 +1109,11 @@ export class RelayRuntime {
707
1109
  }
708
1110
  await session.setReasoningEffortForCurrentSession(effort);
709
1111
  this.updateSession(session);
710
- return this.publicInfo(session);
1112
+ const info = this.publicInfo(session);
1113
+ this.appendActivity({ source: "web", status: "info", type: "reasoning_changed", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: effort });
1114
+ return info;
711
1115
  }
712
- async setFastMode(enabled) {
1116
+ async setFastMode(enabled, actor) {
713
1117
  const session = await this.getSession(true);
714
1118
  this.ensureIdle(session);
715
1119
  if (!(session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).fastMode) {
@@ -717,23 +1121,29 @@ export class RelayRuntime {
717
1121
  }
718
1122
  session.setFastMode(enabled);
719
1123
  this.updateSession(session);
720
- return this.publicInfo(session);
1124
+ const info = this.publicInfo(session);
1125
+ this.appendActivity({ source: "web", status: "info", type: "fast_mode_changed", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: enabled ? "on" : "off" });
1126
+ return info;
721
1127
  }
722
- async setLaunchProfile(profileId) {
1128
+ async setLaunchProfile(profileId, actor) {
723
1129
  const session = await this.getSession(true);
724
1130
  this.ensureIdle(session);
725
1131
  session.setLaunchProfile(profileId);
726
1132
  this.updateSession(session);
727
- return this.publicInfo(session);
1133
+ const info = this.publicInfo(session);
1134
+ this.appendActivity({ source: "web", status: "info", type: "launch_profile_changed", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: info.launchProfileLabel ?? profileId });
1135
+ return info;
728
1136
  }
729
- async handback() {
1137
+ async handback(actor) {
730
1138
  const session = await this.getSession(true);
731
1139
  this.ensureIdle(session);
732
1140
  const result = session.handback();
733
1141
  this.updateSession(session);
1142
+ const info = this.publicInfo(session);
1143
+ this.appendActivity({ source: "web", status: "info", type: "handback", threadId: result.threadId, workspace: result.workspace, agentId: info.agentId, actor, detail: result.command ?? "Thread handed back." });
734
1144
  return result;
735
1145
  }
736
- async abort() {
1146
+ async abort(actor) {
737
1147
  const session = await this.getSession(true);
738
1148
  const snapshot = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
739
1149
  if (snapshot?.activity.active && !session.isProcessing()) {
@@ -743,19 +1153,23 @@ export class RelayRuntime {
743
1153
  message: `Cannot abort the external ${snapshot.agentLabel} CLI task from NordRelay. Stop it in the terminal where it is running.`,
744
1154
  at: new Date().toISOString(),
745
1155
  });
1156
+ const info = this.publicInfo(session);
1157
+ this.appendActivity({ source: "web", status: "aborted", type: "prompt_abort_rejected", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: `External ${snapshot.agentLabel} CLI task is active.` });
746
1158
  return;
747
1159
  }
748
1160
  await session.abort();
1161
+ const info = this.publicInfo(session);
1162
+ this.appendActivity({ source: "web", status: "aborted", type: "prompt_aborted", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: "Current operation aborted." });
749
1163
  this.broadcast({ type: "status", level: "warn", message: "Current operation aborted.", at: new Date().toISOString() });
750
1164
  }
751
- async sendPrompt(text) {
1165
+ async sendPrompt(text, actor) {
752
1166
  const trimmed = text.trim();
753
1167
  if (!trimmed) {
754
1168
  throw new Error("Prompt is empty.");
755
1169
  }
756
- return this.sendEnvelope(toPromptEnvelope(trimmed));
1170
+ return this.sendEnvelope({ ...toPromptEnvelope(trimmed), activityActor: actor }, actor);
757
1171
  }
758
- async sendUploadPrompt(options) {
1172
+ async sendUploadPrompt(options, actor) {
759
1173
  const text = options.text?.trim() ?? "";
760
1174
  const files = options.files.filter((file) => file.data.byteLength > 0);
761
1175
  if (!text && files.length === 0) {
@@ -790,9 +1204,32 @@ export class RelayRuntime {
790
1204
  const transcript = result.text.trim();
791
1205
  if (transcript) {
792
1206
  transcriptParts.push(`Audio transcript (${staged.safeName}, via ${result.backend}):\n${transcript}`);
1207
+ this.appendActivity({
1208
+ source: "web",
1209
+ status: "completed",
1210
+ type: "voice_transcribed",
1211
+ threadId: session.getInfo().threadId,
1212
+ workspace,
1213
+ agentId: session.getInfo().agentId,
1214
+ actor,
1215
+ detail: `${staged.safeName} via ${result.backend}`,
1216
+ durationMs: result.durationMs,
1217
+ });
793
1218
  }
794
1219
  }
795
1220
  }
1221
+ if (stagedFiles.length > 0) {
1222
+ this.appendActivity({
1223
+ source: "web",
1224
+ status: "info",
1225
+ type: "attachment_staged",
1226
+ threadId: session.getInfo().threadId,
1227
+ workspace,
1228
+ agentId: session.getInfo().agentId,
1229
+ actor,
1230
+ detail: `${stagedFiles.length} file(s): ${stagedFiles.map((file) => file.safeName).join(", ")}`,
1231
+ });
1232
+ }
796
1233
  const audioOnly = stagedFiles.length > 0 && stagedFiles.every((file) => file.mimeType.startsWith("audio/"));
797
1234
  if (this.config.voiceTranscribeOnly && audioOnly && !text) {
798
1235
  return {
@@ -813,14 +1250,15 @@ export class RelayRuntime {
813
1250
  if (stagedFiles.length > 0) {
814
1251
  promptInput.stagedFileInstructions = buildFileInstructions(stagedFiles, outDir);
815
1252
  }
816
- const result = await this.sendEnvelope(toPromptEnvelope(promptInput, outDir));
1253
+ const result = await this.sendEnvelope({ ...toPromptEnvelope(promptInput, outDir), activityActor: actor }, actor);
817
1254
  return {
818
1255
  ...result,
819
1256
  transcript: transcriptParts.join("\n\n") || undefined,
820
1257
  files: uploadFileDtos(stagedFiles),
821
1258
  };
822
1259
  }
823
- async sendEnvelope(envelope) {
1260
+ async sendEnvelope(envelope, actor) {
1261
+ const activityActor = envelope.activityActor ?? actor;
824
1262
  const session = await this.getSession(false);
825
1263
  const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
826
1264
  if (session.isProcessing() || external?.activity.active) {
@@ -833,6 +1271,7 @@ export class RelayRuntime {
833
1271
  threadId: info.threadId,
834
1272
  workspace: info.workspace,
835
1273
  agentId: info.agentId,
1274
+ actor: activityActor,
836
1275
  prompt: envelope.description,
837
1276
  detail: external?.activity.active
838
1277
  ? `Queued because ${external.agentLabel} CLI is still processing another task.`
@@ -841,11 +1280,12 @@ export class RelayRuntime {
841
1280
  this.appendAudit({
842
1281
  action: "prompt_queued",
843
1282
  status: "ok",
844
- contextKey: WEB_CONTEXT_KEY,
1283
+ contextKey: this.contextKey,
845
1284
  agentId: info.agentId,
846
1285
  threadId: info.threadId,
847
1286
  workspace: info.workspace,
848
1287
  promptId: queued.id,
1288
+ actor: activityActor,
849
1289
  description: envelope.description,
850
1290
  });
851
1291
  if (external?.activity.active) {
@@ -854,7 +1294,7 @@ export class RelayRuntime {
854
1294
  this.broadcastQueue();
855
1295
  return { queued: true, queueId: queued.id };
856
1296
  }
857
- void this.runPrompt(session, envelope).catch((error) => {
1297
+ void this.runPrompt(session, { ...envelope, activityActor }).catch((error) => {
858
1298
  this.broadcast({ type: "turn_error", id: this.currentTurnId ?? "turn", error: friendlyErrorText(error), at: new Date().toISOString() });
859
1299
  });
860
1300
  return { queued: false };
@@ -865,7 +1305,9 @@ export class RelayRuntime {
865
1305
  queuePaused() {
866
1306
  return this.queueService.isPaused();
867
1307
  }
868
- queueAction(action, id) {
1308
+ queueAction(action, id, actor) {
1309
+ const before = this.queueService.rawList();
1310
+ const affected = id ? before.find((item) => item.id === id) : undefined;
869
1311
  this.queueService.apply(action, id);
870
1312
  if (id && action === "run") {
871
1313
  void this.drainQueue().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
@@ -873,15 +1315,18 @@ export class RelayRuntime {
873
1315
  this.appendActivity({
874
1316
  source: "web",
875
1317
  status: "info",
876
- type: "queue_updated",
1318
+ type: `queue_${action}`,
877
1319
  threadId: null,
878
1320
  workspace: this.config.workspace,
879
- detail: id ? `${action}: ${id}` : action,
1321
+ actor,
1322
+ prompt: affected?.description,
1323
+ detail: id ? `${action}: ${id}` : `${action}: ${before.length} queued`,
880
1324
  });
881
1325
  this.appendAudit({
882
1326
  action: "queue_updated",
883
1327
  status: "ok",
884
- contextKey: WEB_CONTEXT_KEY,
1328
+ contextKey: this.contextKey,
1329
+ actor,
885
1330
  description: id ? `${action}: ${id}` : action,
886
1331
  });
887
1332
  this.broadcastQueue();
@@ -895,13 +1340,39 @@ export class RelayRuntime {
895
1340
  const session = await this.getSession(true);
896
1341
  return this.artifactService.get(session.getInfo().workspace, turnId);
897
1342
  }
898
- async deleteArtifact(turnId) {
1343
+ async deleteArtifact(turnId, actor) {
899
1344
  const session = await this.getSession(true);
900
- return this.artifactService.delete(session.getInfo().workspace, turnId);
1345
+ const info = this.publicInfo(session);
1346
+ const removed = await this.artifactService.delete(info.workspace, turnId);
1347
+ this.appendActivity({
1348
+ source: "web",
1349
+ status: removed ? "info" : "failed",
1350
+ type: "artifact_deleted",
1351
+ threadId: info.threadId,
1352
+ workspace: info.workspace,
1353
+ agentId: info.agentId,
1354
+ actor,
1355
+ detail: turnId,
1356
+ });
1357
+ return removed;
901
1358
  }
902
- async createArtifactZip(turnId) {
1359
+ async createArtifactZip(turnId, actor) {
903
1360
  const session = await this.getSession(true);
904
- return this.artifactService.createZip(session.getInfo().workspace, turnId);
1361
+ const info = this.publicInfo(session);
1362
+ const zip = await this.artifactService.createZip(info.workspace, turnId);
1363
+ if (zip) {
1364
+ this.appendActivity({
1365
+ source: "web",
1366
+ status: "info",
1367
+ type: "artifact_zip_created",
1368
+ threadId: info.threadId,
1369
+ workspace: info.workspace,
1370
+ agentId: info.agentId,
1371
+ actor,
1372
+ detail: zip.name,
1373
+ });
1374
+ }
1375
+ return zip;
905
1376
  }
906
1377
  async artifactPreview(turnId, relativePath) {
907
1378
  const session = await this.getSession(true);
@@ -916,7 +1387,7 @@ export class RelayRuntime {
916
1387
  }
917
1388
  return readFormattedLogTail(lines);
918
1389
  }
919
- clearLogs(target = "connector") {
1390
+ clearLogs(target = "connector", actor) {
920
1391
  const result = clearLogFile(target === "update" ? getUpdateLogPath() : target === "agent-updates" ? getAgentUpdateLogPath() : getConnectorLogPath());
921
1392
  this.appendActivity({
922
1393
  source: "web",
@@ -924,11 +1395,20 @@ export class RelayRuntime {
924
1395
  type: "logs_cleared",
925
1396
  threadId: null,
926
1397
  workspace: this.config.workspace,
1398
+ actor,
927
1399
  detail: `Cleared ${target} log.`,
928
1400
  });
1401
+ this.appendAudit({
1402
+ action: "command",
1403
+ status: "ok",
1404
+ contextKey: this.contextKey,
1405
+ actor,
1406
+ description: `clear ${target} log`,
1407
+ detail: result.filePath,
1408
+ });
929
1409
  return { ok: true, filePath: result.filePath, clearedAt: result.clearedAt.toISOString() };
930
1410
  }
931
- restartConnector() {
1411
+ restartConnector(actor) {
932
1412
  spawnConnectorRestart();
933
1413
  this.broadcastStatus("Restart requested. The dashboard may disconnect briefly.", "warn");
934
1414
  this.appendActivity({
@@ -937,8 +1417,16 @@ export class RelayRuntime {
937
1417
  type: "restart_requested",
938
1418
  threadId: null,
939
1419
  workspace: this.config.workspace,
1420
+ actor,
940
1421
  detail: "Dashboard requested a connector restart.",
941
1422
  });
1423
+ this.appendAudit({
1424
+ action: "command",
1425
+ status: "ok",
1426
+ contextKey: this.contextKey,
1427
+ actor,
1428
+ description: "restart connector",
1429
+ });
942
1430
  return { ok: true, message: "Restart requested." };
943
1431
  }
944
1432
  dispose() {
@@ -950,7 +1438,215 @@ export class RelayRuntime {
950
1438
  this.subscribers.clear();
951
1439
  }
952
1440
  async getSession(deferThreadStart) {
953
- return this.registry.getOrCreate(WEB_CONTEXT_KEY, { deferThreadStart });
1441
+ return this.registry.getOrCreate(this.contextKey, { deferThreadStart });
1442
+ }
1443
+ async cached(key, producer) {
1444
+ return (await this.cache.get(key, this.config.dashboardCacheTtlMs, producer)).value;
1445
+ }
1446
+ listKnownContextMetadata() {
1447
+ const contexts = new Map();
1448
+ const add = (meta) => {
1449
+ if (meta?.contextKey) {
1450
+ contexts.set(meta.contextKey, meta);
1451
+ }
1452
+ };
1453
+ for (const meta of this.registry.listContexts()) {
1454
+ add(meta);
1455
+ }
1456
+ const sharedRegistry = new SessionRegistry(this.config);
1457
+ try {
1458
+ for (const meta of sharedRegistry.listContexts()) {
1459
+ add(meta);
1460
+ }
1461
+ }
1462
+ finally {
1463
+ sharedRegistry.disposeAll();
1464
+ }
1465
+ const current = this.registry.get(this.contextKey)?.getInfo();
1466
+ if (current) {
1467
+ add({
1468
+ contextKey: this.contextKey,
1469
+ agentId: current.agentId,
1470
+ threadId: current.threadId,
1471
+ workspace: current.workspace,
1472
+ model: current.model,
1473
+ reasoningEffort: current.reasoningEffort,
1474
+ launchProfileId: current.nextLaunchProfileId ?? current.launchProfileId,
1475
+ sessionPath: current.sessionPath,
1476
+ updatedAt: Date.now(),
1477
+ });
1478
+ }
1479
+ return [...contexts.values()];
1480
+ }
1481
+ discoverRunningConnectorSessions() {
1482
+ const active = [];
1483
+ const terminal = new Set();
1484
+ const now = Date.now();
1485
+ for (const event of this.activityStore.list({ limit: 500 })) {
1486
+ if (!event.threadId || !event.agentId || !event.contextKey) {
1487
+ continue;
1488
+ }
1489
+ const key = `${event.source}:${event.contextKey}:${event.agentId}:${event.threadId}`;
1490
+ if (isPromptTerminalActivity(event)) {
1491
+ terminal.add(key);
1492
+ continue;
1493
+ }
1494
+ if (event.type !== "prompt_started" || event.status !== "running" || event.source === "cli") {
1495
+ continue;
1496
+ }
1497
+ if (terminal.has(key)) {
1498
+ continue;
1499
+ }
1500
+ const startedMs = Date.parse(event.timestamp);
1501
+ if (!Number.isFinite(startedMs) || now - startedMs > ACTIVE_ACTIVITY_TTL_MS) {
1502
+ continue;
1503
+ }
1504
+ active.push({
1505
+ id: `${event.contextKey}:${event.id}`,
1506
+ contextKey: event.contextKey,
1507
+ sourceContextKey: event.contextKey,
1508
+ source: event.source,
1509
+ status: "running",
1510
+ agentId: event.agentId,
1511
+ agentLabel: event.agentId ? agentLabel(event.agentId) : undefined,
1512
+ threadId: event.threadId,
1513
+ workspace: event.workspace,
1514
+ prompt: event.prompt,
1515
+ startedAt: event.timestamp,
1516
+ updatedAt: event.timestamp,
1517
+ durationMs: Math.max(0, now - startedMs),
1518
+ queueLength: this.promptStore.list(event.contextKey).length,
1519
+ queuePaused: this.promptStore.isPaused(event.contextKey),
1520
+ detail: event.actor?.label ? `Started by ${event.actor.label}` : undefined,
1521
+ });
1522
+ }
1523
+ return active;
1524
+ }
1525
+ discoverActiveCodexSessions(knownContexts, preferences) {
1526
+ if (!this.config.codexEnabled || !enabledAgents(this.config).includes("codex")) {
1527
+ return [];
1528
+ }
1529
+ const capabilities = this.capabilitiesForAgent("codex");
1530
+ if (!capabilities.externalActivity) {
1531
+ return [];
1532
+ }
1533
+ const active = [];
1534
+ const nowMs = Date.now();
1535
+ const staleAfterMs = this.config.codexExternalBusyStaleMs;
1536
+ for (const thread of listCodexThreads(ACTIVE_CODEX_DISCOVERY_LIMIT)) {
1537
+ if (staleAfterMs > 0 && nowMs - thread.updatedAt.getTime() > staleAfterMs) {
1538
+ continue;
1539
+ }
1540
+ const meta = {
1541
+ contextKey: `cli:codex:${thread.id}`,
1542
+ agentId: "codex",
1543
+ threadId: thread.id,
1544
+ workspace: thread.cwd,
1545
+ model: thread.model ?? undefined,
1546
+ reasoningEffort: thread.reasoningEffort ?? undefined,
1547
+ updatedAt: thread.updatedAt.getTime(),
1548
+ };
1549
+ const session = this.externalActiveSession(meta, knownContexts, preferences);
1550
+ if (session) {
1551
+ active.push(session);
1552
+ }
1553
+ }
1554
+ return active;
1555
+ }
1556
+ externalActiveSession(meta, knownContexts, preferences) {
1557
+ if (!meta.threadId) {
1558
+ return null;
1559
+ }
1560
+ const agentId = isAgentId(meta.agentId) ? meta.agentId : this.config.defaultAgent;
1561
+ if (!enabledAgents(this.config).includes(agentId)) {
1562
+ return null;
1563
+ }
1564
+ const capabilities = this.capabilitiesForAgent(agentId);
1565
+ if (!capabilities.externalActivity) {
1566
+ return null;
1567
+ }
1568
+ if (agentId === "codex" &&
1569
+ meta.updatedAt &&
1570
+ this.config.codexExternalBusyStaleMs > 0 &&
1571
+ Date.now() - meta.updatedAt > this.config.codexExternalBusyStaleMs) {
1572
+ return null;
1573
+ }
1574
+ const snapshot = getExternalSnapshotForSession(this.sessionStubForMetadata(meta, agentId, capabilities), this.config, {
1575
+ maxEvents: 8,
1576
+ });
1577
+ if (!snapshot?.activity.active) {
1578
+ return null;
1579
+ }
1580
+ const startedAt = snapshot.activity.startedAt?.toISOString() ?? new Date().toISOString();
1581
+ const updatedAt = snapshot.activity.updatedAt?.toISOString() ?? new Date().toISOString();
1582
+ const startedMs = Date.parse(startedAt);
1583
+ const sourceContextKey = `cli:${snapshot.agentId}:${snapshot.threadId}`;
1584
+ const mirrorChannels = this.mirrorRegistry.activeMirrorsForThread(snapshot.agentId, snapshot.threadId, knownContexts, preferences);
1585
+ const queueLength = this.mirrorRegistry.queueLengthForExternalSource(sourceContextKey, mirrorChannels);
1586
+ const mirrorDetail = mirrorChannels.length > 0
1587
+ ? `Mirroring: ${mirrorChannels.map((mirror) => `${mirror.source} ${mirror.mode}`).join(", ")}`
1588
+ : "Mirroring: none";
1589
+ return {
1590
+ id: `${sourceContextKey}:${snapshot.activity.turnId ?? snapshot.threadId}`,
1591
+ contextKey: sourceContextKey,
1592
+ sourceContextKey,
1593
+ source: "cli",
1594
+ status: "external",
1595
+ agentId: snapshot.agentId,
1596
+ agentLabel: snapshot.agentLabel,
1597
+ threadId: snapshot.threadId,
1598
+ workspace: meta.workspace,
1599
+ prompt: snapshot.latestUserMessage ?? undefined,
1600
+ currentTool: snapshot.latestToolName ?? undefined,
1601
+ lastTool: snapshot.latestToolName ?? undefined,
1602
+ startedAt,
1603
+ updatedAt,
1604
+ durationMs: Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : 0,
1605
+ queueLength,
1606
+ queuePaused: this.mirrorRegistry.queuePausedForExternalSource(sourceContextKey, mirrorChannels),
1607
+ mirrorChannels,
1608
+ detail: `${mirrorDetail} | ${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
1609
+ };
1610
+ }
1611
+ sessionStubForMetadata(meta, agentId, capabilities) {
1612
+ const info = {
1613
+ agentId,
1614
+ agentLabel: agentLabel(agentId),
1615
+ threadId: meta.threadId,
1616
+ workspace: meta.workspace,
1617
+ model: meta.model,
1618
+ reasoningEffort: meta.reasoningEffort,
1619
+ launchProfileId: meta.launchProfileId ?? this.config.defaultLaunchProfileId,
1620
+ launchProfileLabel: meta.launchProfileId ?? this.config.defaultLaunchProfileId,
1621
+ launchProfileBehavior: "-",
1622
+ sandboxMode: "-",
1623
+ approvalPolicy: "-",
1624
+ fastMode: false,
1625
+ unsafeLaunch: false,
1626
+ sessionPath: meta.sessionPath,
1627
+ capabilities,
1628
+ };
1629
+ return {
1630
+ getInfo: () => info,
1631
+ getActiveThreadId: () => meta.threadId,
1632
+ };
1633
+ }
1634
+ capabilitiesForAgent(agentId) {
1635
+ return listAgentAdapterDescriptors().find((descriptor) => descriptor.id === agentId)?.capabilities ?? CODEX_AGENT_CAPABILITIES;
1636
+ }
1637
+ activeSessionKey(session) {
1638
+ return session.threadId ? `${session.agentId ?? "unknown"}:${session.threadId}` : session.id;
1639
+ }
1640
+ preferredActiveSession(existing, candidate) {
1641
+ if (!existing) {
1642
+ return candidate;
1643
+ }
1644
+ const existingPriority = activeSessionPriority(existing);
1645
+ const candidatePriority = activeSessionPriority(candidate);
1646
+ if (candidatePriority !== existingPriority) {
1647
+ return candidatePriority > existingPriority ? candidate : existing;
1648
+ }
1649
+ return Date.parse(candidate.updatedAt) >= Date.parse(existing.updatedAt) ? candidate : existing;
954
1650
  }
955
1651
  async getControlSession(agentId) {
956
1652
  const active = await this.getSession(true);
@@ -1039,169 +1735,14 @@ export class RelayRuntime {
1039
1735
  }
1040
1736
  }
1041
1737
  async runPrompt(session, envelope) {
1042
- await this.ensureActiveThread(session);
1043
- const info = session.getInfo();
1044
- if ((info.capabilities ?? CODEX_AGENT_CAPABILITIES).auth) {
1045
- const auth = await this.checkAgentAuth(info);
1046
- if (!auth.authenticated) {
1047
- throw new Error(`${agentLabel(info.agentId)} is not authenticated: ${auth.detail}`);
1048
- }
1049
- }
1050
1738
  const workspacePolicy = evaluateWorkspacePolicy(session.getInfo().workspace, this.config);
1051
1739
  if (!workspacePolicy.allowed) {
1052
1740
  throw new Error(workspacePolicy.warning ?? "Current workspace is blocked by policy.");
1053
1741
  }
1054
- const turnId = randomUUID().slice(0, 12);
1055
- this.currentTurnId = turnId;
1056
- this.currentTurnStartedAt = Date.now();
1057
- this.accumulatedText = "";
1058
- this.currentProgress = {
1059
- id: turnId,
1060
- source: "web",
1061
- status: "running",
1062
- prompt: envelope.description,
1063
- agentId: info.agentId,
1064
- agentLabel: info.agentLabel,
1065
- threadId: info.threadId,
1066
- workspace: info.workspace,
1067
- startedAt: new Date(this.currentTurnStartedAt).toISOString(),
1068
- updatedAt: new Date(this.currentTurnStartedAt).toISOString(),
1069
- durationMs: 0,
1070
- outputChars: 0,
1071
- tools: [],
1072
- };
1073
- this.queueService.setLastPrompt(envelope);
1074
- const startedDate = new Date();
1075
- const startedAt = startedDate.toISOString();
1076
- this.chatStore.append({
1077
- threadId: info.threadId ?? "pending",
1078
- role: "user",
1079
- text: envelope.description,
1080
- source: "web",
1081
- turnId,
1082
- timestamp: startedAt,
1083
- });
1084
- this.appendActivity({
1085
- source: "web",
1086
- status: "running",
1087
- type: "prompt_started",
1088
- threadId: info.threadId,
1089
- workspace: info.workspace,
1090
- agentId: info.agentId,
1091
- prompt: envelope.description,
1092
- });
1093
- this.appendAudit({
1094
- action: "prompt_started",
1095
- status: "ok",
1096
- contextKey: WEB_CONTEXT_KEY,
1097
- agentId: info.agentId,
1098
- threadId: info.threadId,
1099
- workspace: info.workspace,
1100
- description: envelope.description,
1101
- });
1102
- this.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: startedAt, source: "web" });
1103
- const callbacks = {
1104
- onTextDelta: (delta) => {
1105
- this.accumulatedText += delta;
1106
- this.updateCurrentProgress({ outputChars: this.accumulatedText.length });
1107
- this.broadcast({ type: "text_delta", id: turnId, delta });
1108
- },
1109
- onToolStart: (toolName, toolCallId) => {
1110
- this.addCurrentTool(toolName);
1111
- this.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName });
1112
- },
1113
- onToolUpdate: (toolCallId, partialResult) => {
1114
- this.updateCurrentProgress();
1115
- this.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult });
1116
- },
1117
- onToolEnd: (toolCallId, isError) => {
1118
- this.updateCurrentProgress({ currentTool: undefined });
1119
- this.broadcast({ type: "tool_end", id: turnId, toolCallId, isError });
1120
- },
1121
- onTodoUpdate: (items) => {
1122
- this.updateCurrentProgress({ detail: `Plan: ${items.filter((item) => item.completed).length}/${items.length} done` });
1123
- this.broadcast({ type: "todo_update", id: turnId, items });
1124
- },
1125
- onTurnComplete: () => { },
1126
- onAgentEnd: () => this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() }),
1127
- };
1128
1742
  try {
1129
- await session.prompt(envelope.input, callbacks);
1130
- this.updateSession(session);
1131
- await this.artifactService.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, startedDate);
1132
- if (this.accumulatedText.trim()) {
1133
- this.chatStore.append({
1134
- threadId: info.threadId ?? "pending",
1135
- role: "agent",
1136
- text: this.accumulatedText,
1137
- source: "web",
1138
- turnId,
1139
- });
1140
- }
1141
- this.appendActivity({
1142
- source: "web",
1143
- status: "completed",
1144
- type: "prompt_completed",
1145
- threadId: info.threadId,
1146
- workspace: info.workspace,
1147
- agentId: info.agentId,
1148
- prompt: envelope.description,
1149
- durationMs: Date.now() - this.currentTurnStartedAt,
1150
- });
1151
- this.appendAudit({
1152
- action: "prompt_completed",
1153
- status: "ok",
1154
- contextKey: WEB_CONTEXT_KEY,
1155
- agentId: info.agentId,
1156
- threadId: info.threadId,
1157
- workspace: info.workspace,
1158
- description: envelope.description,
1159
- });
1160
- this.updateCurrentProgress({ status: "completed" });
1161
- this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() });
1162
- this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
1163
- }
1164
- catch (error) {
1165
- const errorText = friendlyErrorText(error);
1166
- this.chatStore.append({
1167
- threadId: info.threadId ?? "pending",
1168
- role: "system",
1169
- text: `Error: ${errorText}`,
1170
- source: "web",
1171
- turnId,
1172
- });
1173
- this.appendActivity({
1174
- source: "web",
1175
- status: "failed",
1176
- type: "prompt_failed",
1177
- threadId: info.threadId,
1178
- workspace: info.workspace,
1179
- agentId: info.agentId,
1180
- prompt: envelope.description,
1181
- detail: errorText,
1182
- durationMs: Date.now() - this.currentTurnStartedAt,
1183
- });
1184
- this.appendAudit({
1185
- action: "prompt_failed",
1186
- status: "failed",
1187
- contextKey: WEB_CONTEXT_KEY,
1188
- agentId: info.agentId,
1189
- threadId: info.threadId,
1190
- workspace: info.workspace,
1191
- description: envelope.description,
1192
- detail: errorText,
1193
- });
1194
- this.updateCurrentProgress({ status: "failed", detail: errorText });
1195
- this.broadcast({ type: "turn_error", id: turnId, error: errorText, at: new Date().toISOString() });
1196
- this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
1197
- throw error;
1743
+ await this.turnService.run(session, envelope);
1198
1744
  }
1199
1745
  finally {
1200
- this.currentTurnId = null;
1201
- if (this.currentProgress) {
1202
- this.currentProgress.durationMs = Date.now() - this.currentTurnStartedAt;
1203
- this.currentProgress.updatedAt = new Date().toISOString();
1204
- }
1205
1746
  await this.drainQueue();
1206
1747
  }
1207
1748
  }
@@ -1231,9 +1772,43 @@ export class RelayRuntime {
1231
1772
  }
1232
1773
  }
1233
1774
  updateSession(session) {
1234
- this.registry.updateMetadata(WEB_CONTEXT_KEY, session);
1775
+ this.registry.updateMetadata(this.contextKey, session);
1235
1776
  this.broadcast({ type: "session_update", session: this.publicInfo(session) });
1236
1777
  }
1778
+ recordActivity(input) {
1779
+ return this.appendActivity(input);
1780
+ }
1781
+ recordAgentUpdateLifecycle(job) {
1782
+ const previous = this.agentUpdateStates.get(job.id);
1783
+ const actor = this.agentUpdateActors.get(job.id);
1784
+ if (job.needsInput && !previous?.needsInput) {
1785
+ this.appendActivity({
1786
+ source: "web",
1787
+ status: "info",
1788
+ type: "agent_update_input_required",
1789
+ agentId: job.agentId,
1790
+ threadId: null,
1791
+ workspace: this.config.workspace,
1792
+ actor,
1793
+ detail: `${job.agentLabel} ${job.operation} may require input.`,
1794
+ });
1795
+ }
1796
+ if (job.status !== "running" && previous?.status === "running") {
1797
+ this.appendActivity({
1798
+ source: "web",
1799
+ status: job.status === "completed" ? "completed" : job.status === "cancelled" ? "aborted" : "failed",
1800
+ type: job.operation === "install" ? `agent_install_${job.status}` : `agent_update_${job.status}`,
1801
+ agentId: job.agentId,
1802
+ threadId: null,
1803
+ workspace: this.config.workspace,
1804
+ actor,
1805
+ detail: job.error ?? `${job.agentLabel} ${job.operation} ${job.status}.`,
1806
+ durationMs: Math.max(0, Date.parse(job.finishedAt ?? job.updatedAt) - Date.parse(job.startedAt)),
1807
+ });
1808
+ this.agentUpdateActors.delete(job.id);
1809
+ }
1810
+ this.agentUpdateStates.set(job.id, { status: job.status, needsInput: job.needsInput });
1811
+ }
1237
1812
  appendActivity(input) {
1238
1813
  const event = this.activityStore.append(this.enrichActivityInput(input));
1239
1814
  this.broadcast({ type: "activity_update", events: this.activity({ limit: 50 }) });
@@ -1303,6 +1878,23 @@ export class RelayRuntime {
1303
1878
  this.subscribers.delete(subscriber);
1304
1879
  }
1305
1880
  }
1881
+ if (shouldRefreshActiveSessions(event)) {
1882
+ this.scheduleActiveSessionsBroadcast();
1883
+ }
1884
+ }
1885
+ scheduleActiveSessionsBroadcast() {
1886
+ if (this.activeSessionsBroadcastTimer) {
1887
+ return;
1888
+ }
1889
+ const delayMs = Math.max(0, 1_000 - (Date.now() - this.activeSessionsLastBroadcastAt));
1890
+ this.activeSessionsBroadcastTimer = setTimeout(() => {
1891
+ this.activeSessionsBroadcastTimer = null;
1892
+ this.activeSessionsLastBroadcastAt = Date.now();
1893
+ void this.activeSessions()
1894
+ .then((active) => this.broadcast({ type: "active_sessions_update", active }))
1895
+ .catch(() => { });
1896
+ }, delayMs);
1897
+ this.activeSessionsBroadcastTimer.unref?.();
1306
1898
  }
1307
1899
  publicInfo(session) {
1308
1900
  const info = session.getInfo();
@@ -1315,92 +1907,3 @@ export class RelayRuntime {
1315
1907
  };
1316
1908
  }
1317
1909
  }
1318
- function cliHealthForAgent(agentId, health) {
1319
- if (agentId === "pi") {
1320
- return { path: health.piCliPath, label: health.piCli, version: health.piCliVersion };
1321
- }
1322
- if (agentId === "hermes") {
1323
- return { path: health.hermesCliPath, label: health.hermesCli, version: health.hermesCliVersion };
1324
- }
1325
- if (agentId === "openclaw") {
1326
- return { path: health.openClawCliPath, label: health.openClawCli, version: health.openClawCliVersion };
1327
- }
1328
- if (agentId === "claude-code") {
1329
- return { path: health.claudeCodeCliPath, label: health.claudeCodeCli, version: health.claudeCodeCliVersion };
1330
- }
1331
- return { path: health.codexCliPath, label: health.codexCli, version: health.codexCliVersion };
1332
- }
1333
- function versionCheckForAgent(agentId, versions) {
1334
- if (agentId === "pi")
1335
- return versions.pi;
1336
- if (agentId === "hermes")
1337
- return versions.hermes;
1338
- if (agentId === "openclaw")
1339
- return versions.openclaw;
1340
- if (agentId === "claude-code")
1341
- return versions.claudeCode;
1342
- return versions.codex;
1343
- }
1344
- function hostLoginCommand(info, config) {
1345
- if (info.agentId === "hermes") {
1346
- return `${config.hermesCliPath ?? "hermes"} login --no-browser`;
1347
- }
1348
- if (info.agentId === "claude-code") {
1349
- return `${config.claudeCodeCliPath ?? "claude"} auth login`;
1350
- }
1351
- if (info.agentId === "pi") {
1352
- return `${config.piCliPath ?? "pi"} auth login`;
1353
- }
1354
- if (info.agentId === "openclaw") {
1355
- return `${config.openClawCliPath ?? "openclaw"} login`;
1356
- }
1357
- return "codex login --device-auth";
1358
- }
1359
- function hostLogoutCommand(info, config) {
1360
- if (info.agentId === "hermes") {
1361
- return `${config.hermesCliPath ?? "hermes"} logout`;
1362
- }
1363
- if (info.agentId === "claude-code") {
1364
- return `${config.claudeCodeCliPath ?? "claude"} auth logout`;
1365
- }
1366
- if (info.agentId === "pi") {
1367
- return `${config.piCliPath ?? "pi"} auth logout`;
1368
- }
1369
- if (info.agentId === "openclaw") {
1370
- return `${config.openClawCliPath ?? "openclaw"} logout`;
1371
- }
1372
- return "codex logout";
1373
- }
1374
- function normalizeMimeType(value, name) {
1375
- const configured = value?.trim();
1376
- if (configured) {
1377
- return configured;
1378
- }
1379
- const extension = path.extname(name).toLowerCase();
1380
- if ([".jpg", ".jpeg"].includes(extension))
1381
- return "image/jpeg";
1382
- if (extension === ".png")
1383
- return "image/png";
1384
- if (extension === ".gif")
1385
- return "image/gif";
1386
- if (extension === ".webp")
1387
- return "image/webp";
1388
- if (extension === ".mp3")
1389
- return "audio/mpeg";
1390
- if (extension === ".wav")
1391
- return "audio/wav";
1392
- if (extension === ".ogg" || extension === ".oga")
1393
- return "audio/ogg";
1394
- if (extension === ".m4a")
1395
- return "audio/mp4";
1396
- if (extension === ".webm")
1397
- return "audio/webm";
1398
- return "application/octet-stream";
1399
- }
1400
- function uploadFileDtos(files) {
1401
- return files.map((file) => ({
1402
- name: file.safeName,
1403
- mimeType: file.mimeType,
1404
- sizeBytes: file.sizeBytes,
1405
- }));
1406
- }