@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
@@ -2,31 +2,40 @@ import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { ensureOutDir } from "./artifacts.js";
4
4
  import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
5
- import { CODEX_AGENT_CAPABILITIES, agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
5
+ import { CODEX_AGENT_CAPABILITIES, agentLabel, agentReasoningLabel, agentReasoningOptions, isAgentId, } from "./agent.js";
6
6
  import { getAgentDiagnostics, getExternalSnapshotForSession, } from "./agent-activity.js";
7
7
  import { listAgentAdapterDescriptors } from "./agent-adapter.js";
8
8
  import { AgentUpdateManager } from "./agent-updates.js";
9
9
  import { createAgentSessionService, enabledAgents } from "./agent-factory.js";
10
10
  import { AuditLogStore } from "./audit-log.js";
11
+ import { BotPreferencesStore } from "./bot-preferences.js";
12
+ import { ChannelTurnService } from "./channel-turn-service.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";
22
28
  import { renderSessionInfoPlain, renderSessionUsageRows } from "./session-format.js";
23
29
  import { SessionLockStore } from "./session-locks.js";
24
30
  import { SessionRegistry } from "./session-registry.js";
25
31
  import { createSupportBundle } from "./support-bundle.js";
26
32
  import { transcribeAudio } from "./voice.js";
27
33
  import { WebActivityStore, WebChatStore, } from "./web-state.js";
34
+ import { channelIdForContextKey } from "./context-key.js";
28
35
  import { evaluateWorkspacePolicy, filterAllowedWorkspaces } from "./workspace-policy.js";
29
36
  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 {
@@ -39,9 +48,14 @@ export class RelayRuntime {
39
48
  lockStore;
40
49
  agentUpdates;
41
50
  queueService;
51
+ jobStore;
42
52
  artifactService;
43
53
  externalActivityMonitor;
54
+ cache = new RuntimeSnapshotCache();
55
+ turnService;
44
56
  subscribers = new Set();
57
+ agentUpdateActors = new Map();
58
+ agentUpdateStates = new Map();
45
59
  externalMonitor;
46
60
  draining = false;
47
61
  currentTurnId = null;
@@ -60,9 +74,13 @@ export class RelayRuntime {
60
74
  this.auditStore = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
61
75
  this.lockStore = new SessionLockStore(config.workspace, config.stateBackend);
62
76
  this.queueService = new RelayQueueService(this.promptStore, WEB_CONTEXT_KEY);
77
+ this.jobStore = new UnifiedJobStore(config.workspace, config.stateBackend, config.unifiedJobMaxItems);
63
78
  this.artifactService = new RelayArtifactService(config);
64
79
  this.agentUpdates = new AgentUpdateManager({
65
- onUpdate: (job) => this.broadcast({ type: "agent_update", job }),
80
+ onUpdate: (job) => {
81
+ this.broadcast({ type: "agent_update", job });
82
+ this.recordAgentUpdateLifecycle(job);
83
+ },
66
84
  });
67
85
  this.externalActivityMonitor = new RelayExternalActivityMonitor({
68
86
  config,
@@ -83,6 +101,36 @@ export class RelayRuntime {
83
101
  }, config.codexExternalBusyCheckMs);
84
102
  this.externalMonitor.unref?.();
85
103
  }
104
+ this.turnService = new ChannelTurnService({
105
+ source: "web",
106
+ contextKey: WEB_CONTEXT_KEY,
107
+ chatStore: this.chatStore,
108
+ artifactService: this.artifactService,
109
+ checkAuth: (info) => this.checkAgentAuth(info),
110
+ ensureActiveThread: (session) => this.ensureActiveThread(session),
111
+ updateSession: (session) => this.updateSession(session),
112
+ appendActivity: (input) => this.appendActivity(input),
113
+ appendAudit: (input) => this.appendAudit(input),
114
+ broadcast: (event) => this.broadcast(event),
115
+ chatHistory: () => this.chatHistory(),
116
+ setLastPrompt: (envelope) => this.queueService.setLastPrompt(envelope),
117
+ getCurrentProgress: () => this.currentProgress,
118
+ setCurrentProgress: (progress) => {
119
+ this.currentProgress = progress;
120
+ },
121
+ setCurrentTurn: (id, startedAt, accumulatedText) => {
122
+ this.currentTurnId = id;
123
+ if (startedAt !== undefined)
124
+ this.currentTurnStartedAt = startedAt;
125
+ if (accumulatedText !== undefined)
126
+ this.accumulatedText = accumulatedText;
127
+ },
128
+ getCurrentTurnStartedAt: () => this.currentTurnStartedAt,
129
+ getAccumulatedText: () => this.accumulatedText,
130
+ setAccumulatedText: (text) => {
131
+ this.accumulatedText = text;
132
+ },
133
+ });
86
134
  }
87
135
  subscribe(callback) {
88
136
  this.subscribers.add(callback);
@@ -105,10 +153,16 @@ export class RelayRuntime {
105
153
  };
106
154
  }
107
155
  async status() {
156
+ const cliOptions = this.cliPathOptions();
157
+ const [health, versionChecks, snapshot] = await Promise.all([
158
+ getConnectorHealth(cliOptions),
159
+ getVersionChecks(cliOptions),
160
+ this.snapshot(),
161
+ ]);
108
162
  return {
109
- health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
110
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
111
- snapshot: await this.snapshot(),
163
+ health,
164
+ versionChecks,
165
+ snapshot,
112
166
  };
113
167
  }
114
168
  async bootstrapStatus() {
@@ -121,13 +175,22 @@ export class RelayRuntime {
121
175
  };
122
176
  }
123
177
  async version() {
124
- return {
125
- health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
126
- state: await readConnectorState(),
127
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
128
- };
178
+ return this.cached("version", async () => {
179
+ const cliOptions = this.cliPathOptions();
180
+ const [health, state, versionChecks] = await Promise.all([
181
+ getConnectorHealth(cliOptions),
182
+ readConnectorState(),
183
+ getVersionChecks(cliOptions),
184
+ ]);
185
+ return {
186
+ health,
187
+ state,
188
+ versionChecks,
189
+ };
190
+ });
129
191
  }
130
- updateConnector() {
192
+ updateConnector(actor) {
193
+ this.cache.invalidate("version");
131
194
  const update = spawnSelfUpdate();
132
195
  this.broadcastStatus(`Update started with ${update.method}. Log: ${update.logPath}`, "warn");
133
196
  this.appendActivity({
@@ -136,12 +199,14 @@ export class RelayRuntime {
136
199
  type: "update_started",
137
200
  threadId: null,
138
201
  workspace: this.config.workspace,
202
+ actor,
139
203
  detail: `${update.method}: ${update.summary}`,
140
204
  });
141
205
  this.appendAudit({
142
206
  action: "command",
143
207
  status: "ok",
144
208
  contextKey: WEB_CONTEXT_KEY,
209
+ actor,
145
210
  description: "update",
146
211
  detail: update.summary,
147
212
  });
@@ -150,13 +215,19 @@ export class RelayRuntime {
150
215
  agentUpdateJobs() {
151
216
  return this.agentUpdates.list();
152
217
  }
153
- startAgentUpdate(agentId, operation = "update") {
218
+ startAgentUpdate(agentId, operation = "update", actor) {
219
+ this.cache.invalidate("adapterHealth");
220
+ this.cache.invalidate("version");
154
221
  const job = this.agentUpdates.start(agentId, {
155
222
  piCliPath: this.config.piCliPath,
156
223
  hermesCliPath: this.config.hermesCliPath,
157
224
  openClawCliPath: this.config.openClawCliPath,
158
225
  claudeCodeCliPath: this.config.claudeCodeCliPath,
159
226
  }, operation);
227
+ if (actor) {
228
+ this.agentUpdateActors.set(job.id, actor);
229
+ }
230
+ this.agentUpdateStates.set(job.id, { status: job.status, needsInput: job.needsInput });
160
231
  this.broadcastStatus(`${job.agentLabel} ${operation} started. Log: ${job.logPath}`, "warn");
161
232
  this.appendActivity({
162
233
  source: "web",
@@ -165,6 +236,7 @@ export class RelayRuntime {
165
236
  agentId,
166
237
  threadId: null,
167
238
  workspace: this.config.workspace,
239
+ actor,
168
240
  detail: `${job.method}: ${job.summary}`,
169
241
  });
170
242
  this.appendAudit({
@@ -172,6 +244,7 @@ export class RelayRuntime {
172
244
  status: "ok",
173
245
  contextKey: WEB_CONTEXT_KEY,
174
246
  agentId,
247
+ actor,
175
248
  description: `${operation} ${agentId}`,
176
249
  detail: job.summary,
177
250
  });
@@ -180,83 +253,130 @@ export class RelayRuntime {
180
253
  agentUpdateLog(id) {
181
254
  return this.agentUpdates.readLog(id);
182
255
  }
183
- deleteAgentUpdateLog(id) {
256
+ deleteAgentUpdateLog(id, actor) {
184
257
  const job = this.agentUpdates.deleteLog(id);
258
+ this.appendActivity({
259
+ source: "web",
260
+ status: "info",
261
+ type: "agent_update_log_deleted",
262
+ agentId: job.agentId,
263
+ threadId: null,
264
+ workspace: this.config.workspace,
265
+ actor,
266
+ detail: job.logPath,
267
+ });
185
268
  this.appendAudit({
186
269
  action: "command",
187
270
  status: "ok",
188
271
  contextKey: WEB_CONTEXT_KEY,
189
272
  agentId: job.agentId,
273
+ actor,
190
274
  description: `delete update log ${id}`,
191
275
  detail: job.logPath,
192
276
  });
193
277
  return job;
194
278
  }
195
- sendAgentUpdateInput(id, input) {
196
- return this.agentUpdates.sendInput(id, input);
279
+ sendAgentUpdateInput(id, input, actor) {
280
+ const job = this.agentUpdates.sendInput(id, input);
281
+ this.appendActivity({
282
+ source: "web",
283
+ status: "info",
284
+ type: "agent_update_input_sent",
285
+ agentId: job.agentId,
286
+ threadId: null,
287
+ workspace: this.config.workspace,
288
+ actor: actor ?? this.agentUpdateActors.get(id),
289
+ detail: `Input sent to ${job.agentLabel} ${job.operation}.`,
290
+ });
291
+ return job;
197
292
  }
198
- cancelAgentUpdate(id) {
199
- return this.agentUpdates.cancel(id);
293
+ cancelAgentUpdate(id, actor) {
294
+ const job = this.agentUpdates.cancel(id);
295
+ this.appendActivity({
296
+ source: "web",
297
+ status: "aborted",
298
+ type: "agent_update_cancel_requested",
299
+ agentId: job.agentId,
300
+ threadId: null,
301
+ workspace: this.config.workspace,
302
+ actor: actor ?? this.agentUpdateActors.get(id),
303
+ detail: `${job.agentLabel} ${job.operation} cancellation requested.`,
304
+ });
305
+ return job;
200
306
  }
201
307
  async diagnostics() {
202
- return {
203
- health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
204
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
205
- snapshot: await this.snapshot(),
206
- runtime: {
207
- stateBackend: this.config.stateBackend,
208
- sourceWorkspace: this.config.workspace,
209
- queuePaused: this.queueService.isPaused(),
210
- externalMirror: this.externalActivityMonitor.snapshot(),
211
- agentDiagnostics: getAgentDiagnostics(await this.getSession(true), this.config),
212
- },
213
- };
214
- }
215
- async adapterHealth() {
216
- const health = await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath });
217
- const versions = await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath });
218
- return Promise.all(listAgentAdapterDescriptors().map(async (descriptor) => {
219
- const enabled = enabledAgents(this.config).includes(descriptor.id);
220
- const auth = descriptor.capabilities.auth && enabled
221
- ? await this.authStatus(descriptor.id).catch((error) => ({
222
- agentId: descriptor.id,
223
- agentLabel: descriptor.label,
224
- supported: descriptor.capabilities.auth,
225
- authenticated: false,
226
- detail: friendlyErrorText(error),
227
- loginSupported: descriptor.capabilities.login,
228
- logoutSupported: descriptor.capabilities.logout,
229
- }))
230
- : null;
231
- const cli = cliHealthForAgent(descriptor.id, health);
232
- const version = versionCheckForAgent(descriptor.id, versions);
308
+ return this.cached("diagnostics", async () => {
309
+ const cliOptions = this.cliPathOptions();
310
+ const [health, versionChecks, snapshot, session] = await Promise.all([
311
+ getConnectorHealth(cliOptions),
312
+ getVersionChecks(cliOptions),
313
+ this.snapshot(),
314
+ this.getSession(true),
315
+ ]);
233
316
  return {
234
- id: descriptor.id,
235
- label: descriptor.label,
236
- enabled,
237
- status: descriptor.status === "available" ? (enabled ? "enabled" : "disabled") : "planned",
238
- auth: {
239
- supported: descriptor.capabilities.auth,
240
- authenticated: auth ? auth.authenticated : null,
241
- method: auth?.method,
242
- detail: auth?.detail,
317
+ health,
318
+ versionChecks,
319
+ snapshot,
320
+ runtime: {
321
+ stateBackend: this.config.stateBackend,
322
+ sourceWorkspace: this.config.workspace,
323
+ queuePaused: this.queueService.isPaused(),
324
+ externalMirror: this.externalActivityMonitor.snapshot(),
325
+ agentDiagnostics: getAgentDiagnostics(session, this.config),
243
326
  },
244
- cli,
245
- version: {
246
- installed: version.installedLabel,
247
- latest: version.latestVersion,
248
- status: version.status,
249
- detail: version.detail,
250
- },
251
- capabilities: descriptor.capabilities,
252
- notes: descriptor.notes,
253
327
  };
254
- }));
328
+ });
329
+ }
330
+ async adapterHealth() {
331
+ return this.cached("adapterHealth", async () => {
332
+ const cliOptions = this.cliPathOptions();
333
+ const [health, versions] = await Promise.all([
334
+ getConnectorHealth(cliOptions),
335
+ getVersionChecks(cliOptions),
336
+ ]);
337
+ return Promise.all(listAgentAdapterDescriptors().map(async (descriptor) => {
338
+ const enabled = enabledAgents(this.config).includes(descriptor.id);
339
+ const auth = descriptor.capabilities.auth && enabled
340
+ ? await this.authStatus(descriptor.id).catch((error) => ({
341
+ agentId: descriptor.id,
342
+ agentLabel: descriptor.label,
343
+ supported: descriptor.capabilities.auth,
344
+ authenticated: false,
345
+ detail: friendlyErrorText(error),
346
+ loginSupported: descriptor.capabilities.login,
347
+ logoutSupported: descriptor.capabilities.logout,
348
+ }))
349
+ : null;
350
+ const cli = cliHealthForAgent(descriptor.id, health);
351
+ const version = versionCheckForAgent(descriptor.id, versions);
352
+ return {
353
+ id: descriptor.id,
354
+ label: descriptor.label,
355
+ enabled,
356
+ status: descriptor.status === "available" ? (enabled ? "enabled" : "disabled") : "planned",
357
+ auth: {
358
+ supported: descriptor.capabilities.auth,
359
+ authenticated: auth ? auth.authenticated : null,
360
+ method: auth?.method,
361
+ detail: auth?.detail,
362
+ },
363
+ cli,
364
+ version: {
365
+ installed: version.installedLabel,
366
+ latest: version.latestVersion,
367
+ status: version.status,
368
+ detail: version.detail,
369
+ },
370
+ capabilities: descriptor.capabilities,
371
+ notes: descriptor.notes,
372
+ };
373
+ }));
374
+ });
255
375
  }
256
376
  permissions() {
257
377
  return {
258
378
  mode: "users",
259
- message: "Access is managed by NordRelay users, groups, Telegram identities, and Telegram chat access records.",
379
+ message: "Access is managed by NordRelay users, groups, Telegram identities, Telegram chat access records, Discord identities, and Discord channel access records.",
260
380
  };
261
381
  }
262
382
  tasks() {
@@ -268,10 +388,229 @@ export class RelayRuntime {
268
388
  recent: this.activity({ limit: 20 }),
269
389
  };
270
390
  }
271
- audit(limit = 50) {
272
- return this.auditStore.list(limit);
391
+ async jobs() {
392
+ const jobs = [];
393
+ const current = this.currentProgress;
394
+ if (current) {
395
+ jobs.push(taskToUnifiedJob("web:current", "web-turn", "Current WebUI turn", current, {
396
+ canCancel: current.status === "running",
397
+ canRetry: false,
398
+ canReadLog: false,
399
+ }));
400
+ }
401
+ const external = this.externalActivityMonitor.task();
402
+ if (external) {
403
+ jobs.push(taskToUnifiedJob(`external:${external.agentId ?? "agent"}:${external.threadId ?? "pending"}`, "external-turn", "External CLI turn", external, {
404
+ canCancel: false,
405
+ canRetry: false,
406
+ canReadLog: false,
407
+ }));
408
+ }
409
+ for (const item of this.queueService.rawList()) {
410
+ const createdAt = new Date(item.createdAt).toISOString();
411
+ jobs.push({
412
+ id: `queue:${item.id}`,
413
+ kind: "queued-prompt",
414
+ title: `Queued prompt ${item.id}`,
415
+ status: "queued",
416
+ source: "web",
417
+ threadId: null,
418
+ workspace: this.config.workspace,
419
+ owner: item.activityActor,
420
+ startedAt: createdAt,
421
+ updatedAt: createdAt,
422
+ summary: item.description,
423
+ queueId: item.id,
424
+ logTail: item.lastError,
425
+ canCancel: true,
426
+ canRetry: true,
427
+ canReadLog: true,
428
+ });
429
+ }
430
+ for (const job of this.agentUpdates.list()) {
431
+ jobs.push({
432
+ id: `agent-update:${job.id}`,
433
+ kind: "agent-update",
434
+ title: `${job.agentLabel} ${job.operation}`,
435
+ status: agentUpdateStatusToUnified(job.status),
436
+ source: "web",
437
+ agentId: job.agentId,
438
+ agentLabel: job.agentLabel,
439
+ threadId: null,
440
+ workspace: this.config.workspace,
441
+ owner: this.agentUpdateActors.get(job.id),
442
+ startedAt: job.startedAt,
443
+ updatedAt: job.updatedAt,
444
+ finishedAt: job.finishedAt,
445
+ summary: job.error || job.summary,
446
+ logPath: job.logPath,
447
+ logTail: job.outputTail,
448
+ updateJobId: job.id,
449
+ canCancel: job.status === "running",
450
+ canRetry: job.status !== "running",
451
+ canReadLog: true,
452
+ });
453
+ }
454
+ for (const event of this.activity({ limit: 100 })) {
455
+ if (event.type === "diagnostics_bundle_exported") {
456
+ jobs.push(activityToUnifiedJob(event, "support-bundle", "Diagnostics support bundle", {
457
+ canCancel: false,
458
+ canRetry: true,
459
+ canReadLog: Boolean(event.detail),
460
+ }));
461
+ }
462
+ else if (event.type === "update_started") {
463
+ jobs.push(activityToUnifiedJob(event, "connector-update", "NordRelay update", {
464
+ canCancel: false,
465
+ canRetry: true,
466
+ canReadLog: Boolean(event.detail),
467
+ }));
468
+ }
469
+ else if (event.category === "prompt" && event.type.startsWith("prompt_")) {
470
+ jobs.push(promptActivityToUnifiedJob(event));
471
+ }
472
+ }
473
+ const liveJobs = dedupeJobs(jobs);
474
+ const storedJobs = this.jobStore.upsertMany(liveJobs);
475
+ return {
476
+ jobs: dedupeJobs([...liveJobs, ...storedJobs]).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)),
477
+ updatedAt: new Date().toISOString(),
478
+ };
273
479
  }
274
- async supportBundle() {
480
+ async jobLog(id) {
481
+ if (id.startsWith("agent-update:")) {
482
+ const updateId = id.slice("agent-update:".length);
483
+ const log = this.agentUpdates.readLog(updateId);
484
+ return { job: (await this.jobs()).jobs.find((job) => job.id === id) ?? null, plain: log.plain };
485
+ }
486
+ if (id.startsWith("queue:")) {
487
+ const queueId = id.slice("queue:".length);
488
+ const item = this.queueService.rawList().find((candidate) => candidate.id === queueId);
489
+ return {
490
+ job: (await this.jobs()).jobs.find((job) => job.id === id) ?? null,
491
+ plain: item ? [
492
+ `Queued prompt: ${item.id}`,
493
+ `Created: ${new Date(item.createdAt).toISOString()}`,
494
+ `Attempts: ${item.attempts ?? 0}`,
495
+ `Description: ${item.description}`,
496
+ item.lastError ? `Last error: ${item.lastError}` : "",
497
+ ].filter(Boolean).join("\n") : "Queued prompt not found.",
498
+ };
499
+ }
500
+ const job = (await this.jobs()).jobs.find((candidate) => candidate.id === id) ?? null;
501
+ return { job, plain: job?.logTail || job?.logPath || job?.summary || this.jobStore.get(id)?.summary || "No log available for this job." };
502
+ }
503
+ async jobAction(id, action, actor) {
504
+ if (id === "web:current" && action === "cancel") {
505
+ await this.abort(actor);
506
+ return this.jobs();
507
+ }
508
+ if (id.startsWith("queue:")) {
509
+ const queueId = id.slice("queue:".length);
510
+ this.queueService.apply(action === "cancel" ? "cancel" : "run", queueId);
511
+ this.jobStore.patch(id, {
512
+ status: action === "cancel" ? "aborted" : "queued",
513
+ summary: action === "cancel" ? `Cancelled queued prompt ${queueId}.` : `Queued prompt ${queueId} moved to the front.`,
514
+ canCancel: action !== "cancel",
515
+ canRetry: action === "cancel",
516
+ finishedAt: action === "cancel" ? new Date().toISOString() : undefined,
517
+ });
518
+ this.broadcast({ type: "queue_update", queue: this.queue(), paused: this.queuePaused() });
519
+ this.appendActivity({
520
+ source: "web",
521
+ status: action === "cancel" ? "aborted" : "queued",
522
+ type: action === "cancel" ? "job_cancelled" : "job_retried",
523
+ threadId: null,
524
+ workspace: this.config.workspace,
525
+ actor,
526
+ detail: `queue:${queueId}`,
527
+ });
528
+ if (action === "retry") {
529
+ void this.drainQueue();
530
+ }
531
+ return this.jobs();
532
+ }
533
+ if (id.startsWith("agent-update:")) {
534
+ const updateId = id.slice("agent-update:".length);
535
+ const current = this.agentUpdates.get(updateId);
536
+ if (!current) {
537
+ throw new Error("Unknown agent update job.");
538
+ }
539
+ if (action === "cancel") {
540
+ this.cancelAgentUpdate(updateId, actor);
541
+ }
542
+ else {
543
+ this.startAgentUpdate(current.agentId, current.operation, actor);
544
+ }
545
+ return this.jobs();
546
+ }
547
+ if (id.startsWith("support-bundle:") && action === "retry") {
548
+ await this.supportBundle(actor);
549
+ return this.jobs();
550
+ }
551
+ if (id.startsWith("connector-update:") && action === "retry") {
552
+ this.updateConnector(actor);
553
+ return this.jobs();
554
+ }
555
+ throw new Error(`Unsupported job action: ${action} ${id}`);
556
+ }
557
+ async activeSessions() {
558
+ const sessions = new Map();
559
+ const knownContexts = this.listKnownContextMetadata();
560
+ const preferences = new BotPreferencesStore(this.config.workspace, this.config.stateBackend);
561
+ const addActiveSession = (session) => {
562
+ const key = this.activeSessionKey(session);
563
+ const existing = sessions.get(key);
564
+ sessions.set(key, this.preferredActiveSession(existing, session));
565
+ };
566
+ if (this.currentProgress?.status === "running") {
567
+ addActiveSession({
568
+ ...this.currentProgress,
569
+ contextKey: WEB_CONTEXT_KEY,
570
+ sourceContextKey: WEB_CONTEXT_KEY,
571
+ source: "web",
572
+ status: "running",
573
+ queueLength: this.queueService.length(),
574
+ queuePaused: this.queueService.isPaused(),
575
+ });
576
+ }
577
+ for (const active of this.discoverRunningConnectorSessions()) {
578
+ addActiveSession(active);
579
+ }
580
+ for (const active of this.discoverActiveCodexSessions(knownContexts, preferences)) {
581
+ addActiveSession(active);
582
+ }
583
+ for (const meta of knownContexts) {
584
+ if (meta.contextKey === WEB_CONTEXT_KEY && this.currentProgress?.status === "running") {
585
+ continue;
586
+ }
587
+ const active = this.externalActiveSession(meta, knownContexts, preferences);
588
+ if (active) {
589
+ addActiveSession(active);
590
+ }
591
+ }
592
+ return {
593
+ sessions: [...sessions.values()].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)),
594
+ updatedAt: new Date().toISOString(),
595
+ };
596
+ }
597
+ async metrics() {
598
+ const [active, jobs] = await Promise.all([
599
+ this.activeSessions(),
600
+ this.jobs(),
601
+ ]);
602
+ return buildRuntimeMetrics({
603
+ queueLength: this.queueService.length(),
604
+ queuePaused: this.queueService.isPaused(),
605
+ activeTurnCount: active.sessions.length,
606
+ jobs: jobs.jobs,
607
+ activity: this.activity({ limit: 500 }),
608
+ });
609
+ }
610
+ audit(options = 50) {
611
+ return this.auditStore.list(options);
612
+ }
613
+ async supportBundle(actor) {
275
614
  const bundle = await createSupportBundle({
276
615
  config: this.config,
277
616
  diagnostics: await this.diagnostics(),
@@ -286,12 +625,14 @@ export class RelayRuntime {
286
625
  type: "diagnostics_bundle_exported",
287
626
  threadId: null,
288
627
  workspace: this.config.workspace,
628
+ actor,
289
629
  detail: bundle.path,
290
630
  });
291
631
  this.appendAudit({
292
632
  action: "command",
293
633
  status: "ok",
294
634
  contextKey: WEB_CONTEXT_KEY,
635
+ actor,
295
636
  description: "export diagnostics bundle",
296
637
  detail: bundle.path,
297
638
  });
@@ -300,23 +641,48 @@ export class RelayRuntime {
300
641
  locks() {
301
642
  return this.lockStore.list();
302
643
  }
303
- lockWebSession(ownerName = "Web dashboard") {
304
- const lock = this.lockStore.set(WEB_CONTEXT_KEY, 0, ownerName, this.config.sessionLockTtlMs);
644
+ lockWebSession(ownerName = "Web dashboard", actor) {
645
+ const label = ownerName || actor?.label || "Web dashboard";
646
+ const lock = this.lockStore.set(WEB_CONTEXT_KEY, {
647
+ userId: actor?.id ?? "web",
648
+ label,
649
+ channel: "web",
650
+ }, this.config.sessionLockTtlMs);
651
+ this.appendActivity({
652
+ source: "web",
653
+ status: "info",
654
+ type: "lock_created",
655
+ threadId: null,
656
+ workspace: this.config.workspace,
657
+ actor,
658
+ detail: `locked by ${label}`,
659
+ });
305
660
  this.appendAudit({
306
661
  action: "lock_updated",
307
662
  status: "ok",
308
663
  contextKey: WEB_CONTEXT_KEY,
664
+ actor,
309
665
  description: "lock",
310
- detail: `locked by ${ownerName}`,
666
+ detail: `locked by ${label}`,
311
667
  });
312
668
  return lock;
313
669
  }
314
- unlockWebSession() {
670
+ unlockWebSession(actor) {
315
671
  const removed = this.lockStore.clear(WEB_CONTEXT_KEY);
672
+ this.appendActivity({
673
+ source: "web",
674
+ status: "info",
675
+ type: "lock_removed",
676
+ threadId: null,
677
+ workspace: this.config.workspace,
678
+ actor,
679
+ detail: removed ? "unlocked" : "no lock",
680
+ });
316
681
  this.appendAudit({
317
682
  action: "lock_updated",
318
683
  status: "ok",
319
684
  contextKey: WEB_CONTEXT_KEY,
685
+ actor,
320
686
  description: "unlock",
321
687
  detail: removed ? "unlocked" : "no lock",
322
688
  });
@@ -385,7 +751,7 @@ export class RelayRuntime {
385
751
  }
386
752
  }
387
753
  }
388
- async login(agentId) {
754
+ async login(agentId, actor) {
389
755
  const { session, dispose } = await this.getControlSession(agentId);
390
756
  try {
391
757
  const info = this.publicInfo(session);
@@ -409,6 +775,16 @@ export class RelayRuntime {
409
775
  };
410
776
  }
411
777
  const result = await this.startAgentLogin(info);
778
+ this.appendActivity({
779
+ source: "web",
780
+ status: result.success ? "info" : "failed",
781
+ type: result.success ? "login_started" : "login_failed",
782
+ threadId: info.threadId,
783
+ workspace: info.workspace,
784
+ agentId: info.agentId,
785
+ actor,
786
+ detail: result.message,
787
+ });
412
788
  this.appendAudit({
413
789
  action: "command",
414
790
  status: result.success ? "ok" : "failed",
@@ -416,6 +792,7 @@ export class RelayRuntime {
416
792
  agentId: info.agentId,
417
793
  threadId: info.threadId,
418
794
  workspace: info.workspace,
795
+ actor,
419
796
  description: "login",
420
797
  detail: result.message,
421
798
  });
@@ -427,7 +804,7 @@ export class RelayRuntime {
427
804
  }
428
805
  }
429
806
  }
430
- async logout(agentId) {
807
+ async logout(agentId, actor) {
431
808
  const { session, dispose } = await this.getControlSession(agentId);
432
809
  try {
433
810
  const info = this.publicInfo(session);
@@ -461,6 +838,16 @@ export class RelayRuntime {
461
838
  };
462
839
  }
463
840
  const result = await this.startAgentLogout(info);
841
+ this.appendActivity({
842
+ source: "web",
843
+ status: result.success ? "info" : "failed",
844
+ type: result.success ? "logout_completed" : "logout_failed",
845
+ threadId: info.threadId,
846
+ workspace: info.workspace,
847
+ agentId: info.agentId,
848
+ actor,
849
+ detail: result.message,
850
+ });
464
851
  this.appendAudit({
465
852
  action: "command",
466
853
  status: result.success ? "ok" : "failed",
@@ -468,6 +855,7 @@ export class RelayRuntime {
468
855
  agentId: info.agentId,
469
856
  threadId: info.threadId,
470
857
  workspace: info.workspace,
858
+ actor,
471
859
  description: "logout",
472
860
  detail: result.message,
473
861
  });
@@ -495,18 +883,29 @@ export class RelayRuntime {
495
883
  activity: this.activity({ limit: 100 }).filter((event) => event.threadId === threadId),
496
884
  };
497
885
  }
498
- async clearChatHistory() {
886
+ async clearChatHistory(actor) {
499
887
  const session = await this.getSession(true);
500
- const removed = this.chatStore.clear(this.publicInfo(session).threadId);
888
+ const info = this.publicInfo(session);
889
+ const removed = this.chatStore.clear(info.threadId);
501
890
  const messages = await this.chatHistory();
502
891
  this.broadcast({ type: "chat_history", messages });
892
+ this.appendActivity({
893
+ source: "web",
894
+ status: "info",
895
+ type: "chat_history_cleared",
896
+ threadId: info.threadId,
897
+ workspace: info.workspace,
898
+ agentId: info.agentId,
899
+ actor,
900
+ detail: `${removed} messages removed.`,
901
+ });
503
902
  return { removed, messages };
504
903
  }
505
904
  activity(options = {}) {
506
905
  const currentInfo = this.registry.get(WEB_CONTEXT_KEY)?.getInfo();
507
906
  return this.activityStore.list(options).map((event) => this.enrichActivityEvent(event, currentInfo));
508
907
  }
509
- async retry() {
908
+ async retry(actor) {
510
909
  const cached = this.queueService.getLastPrompt();
511
910
  if (!cached) {
512
911
  throw new Error("Nothing to retry. Send a message first.");
@@ -515,12 +914,13 @@ export class RelayRuntime {
515
914
  action: "command",
516
915
  status: "ok",
517
916
  contextKey: WEB_CONTEXT_KEY,
917
+ actor,
518
918
  description: "retry",
519
919
  detail: cached.description,
520
920
  });
521
- return this.sendEnvelope(cached);
921
+ return this.sendEnvelope({ ...cached, activityActor: cached.activityActor ?? actor }, actor);
522
922
  }
523
- async sync() {
923
+ async sync(actor) {
524
924
  const session = await this.getSession(true);
525
925
  const info = this.publicInfo(session);
526
926
  if (!(info.capabilities ?? CODEX_AGENT_CAPABILITIES).externalActivity) {
@@ -537,6 +937,7 @@ export class RelayRuntime {
537
937
  threadId: result.info.threadId,
538
938
  workspace: result.info.workspace,
539
939
  agentId: result.info.agentId,
940
+ actor,
540
941
  detail: result.changedFields.length > 0 ? result.changedFields.join(", ") : "already in sync",
541
942
  });
542
943
  this.appendAudit({
@@ -546,6 +947,7 @@ export class RelayRuntime {
546
947
  agentId: result.info.agentId,
547
948
  threadId: result.info.threadId,
548
949
  workspace: result.info.workspace,
950
+ actor,
549
951
  description: "sync",
550
952
  detail: result.changedFields.join(", ") || "none",
551
953
  });
@@ -612,15 +1014,26 @@ export class RelayRuntime {
612
1014
  });
613
1015
  return session.listModels();
614
1016
  }
615
- async setAgent(agentId) {
1017
+ async setAgent(agentId, actor) {
616
1018
  if (!enabledAgents(this.config).includes(agentId)) {
617
1019
  throw new Error(`Agent is not enabled: ${agentId}`);
618
1020
  }
619
1021
  const session = await this.registry.switchAgent(WEB_CONTEXT_KEY, agentId);
620
1022
  this.updateSession(session);
1023
+ const info = this.publicInfo(session);
1024
+ this.appendActivity({
1025
+ source: "web",
1026
+ status: "info",
1027
+ type: "agent_switch",
1028
+ threadId: info.threadId,
1029
+ workspace: info.workspace,
1030
+ agentId: info.agentId,
1031
+ actor,
1032
+ detail: `Dashboard switched agent to ${info.agentLabel}.`,
1033
+ });
621
1034
  return this.publicInfo(session);
622
1035
  }
623
- async newSession(options = {}) {
1036
+ async newSession(options = {}, actor) {
624
1037
  const session = options.agentId ? await this.registry.switchAgent(WEB_CONTEXT_KEY, options.agentId) : await this.getSession(true);
625
1038
  this.ensureIdle(session);
626
1039
  if (options.reasoningEffort) {
@@ -645,11 +1058,12 @@ export class RelayRuntime {
645
1058
  threadId: info.threadId,
646
1059
  workspace: info.workspace,
647
1060
  agentId: info.agentId,
1061
+ actor,
648
1062
  detail: "New dashboard session created.",
649
1063
  });
650
1064
  return this.publicInfo(session);
651
1065
  }
652
- async switchSession(threadId) {
1066
+ async switchSession(threadId, actor) {
653
1067
  const session = await this.getSession(true);
654
1068
  this.ensureIdle(session);
655
1069
  const info = await session.switchSession(threadId);
@@ -662,21 +1076,24 @@ export class RelayRuntime {
662
1076
  threadId: info.threadId,
663
1077
  workspace: info.workspace,
664
1078
  agentId: info.agentId,
1079
+ actor,
665
1080
  detail: "Dashboard switched session.",
666
1081
  });
667
1082
  return this.publicInfo(session);
668
1083
  }
669
- async attachSession(threadId) {
670
- return this.switchSession(threadId);
1084
+ async attachSession(threadId, actor) {
1085
+ return this.switchSession(threadId, actor);
671
1086
  }
672
- async setModel(model) {
1087
+ async setModel(model, actor) {
673
1088
  const session = await this.getSession(true);
674
1089
  this.ensureIdle(session);
675
1090
  await session.setModelForCurrentSession(model);
676
1091
  this.updateSession(session);
677
- return this.publicInfo(session);
1092
+ const info = this.publicInfo(session);
1093
+ this.appendActivity({ source: "web", status: "info", type: "model_changed", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: model });
1094
+ return info;
678
1095
  }
679
- async setReasoningEffort(effort) {
1096
+ async setReasoningEffort(effort, actor) {
680
1097
  const session = await this.getSession(true);
681
1098
  this.ensureIdle(session);
682
1099
  const options = agentReasoningOptions(session.getInfo().agentId);
@@ -685,9 +1102,11 @@ export class RelayRuntime {
685
1102
  }
686
1103
  await session.setReasoningEffortForCurrentSession(effort);
687
1104
  this.updateSession(session);
688
- return this.publicInfo(session);
1105
+ const info = this.publicInfo(session);
1106
+ this.appendActivity({ source: "web", status: "info", type: "reasoning_changed", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: effort });
1107
+ return info;
689
1108
  }
690
- async setFastMode(enabled) {
1109
+ async setFastMode(enabled, actor) {
691
1110
  const session = await this.getSession(true);
692
1111
  this.ensureIdle(session);
693
1112
  if (!(session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).fastMode) {
@@ -695,23 +1114,29 @@ export class RelayRuntime {
695
1114
  }
696
1115
  session.setFastMode(enabled);
697
1116
  this.updateSession(session);
698
- return this.publicInfo(session);
1117
+ const info = this.publicInfo(session);
1118
+ this.appendActivity({ source: "web", status: "info", type: "fast_mode_changed", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: enabled ? "on" : "off" });
1119
+ return info;
699
1120
  }
700
- async setLaunchProfile(profileId) {
1121
+ async setLaunchProfile(profileId, actor) {
701
1122
  const session = await this.getSession(true);
702
1123
  this.ensureIdle(session);
703
1124
  session.setLaunchProfile(profileId);
704
1125
  this.updateSession(session);
705
- return this.publicInfo(session);
1126
+ const info = this.publicInfo(session);
1127
+ this.appendActivity({ source: "web", status: "info", type: "launch_profile_changed", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: info.launchProfileLabel ?? profileId });
1128
+ return info;
706
1129
  }
707
- async handback() {
1130
+ async handback(actor) {
708
1131
  const session = await this.getSession(true);
709
1132
  this.ensureIdle(session);
710
1133
  const result = session.handback();
711
1134
  this.updateSession(session);
1135
+ const info = this.publicInfo(session);
1136
+ this.appendActivity({ source: "web", status: "info", type: "handback", threadId: result.threadId, workspace: result.workspace, agentId: info.agentId, actor, detail: result.command ?? "Thread handed back." });
712
1137
  return result;
713
1138
  }
714
- async abort() {
1139
+ async abort(actor) {
715
1140
  const session = await this.getSession(true);
716
1141
  const snapshot = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
717
1142
  if (snapshot?.activity.active && !session.isProcessing()) {
@@ -721,19 +1146,23 @@ export class RelayRuntime {
721
1146
  message: `Cannot abort the external ${snapshot.agentLabel} CLI task from NordRelay. Stop it in the terminal where it is running.`,
722
1147
  at: new Date().toISOString(),
723
1148
  });
1149
+ const info = this.publicInfo(session);
1150
+ 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.` });
724
1151
  return;
725
1152
  }
726
1153
  await session.abort();
1154
+ const info = this.publicInfo(session);
1155
+ this.appendActivity({ source: "web", status: "aborted", type: "prompt_aborted", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor, detail: "Current operation aborted." });
727
1156
  this.broadcast({ type: "status", level: "warn", message: "Current operation aborted.", at: new Date().toISOString() });
728
1157
  }
729
- async sendPrompt(text) {
1158
+ async sendPrompt(text, actor) {
730
1159
  const trimmed = text.trim();
731
1160
  if (!trimmed) {
732
1161
  throw new Error("Prompt is empty.");
733
1162
  }
734
- return this.sendEnvelope(toPromptEnvelope(trimmed));
1163
+ return this.sendEnvelope({ ...toPromptEnvelope(trimmed), activityActor: actor }, actor);
735
1164
  }
736
- async sendUploadPrompt(options) {
1165
+ async sendUploadPrompt(options, actor) {
737
1166
  const text = options.text?.trim() ?? "";
738
1167
  const files = options.files.filter((file) => file.data.byteLength > 0);
739
1168
  if (!text && files.length === 0) {
@@ -768,9 +1197,32 @@ export class RelayRuntime {
768
1197
  const transcript = result.text.trim();
769
1198
  if (transcript) {
770
1199
  transcriptParts.push(`Audio transcript (${staged.safeName}, via ${result.backend}):\n${transcript}`);
1200
+ this.appendActivity({
1201
+ source: "web",
1202
+ status: "completed",
1203
+ type: "voice_transcribed",
1204
+ threadId: session.getInfo().threadId,
1205
+ workspace,
1206
+ agentId: session.getInfo().agentId,
1207
+ actor,
1208
+ detail: `${staged.safeName} via ${result.backend}`,
1209
+ durationMs: result.durationMs,
1210
+ });
771
1211
  }
772
1212
  }
773
1213
  }
1214
+ if (stagedFiles.length > 0) {
1215
+ this.appendActivity({
1216
+ source: "web",
1217
+ status: "info",
1218
+ type: "attachment_staged",
1219
+ threadId: session.getInfo().threadId,
1220
+ workspace,
1221
+ agentId: session.getInfo().agentId,
1222
+ actor,
1223
+ detail: `${stagedFiles.length} file(s): ${stagedFiles.map((file) => file.safeName).join(", ")}`,
1224
+ });
1225
+ }
774
1226
  const audioOnly = stagedFiles.length > 0 && stagedFiles.every((file) => file.mimeType.startsWith("audio/"));
775
1227
  if (this.config.voiceTranscribeOnly && audioOnly && !text) {
776
1228
  return {
@@ -791,14 +1243,15 @@ export class RelayRuntime {
791
1243
  if (stagedFiles.length > 0) {
792
1244
  promptInput.stagedFileInstructions = buildFileInstructions(stagedFiles, outDir);
793
1245
  }
794
- const result = await this.sendEnvelope(toPromptEnvelope(promptInput, outDir));
1246
+ const result = await this.sendEnvelope({ ...toPromptEnvelope(promptInput, outDir), activityActor: actor }, actor);
795
1247
  return {
796
1248
  ...result,
797
1249
  transcript: transcriptParts.join("\n\n") || undefined,
798
1250
  files: uploadFileDtos(stagedFiles),
799
1251
  };
800
1252
  }
801
- async sendEnvelope(envelope) {
1253
+ async sendEnvelope(envelope, actor) {
1254
+ const activityActor = envelope.activityActor ?? actor;
802
1255
  const session = await this.getSession(false);
803
1256
  const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
804
1257
  if (session.isProcessing() || external?.activity.active) {
@@ -811,6 +1264,7 @@ export class RelayRuntime {
811
1264
  threadId: info.threadId,
812
1265
  workspace: info.workspace,
813
1266
  agentId: info.agentId,
1267
+ actor: activityActor,
814
1268
  prompt: envelope.description,
815
1269
  detail: external?.activity.active
816
1270
  ? `Queued because ${external.agentLabel} CLI is still processing another task.`
@@ -824,6 +1278,7 @@ export class RelayRuntime {
824
1278
  threadId: info.threadId,
825
1279
  workspace: info.workspace,
826
1280
  promptId: queued.id,
1281
+ actor: activityActor,
827
1282
  description: envelope.description,
828
1283
  });
829
1284
  if (external?.activity.active) {
@@ -832,7 +1287,7 @@ export class RelayRuntime {
832
1287
  this.broadcastQueue();
833
1288
  return { queued: true, queueId: queued.id };
834
1289
  }
835
- void this.runPrompt(session, envelope).catch((error) => {
1290
+ void this.runPrompt(session, { ...envelope, activityActor }).catch((error) => {
836
1291
  this.broadcast({ type: "turn_error", id: this.currentTurnId ?? "turn", error: friendlyErrorText(error), at: new Date().toISOString() });
837
1292
  });
838
1293
  return { queued: false };
@@ -843,7 +1298,9 @@ export class RelayRuntime {
843
1298
  queuePaused() {
844
1299
  return this.queueService.isPaused();
845
1300
  }
846
- queueAction(action, id) {
1301
+ queueAction(action, id, actor) {
1302
+ const before = this.queueService.rawList();
1303
+ const affected = id ? before.find((item) => item.id === id) : undefined;
847
1304
  this.queueService.apply(action, id);
848
1305
  if (id && action === "run") {
849
1306
  void this.drainQueue().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
@@ -851,15 +1308,18 @@ export class RelayRuntime {
851
1308
  this.appendActivity({
852
1309
  source: "web",
853
1310
  status: "info",
854
- type: "queue_updated",
1311
+ type: `queue_${action}`,
855
1312
  threadId: null,
856
1313
  workspace: this.config.workspace,
857
- detail: id ? `${action}: ${id}` : action,
1314
+ actor,
1315
+ prompt: affected?.description,
1316
+ detail: id ? `${action}: ${id}` : `${action}: ${before.length} queued`,
858
1317
  });
859
1318
  this.appendAudit({
860
1319
  action: "queue_updated",
861
1320
  status: "ok",
862
1321
  contextKey: WEB_CONTEXT_KEY,
1322
+ actor,
863
1323
  description: id ? `${action}: ${id}` : action,
864
1324
  });
865
1325
  this.broadcastQueue();
@@ -873,13 +1333,39 @@ export class RelayRuntime {
873
1333
  const session = await this.getSession(true);
874
1334
  return this.artifactService.get(session.getInfo().workspace, turnId);
875
1335
  }
876
- async deleteArtifact(turnId) {
1336
+ async deleteArtifact(turnId, actor) {
877
1337
  const session = await this.getSession(true);
878
- return this.artifactService.delete(session.getInfo().workspace, turnId);
1338
+ const info = this.publicInfo(session);
1339
+ const removed = await this.artifactService.delete(info.workspace, turnId);
1340
+ this.appendActivity({
1341
+ source: "web",
1342
+ status: removed ? "info" : "failed",
1343
+ type: "artifact_deleted",
1344
+ threadId: info.threadId,
1345
+ workspace: info.workspace,
1346
+ agentId: info.agentId,
1347
+ actor,
1348
+ detail: turnId,
1349
+ });
1350
+ return removed;
879
1351
  }
880
- async createArtifactZip(turnId) {
1352
+ async createArtifactZip(turnId, actor) {
881
1353
  const session = await this.getSession(true);
882
- return this.artifactService.createZip(session.getInfo().workspace, turnId);
1354
+ const info = this.publicInfo(session);
1355
+ const zip = await this.artifactService.createZip(info.workspace, turnId);
1356
+ if (zip) {
1357
+ this.appendActivity({
1358
+ source: "web",
1359
+ status: "info",
1360
+ type: "artifact_zip_created",
1361
+ threadId: info.threadId,
1362
+ workspace: info.workspace,
1363
+ agentId: info.agentId,
1364
+ actor,
1365
+ detail: zip.name,
1366
+ });
1367
+ }
1368
+ return zip;
883
1369
  }
884
1370
  async artifactPreview(turnId, relativePath) {
885
1371
  const session = await this.getSession(true);
@@ -894,7 +1380,7 @@ export class RelayRuntime {
894
1380
  }
895
1381
  return readFormattedLogTail(lines);
896
1382
  }
897
- clearLogs(target = "connector") {
1383
+ clearLogs(target = "connector", actor) {
898
1384
  const result = clearLogFile(target === "update" ? getUpdateLogPath() : target === "agent-updates" ? getAgentUpdateLogPath() : getConnectorLogPath());
899
1385
  this.appendActivity({
900
1386
  source: "web",
@@ -902,11 +1388,20 @@ export class RelayRuntime {
902
1388
  type: "logs_cleared",
903
1389
  threadId: null,
904
1390
  workspace: this.config.workspace,
1391
+ actor,
905
1392
  detail: `Cleared ${target} log.`,
906
1393
  });
1394
+ this.appendAudit({
1395
+ action: "command",
1396
+ status: "ok",
1397
+ contextKey: WEB_CONTEXT_KEY,
1398
+ actor,
1399
+ description: `clear ${target} log`,
1400
+ detail: result.filePath,
1401
+ });
907
1402
  return { ok: true, filePath: result.filePath, clearedAt: result.clearedAt.toISOString() };
908
1403
  }
909
- restartConnector() {
1404
+ restartConnector(actor) {
910
1405
  spawnConnectorRestart();
911
1406
  this.broadcastStatus("Restart requested. The dashboard may disconnect briefly.", "warn");
912
1407
  this.appendActivity({
@@ -915,8 +1410,16 @@ export class RelayRuntime {
915
1410
  type: "restart_requested",
916
1411
  threadId: null,
917
1412
  workspace: this.config.workspace,
1413
+ actor,
918
1414
  detail: "Dashboard requested a connector restart.",
919
1415
  });
1416
+ this.appendAudit({
1417
+ action: "command",
1418
+ status: "ok",
1419
+ contextKey: WEB_CONTEXT_KEY,
1420
+ actor,
1421
+ description: "restart connector",
1422
+ });
920
1423
  return { ok: true, message: "Restart requested." };
921
1424
  }
922
1425
  dispose() {
@@ -930,6 +1433,234 @@ export class RelayRuntime {
930
1433
  async getSession(deferThreadStart) {
931
1434
  return this.registry.getOrCreate(WEB_CONTEXT_KEY, { deferThreadStart });
932
1435
  }
1436
+ async cached(key, producer) {
1437
+ return (await this.cache.get(key, this.config.dashboardCacheTtlMs, producer)).value;
1438
+ }
1439
+ listKnownContextMetadata() {
1440
+ const contexts = new Map();
1441
+ const add = (meta) => {
1442
+ if (meta?.contextKey) {
1443
+ contexts.set(meta.contextKey, meta);
1444
+ }
1445
+ };
1446
+ for (const meta of this.registry.listContexts()) {
1447
+ add(meta);
1448
+ }
1449
+ const sharedRegistry = new SessionRegistry(this.config);
1450
+ try {
1451
+ for (const meta of sharedRegistry.listContexts()) {
1452
+ add(meta);
1453
+ }
1454
+ }
1455
+ finally {
1456
+ sharedRegistry.disposeAll();
1457
+ }
1458
+ const current = this.registry.get(WEB_CONTEXT_KEY)?.getInfo();
1459
+ if (current) {
1460
+ add({
1461
+ contextKey: WEB_CONTEXT_KEY,
1462
+ agentId: current.agentId,
1463
+ threadId: current.threadId,
1464
+ workspace: current.workspace,
1465
+ model: current.model,
1466
+ reasoningEffort: current.reasoningEffort,
1467
+ launchProfileId: current.nextLaunchProfileId ?? current.launchProfileId,
1468
+ sessionPath: current.sessionPath,
1469
+ updatedAt: Date.now(),
1470
+ });
1471
+ }
1472
+ return [...contexts.values()];
1473
+ }
1474
+ discoverRunningConnectorSessions() {
1475
+ const active = [];
1476
+ const terminal = new Set();
1477
+ const now = Date.now();
1478
+ for (const event of this.activityStore.list({ limit: 500 })) {
1479
+ if (!event.threadId || !event.agentId || !event.contextKey) {
1480
+ continue;
1481
+ }
1482
+ const key = `${event.source}:${event.contextKey}:${event.agentId}:${event.threadId}`;
1483
+ if (isPromptTerminalActivity(event)) {
1484
+ terminal.add(key);
1485
+ continue;
1486
+ }
1487
+ if (event.type !== "prompt_started" || event.status !== "running" || event.source === "cli") {
1488
+ continue;
1489
+ }
1490
+ if (terminal.has(key)) {
1491
+ continue;
1492
+ }
1493
+ const startedMs = Date.parse(event.timestamp);
1494
+ if (!Number.isFinite(startedMs) || now - startedMs > ACTIVE_ACTIVITY_TTL_MS) {
1495
+ continue;
1496
+ }
1497
+ active.push({
1498
+ id: `${event.contextKey}:${event.id}`,
1499
+ contextKey: event.contextKey,
1500
+ sourceContextKey: event.contextKey,
1501
+ source: event.source,
1502
+ status: "running",
1503
+ agentId: event.agentId,
1504
+ agentLabel: event.agentId ? agentLabel(event.agentId) : undefined,
1505
+ threadId: event.threadId,
1506
+ workspace: event.workspace,
1507
+ prompt: event.prompt,
1508
+ startedAt: event.timestamp,
1509
+ updatedAt: event.timestamp,
1510
+ durationMs: Math.max(0, now - startedMs),
1511
+ queueLength: this.promptStore.list(event.contextKey).length,
1512
+ queuePaused: this.promptStore.isPaused(event.contextKey),
1513
+ detail: event.actor?.label ? `Started by ${event.actor.label}` : undefined,
1514
+ });
1515
+ }
1516
+ return active;
1517
+ }
1518
+ discoverActiveCodexSessions(knownContexts, preferences) {
1519
+ if (!this.config.codexEnabled || !enabledAgents(this.config).includes("codex")) {
1520
+ return [];
1521
+ }
1522
+ const capabilities = this.capabilitiesForAgent("codex");
1523
+ if (!capabilities.externalActivity) {
1524
+ return [];
1525
+ }
1526
+ const active = [];
1527
+ for (const thread of listCodexThreads(ACTIVE_CODEX_DISCOVERY_LIMIT)) {
1528
+ const meta = {
1529
+ contextKey: `cli:codex:${thread.id}`,
1530
+ agentId: "codex",
1531
+ threadId: thread.id,
1532
+ workspace: thread.cwd,
1533
+ model: thread.model ?? undefined,
1534
+ reasoningEffort: thread.reasoningEffort ?? undefined,
1535
+ updatedAt: thread.updatedAt.getTime(),
1536
+ };
1537
+ const session = this.externalActiveSession(meta, knownContexts, preferences);
1538
+ if (session) {
1539
+ active.push(session);
1540
+ }
1541
+ }
1542
+ return active;
1543
+ }
1544
+ externalActiveSession(meta, knownContexts, preferences) {
1545
+ if (!meta.threadId) {
1546
+ return null;
1547
+ }
1548
+ const agentId = isAgentId(meta.agentId) ? meta.agentId : this.config.defaultAgent;
1549
+ if (!enabledAgents(this.config).includes(agentId)) {
1550
+ return null;
1551
+ }
1552
+ const capabilities = this.capabilitiesForAgent(agentId);
1553
+ if (!capabilities.externalActivity) {
1554
+ return null;
1555
+ }
1556
+ const snapshot = getExternalSnapshotForSession(this.sessionStubForMetadata(meta, agentId, capabilities), this.config, {
1557
+ maxEvents: 8,
1558
+ });
1559
+ if (!snapshot?.activity.active) {
1560
+ return null;
1561
+ }
1562
+ const startedAt = snapshot.activity.startedAt?.toISOString() ?? new Date().toISOString();
1563
+ const updatedAt = snapshot.activity.updatedAt?.toISOString() ?? new Date().toISOString();
1564
+ const startedMs = Date.parse(startedAt);
1565
+ const sourceContextKey = `cli:${snapshot.agentId}:${snapshot.threadId}`;
1566
+ const mirrorChannels = this.activeMirrorChannels(snapshot.agentId, snapshot.threadId, knownContexts, preferences);
1567
+ const queueLength = mirrorChannels.reduce((sum, mirror) => sum + mirror.queueLength, this.promptStore.list(sourceContextKey).length);
1568
+ const mirrorDetail = mirrorChannels.length > 0
1569
+ ? `Mirroring: ${mirrorChannels.map((mirror) => `${mirror.source} ${mirror.mode}`).join(", ")}`
1570
+ : "Mirroring: none";
1571
+ return {
1572
+ id: `${sourceContextKey}:${snapshot.activity.turnId ?? snapshot.threadId}`,
1573
+ contextKey: sourceContextKey,
1574
+ sourceContextKey,
1575
+ source: "cli",
1576
+ status: "external",
1577
+ agentId: snapshot.agentId,
1578
+ agentLabel: snapshot.agentLabel,
1579
+ threadId: snapshot.threadId,
1580
+ workspace: meta.workspace,
1581
+ prompt: snapshot.latestUserMessage ?? undefined,
1582
+ currentTool: snapshot.latestToolName ?? undefined,
1583
+ lastTool: snapshot.latestToolName ?? undefined,
1584
+ startedAt,
1585
+ updatedAt,
1586
+ durationMs: Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : 0,
1587
+ queueLength,
1588
+ queuePaused: mirrorChannels.some((mirror) => mirror.queuePaused) || this.promptStore.isPaused(sourceContextKey),
1589
+ mirrorChannels,
1590
+ detail: `${mirrorDetail} | ${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
1591
+ };
1592
+ }
1593
+ activeMirrorChannels(agentId, threadId, knownContexts, preferences) {
1594
+ const mirrors = [];
1595
+ const seen = new Set();
1596
+ for (const meta of knownContexts) {
1597
+ const metaAgentId = meta.agentId ?? this.config.defaultAgent ?? "codex";
1598
+ if (meta.threadId !== threadId || metaAgentId !== agentId) {
1599
+ continue;
1600
+ }
1601
+ const source = activeSessionSourceForContext(meta.contextKey);
1602
+ if (source !== "telegram" && source !== "discord") {
1603
+ continue;
1604
+ }
1605
+ const mode = this.effectiveMirrorMode(meta.contextKey, source, preferences);
1606
+ if (mode === "off" || seen.has(meta.contextKey)) {
1607
+ continue;
1608
+ }
1609
+ seen.add(meta.contextKey);
1610
+ mirrors.push({
1611
+ source,
1612
+ contextKey: meta.contextKey,
1613
+ mode,
1614
+ queueLength: this.promptStore.list(meta.contextKey).length,
1615
+ queuePaused: this.promptStore.isPaused(meta.contextKey),
1616
+ });
1617
+ }
1618
+ return mirrors;
1619
+ }
1620
+ effectiveMirrorMode(contextKey, source, preferences) {
1621
+ const configured = source === "telegram" ? this.config.telegramMirrorMode : this.config.discordMirrorMode;
1622
+ return preferences.get(contextKey).mirrorMode ?? configured;
1623
+ }
1624
+ sessionStubForMetadata(meta, agentId, capabilities) {
1625
+ const info = {
1626
+ agentId,
1627
+ agentLabel: agentLabel(agentId),
1628
+ threadId: meta.threadId,
1629
+ workspace: meta.workspace,
1630
+ model: meta.model,
1631
+ reasoningEffort: meta.reasoningEffort,
1632
+ launchProfileId: meta.launchProfileId ?? this.config.defaultLaunchProfileId,
1633
+ launchProfileLabel: meta.launchProfileId ?? this.config.defaultLaunchProfileId,
1634
+ launchProfileBehavior: "-",
1635
+ sandboxMode: "-",
1636
+ approvalPolicy: "-",
1637
+ fastMode: false,
1638
+ unsafeLaunch: false,
1639
+ sessionPath: meta.sessionPath,
1640
+ capabilities,
1641
+ };
1642
+ return {
1643
+ getInfo: () => info,
1644
+ getActiveThreadId: () => meta.threadId,
1645
+ };
1646
+ }
1647
+ capabilitiesForAgent(agentId) {
1648
+ return listAgentAdapterDescriptors().find((descriptor) => descriptor.id === agentId)?.capabilities ?? CODEX_AGENT_CAPABILITIES;
1649
+ }
1650
+ activeSessionKey(session) {
1651
+ return session.threadId ? `${session.agentId ?? "unknown"}:${session.threadId}` : session.id;
1652
+ }
1653
+ preferredActiveSession(existing, candidate) {
1654
+ if (!existing) {
1655
+ return candidate;
1656
+ }
1657
+ const existingPriority = activeSessionPriority(existing);
1658
+ const candidatePriority = activeSessionPriority(candidate);
1659
+ if (candidatePriority !== existingPriority) {
1660
+ return candidatePriority > existingPriority ? candidate : existing;
1661
+ }
1662
+ return Date.parse(candidate.updatedAt) >= Date.parse(existing.updatedAt) ? candidate : existing;
1663
+ }
933
1664
  async getControlSession(agentId) {
934
1665
  const active = await this.getSession(true);
935
1666
  const activeInfo = this.publicInfo(active);
@@ -945,6 +1676,14 @@ export class RelayRuntime {
945
1676
  });
946
1677
  return { session, dispose: true };
947
1678
  }
1679
+ cliPathOptions() {
1680
+ return {
1681
+ piCliPath: this.config.piCliPath,
1682
+ hermesCliPath: this.config.hermesCliPath,
1683
+ openClawCliPath: this.config.openClawCliPath,
1684
+ claudeCodeCliPath: this.config.claudeCodeCliPath,
1685
+ };
1686
+ }
948
1687
  async ensureActiveThread(session) {
949
1688
  if (!session.hasActiveThread()) {
950
1689
  await session.newThread();
@@ -1009,169 +1748,14 @@ export class RelayRuntime {
1009
1748
  }
1010
1749
  }
1011
1750
  async runPrompt(session, envelope) {
1012
- await this.ensureActiveThread(session);
1013
- const info = session.getInfo();
1014
- if ((info.capabilities ?? CODEX_AGENT_CAPABILITIES).auth) {
1015
- const auth = await this.checkAgentAuth(info);
1016
- if (!auth.authenticated) {
1017
- throw new Error(`${agentLabel(info.agentId)} is not authenticated: ${auth.detail}`);
1018
- }
1019
- }
1020
1751
  const workspacePolicy = evaluateWorkspacePolicy(session.getInfo().workspace, this.config);
1021
1752
  if (!workspacePolicy.allowed) {
1022
1753
  throw new Error(workspacePolicy.warning ?? "Current workspace is blocked by policy.");
1023
1754
  }
1024
- const turnId = randomUUID().slice(0, 12);
1025
- this.currentTurnId = turnId;
1026
- this.currentTurnStartedAt = Date.now();
1027
- this.accumulatedText = "";
1028
- this.currentProgress = {
1029
- id: turnId,
1030
- source: "web",
1031
- status: "running",
1032
- prompt: envelope.description,
1033
- agentId: info.agentId,
1034
- agentLabel: info.agentLabel,
1035
- threadId: info.threadId,
1036
- workspace: info.workspace,
1037
- startedAt: new Date(this.currentTurnStartedAt).toISOString(),
1038
- updatedAt: new Date(this.currentTurnStartedAt).toISOString(),
1039
- durationMs: 0,
1040
- outputChars: 0,
1041
- tools: [],
1042
- };
1043
- this.queueService.setLastPrompt(envelope);
1044
- const startedDate = new Date();
1045
- const startedAt = startedDate.toISOString();
1046
- this.chatStore.append({
1047
- threadId: info.threadId ?? "pending",
1048
- role: "user",
1049
- text: envelope.description,
1050
- source: "web",
1051
- turnId,
1052
- timestamp: startedAt,
1053
- });
1054
- this.appendActivity({
1055
- source: "web",
1056
- status: "running",
1057
- type: "prompt_started",
1058
- threadId: info.threadId,
1059
- workspace: info.workspace,
1060
- agentId: info.agentId,
1061
- prompt: envelope.description,
1062
- });
1063
- this.appendAudit({
1064
- action: "prompt_started",
1065
- status: "ok",
1066
- contextKey: WEB_CONTEXT_KEY,
1067
- agentId: info.agentId,
1068
- threadId: info.threadId,
1069
- workspace: info.workspace,
1070
- description: envelope.description,
1071
- });
1072
- this.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: startedAt, source: "web" });
1073
- const callbacks = {
1074
- onTextDelta: (delta) => {
1075
- this.accumulatedText += delta;
1076
- this.updateCurrentProgress({ outputChars: this.accumulatedText.length });
1077
- this.broadcast({ type: "text_delta", id: turnId, delta });
1078
- },
1079
- onToolStart: (toolName, toolCallId) => {
1080
- this.addCurrentTool(toolName);
1081
- this.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName });
1082
- },
1083
- onToolUpdate: (toolCallId, partialResult) => {
1084
- this.updateCurrentProgress();
1085
- this.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult });
1086
- },
1087
- onToolEnd: (toolCallId, isError) => {
1088
- this.updateCurrentProgress({ currentTool: undefined });
1089
- this.broadcast({ type: "tool_end", id: turnId, toolCallId, isError });
1090
- },
1091
- onTodoUpdate: (items) => {
1092
- this.updateCurrentProgress({ detail: `Plan: ${items.filter((item) => item.completed).length}/${items.length} done` });
1093
- this.broadcast({ type: "todo_update", id: turnId, items });
1094
- },
1095
- onTurnComplete: () => { },
1096
- onAgentEnd: () => this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() }),
1097
- };
1098
1755
  try {
1099
- await session.prompt(envelope.input, callbacks);
1100
- this.updateSession(session);
1101
- await this.artifactService.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, startedDate);
1102
- if (this.accumulatedText.trim()) {
1103
- this.chatStore.append({
1104
- threadId: info.threadId ?? "pending",
1105
- role: "agent",
1106
- text: this.accumulatedText,
1107
- source: "web",
1108
- turnId,
1109
- });
1110
- }
1111
- this.appendActivity({
1112
- source: "web",
1113
- status: "completed",
1114
- type: "prompt_completed",
1115
- threadId: info.threadId,
1116
- workspace: info.workspace,
1117
- agentId: info.agentId,
1118
- prompt: envelope.description,
1119
- durationMs: Date.now() - this.currentTurnStartedAt,
1120
- });
1121
- this.appendAudit({
1122
- action: "prompt_completed",
1123
- status: "ok",
1124
- contextKey: WEB_CONTEXT_KEY,
1125
- agentId: info.agentId,
1126
- threadId: info.threadId,
1127
- workspace: info.workspace,
1128
- description: envelope.description,
1129
- });
1130
- this.updateCurrentProgress({ status: "completed" });
1131
- this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() });
1132
- this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
1133
- }
1134
- catch (error) {
1135
- const errorText = friendlyErrorText(error);
1136
- this.chatStore.append({
1137
- threadId: info.threadId ?? "pending",
1138
- role: "system",
1139
- text: `Error: ${errorText}`,
1140
- source: "web",
1141
- turnId,
1142
- });
1143
- this.appendActivity({
1144
- source: "web",
1145
- status: "failed",
1146
- type: "prompt_failed",
1147
- threadId: info.threadId,
1148
- workspace: info.workspace,
1149
- agentId: info.agentId,
1150
- prompt: envelope.description,
1151
- detail: errorText,
1152
- durationMs: Date.now() - this.currentTurnStartedAt,
1153
- });
1154
- this.appendAudit({
1155
- action: "prompt_failed",
1156
- status: "failed",
1157
- contextKey: WEB_CONTEXT_KEY,
1158
- agentId: info.agentId,
1159
- threadId: info.threadId,
1160
- workspace: info.workspace,
1161
- description: envelope.description,
1162
- detail: errorText,
1163
- });
1164
- this.updateCurrentProgress({ status: "failed", detail: errorText });
1165
- this.broadcast({ type: "turn_error", id: turnId, error: errorText, at: new Date().toISOString() });
1166
- this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
1167
- throw error;
1756
+ await this.turnService.run(session, envelope);
1168
1757
  }
1169
1758
  finally {
1170
- this.currentTurnId = null;
1171
- if (this.currentProgress) {
1172
- this.currentProgress.durationMs = Date.now() - this.currentTurnStartedAt;
1173
- this.currentProgress.updatedAt = new Date().toISOString();
1174
- }
1175
1759
  await this.drainQueue();
1176
1760
  }
1177
1761
  }
@@ -1204,6 +1788,40 @@ export class RelayRuntime {
1204
1788
  this.registry.updateMetadata(WEB_CONTEXT_KEY, session);
1205
1789
  this.broadcast({ type: "session_update", session: this.publicInfo(session) });
1206
1790
  }
1791
+ recordActivity(input) {
1792
+ return this.appendActivity(input);
1793
+ }
1794
+ recordAgentUpdateLifecycle(job) {
1795
+ const previous = this.agentUpdateStates.get(job.id);
1796
+ const actor = this.agentUpdateActors.get(job.id);
1797
+ if (job.needsInput && !previous?.needsInput) {
1798
+ this.appendActivity({
1799
+ source: "web",
1800
+ status: "info",
1801
+ type: "agent_update_input_required",
1802
+ agentId: job.agentId,
1803
+ threadId: null,
1804
+ workspace: this.config.workspace,
1805
+ actor,
1806
+ detail: `${job.agentLabel} ${job.operation} may require input.`,
1807
+ });
1808
+ }
1809
+ if (job.status !== "running" && previous?.status === "running") {
1810
+ this.appendActivity({
1811
+ source: "web",
1812
+ status: job.status === "completed" ? "completed" : job.status === "cancelled" ? "aborted" : "failed",
1813
+ type: job.operation === "install" ? `agent_install_${job.status}` : `agent_update_${job.status}`,
1814
+ agentId: job.agentId,
1815
+ threadId: null,
1816
+ workspace: this.config.workspace,
1817
+ actor,
1818
+ detail: job.error ?? `${job.agentLabel} ${job.operation} ${job.status}.`,
1819
+ durationMs: Math.max(0, Date.parse(job.finishedAt ?? job.updatedAt) - Date.parse(job.startedAt)),
1820
+ });
1821
+ this.agentUpdateActors.delete(job.id);
1822
+ }
1823
+ this.agentUpdateStates.set(job.id, { status: job.status, needsInput: job.needsInput });
1824
+ }
1207
1825
  appendActivity(input) {
1208
1826
  const event = this.activityStore.append(this.enrichActivityInput(input));
1209
1827
  this.broadcast({ type: "activity_update", events: this.activity({ limit: 50 }) });
@@ -1341,6 +1959,123 @@ function hostLogoutCommand(info, config) {
1341
1959
  }
1342
1960
  return "codex logout";
1343
1961
  }
1962
+ function activeSessionSourceForContext(contextKey) {
1963
+ const channelId = channelIdForContextKey(contextKey);
1964
+ if (channelId === "telegram") {
1965
+ return "telegram";
1966
+ }
1967
+ if (channelId === "discord") {
1968
+ return "discord";
1969
+ }
1970
+ if (channelId === "web") {
1971
+ return "web";
1972
+ }
1973
+ return "cli";
1974
+ }
1975
+ function activeSessionPriority(session) {
1976
+ if (session.status === "running") {
1977
+ return 3;
1978
+ }
1979
+ return session.contextKey.startsWith("cli:") ? 1 : 2;
1980
+ }
1981
+ function isPromptTerminalActivity(event) {
1982
+ return event.status === "completed" ||
1983
+ event.status === "failed" ||
1984
+ event.status === "aborted" ||
1985
+ event.type === "prompt_completed" ||
1986
+ event.type === "prompt_failed" ||
1987
+ event.type === "prompt_aborted";
1988
+ }
1989
+ function taskToUnifiedJob(id, kind, title, task, options) {
1990
+ return {
1991
+ id,
1992
+ kind,
1993
+ title,
1994
+ status: task.status,
1995
+ source: task.source,
1996
+ agentId: task.agentId,
1997
+ agentLabel: task.agentLabel,
1998
+ threadId: task.threadId,
1999
+ workspace: task.workspace,
2000
+ startedAt: task.startedAt,
2001
+ updatedAt: task.updatedAt,
2002
+ durationMs: task.durationMs,
2003
+ summary: task.prompt || task.detail,
2004
+ logTail: task.currentTool || task.lastTool ? `Current tool: ${task.currentTool ?? "-"}\nLast tool: ${task.lastTool ?? "-"}` : undefined,
2005
+ ...options,
2006
+ };
2007
+ }
2008
+ function activityToUnifiedJob(event, kind, title, options) {
2009
+ return {
2010
+ id: `${kind}:${event.id}`,
2011
+ kind,
2012
+ title,
2013
+ status: event.status,
2014
+ source: event.source,
2015
+ agentId: event.agentId,
2016
+ threadId: event.threadId,
2017
+ workspace: event.workspace,
2018
+ owner: event.actor,
2019
+ startedAt: event.timestamp,
2020
+ updatedAt: event.timestamp,
2021
+ finishedAt: event.timestamp,
2022
+ durationMs: event.durationMs,
2023
+ summary: event.prompt || event.detail,
2024
+ logPath: event.detail,
2025
+ logTail: event.detail,
2026
+ ...options,
2027
+ };
2028
+ }
2029
+ function promptActivityToUnifiedJob(event) {
2030
+ const status = event.status === "info" ? "completed" : event.status;
2031
+ const sourceLabel = event.source === "web"
2032
+ ? "WebUI"
2033
+ : event.source === "telegram"
2034
+ ? "Telegram"
2035
+ : event.source === "discord"
2036
+ ? "Discord"
2037
+ : "CLI";
2038
+ const promptKey = event.threadId ?? event.contextKey ?? event.id;
2039
+ return {
2040
+ id: `prompt:${event.source}:${promptKey}:${event.id}`,
2041
+ kind: event.source === "cli" ? "external-turn" : "web-turn",
2042
+ title: `${sourceLabel} prompt`,
2043
+ status,
2044
+ source: event.source,
2045
+ agentId: event.agentId,
2046
+ threadId: event.threadId,
2047
+ workspace: event.workspace,
2048
+ owner: event.actor,
2049
+ startedAt: event.timestamp,
2050
+ updatedAt: event.timestamp,
2051
+ finishedAt: status === "running" || status === "queued" ? undefined : event.timestamp,
2052
+ durationMs: event.durationMs,
2053
+ summary: event.prompt || event.detail,
2054
+ logTail: event.detail,
2055
+ canCancel: status === "running" && event.source === "web",
2056
+ canRetry: status !== "running",
2057
+ canReadLog: Boolean(event.detail || event.prompt),
2058
+ };
2059
+ }
2060
+ function agentUpdateStatusToUnified(status) {
2061
+ if (status === "cancelled")
2062
+ return "aborted";
2063
+ if (status === "running")
2064
+ return "running";
2065
+ if (status === "completed")
2066
+ return "completed";
2067
+ return "failed";
2068
+ }
2069
+ function dedupeJobs(jobs) {
2070
+ const seen = new Set();
2071
+ return jobs.filter((job) => {
2072
+ if (seen.has(job.id)) {
2073
+ return false;
2074
+ }
2075
+ seen.add(job.id);
2076
+ return true;
2077
+ });
2078
+ }
1344
2079
  function normalizeMimeType(value, name) {
1345
2080
  const configured = value?.trim();
1346
2081
  if (configured) {