@nordbyte/nordrelay 0.5.2 → 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 (52) hide show
  1. package/.env.example +63 -11
  2. package/README.md +90 -19
  3. package/dist/access-control.js +1 -0
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-rendering.js +10 -7
  7. package/dist/bot.js +458 -5
  8. package/dist/channel-actions.js +7 -2
  9. package/dist/channel-adapter.js +34 -7
  10. package/dist/channel-command-service.js +156 -0
  11. package/dist/channel-turn-service.js +237 -0
  12. package/dist/config-metadata.js +78 -13
  13. package/dist/config.js +77 -7
  14. package/dist/context-key.js +77 -5
  15. package/dist/discord-artifacts.js +165 -0
  16. package/dist/discord-bot.js +2014 -0
  17. package/dist/discord-channel-runtime.js +133 -0
  18. package/dist/discord-command-surface.js +119 -0
  19. package/dist/discord-rate-limit.js +141 -0
  20. package/dist/index.js +16 -5
  21. package/dist/job-store.js +127 -0
  22. package/dist/metrics.js +41 -0
  23. package/dist/relay-external-activity-monitor.js +47 -6
  24. package/dist/relay-runtime.js +986 -281
  25. package/dist/runtime-cache.js +57 -0
  26. package/dist/session-locks.js +10 -7
  27. package/dist/support-bundle.js +1 -0
  28. package/dist/telegram-access-commands.js +15 -2
  29. package/dist/telegram-access-middleware.js +16 -3
  30. package/dist/telegram-agent-commands.js +25 -0
  31. package/dist/telegram-artifact-commands.js +46 -0
  32. package/dist/telegram-diagnostics-command.js +5 -50
  33. package/dist/telegram-general-commands.js +2 -6
  34. package/dist/telegram-operational-commands.js +14 -6
  35. package/dist/telegram-queue-commands.js +74 -4
  36. package/dist/telegram-support-command.js +7 -0
  37. package/dist/telegram-update-commands.js +27 -0
  38. package/dist/user-management.js +208 -0
  39. package/dist/web-api-contract.js +9 -0
  40. package/dist/web-dashboard-access-routes.js +74 -1
  41. package/dist/web-dashboard-artifact-routes.js +3 -3
  42. package/dist/web-dashboard-assets.js +2 -0
  43. package/dist/web-dashboard-pages.js +97 -13
  44. package/dist/web-dashboard-runtime-routes.js +53 -8
  45. package/dist/web-dashboard-session-routes.js +27 -20
  46. package/dist/web-dashboard-ui.js +1 -0
  47. package/dist/web-dashboard.js +148 -6
  48. package/dist/web-state.js +33 -2
  49. package/dist/webui-assets/dashboard.css +75 -1
  50. package/dist/webui-assets/dashboard.js +358 -47
  51. package/package.json +3 -1
  52. package/plugins/nordrelay/scripts/nordrelay.mjs +210 -17
@@ -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);
@@ -127,19 +175,22 @@ export class RelayRuntime {
127
175
  };
128
176
  }
129
177
  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
- };
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
+ });
141
191
  }
142
- updateConnector() {
192
+ updateConnector(actor) {
193
+ this.cache.invalidate("version");
143
194
  const update = spawnSelfUpdate();
144
195
  this.broadcastStatus(`Update started with ${update.method}. Log: ${update.logPath}`, "warn");
145
196
  this.appendActivity({
@@ -148,12 +199,14 @@ export class RelayRuntime {
148
199
  type: "update_started",
149
200
  threadId: null,
150
201
  workspace: this.config.workspace,
202
+ actor,
151
203
  detail: `${update.method}: ${update.summary}`,
152
204
  });
153
205
  this.appendAudit({
154
206
  action: "command",
155
207
  status: "ok",
156
208
  contextKey: WEB_CONTEXT_KEY,
209
+ actor,
157
210
  description: "update",
158
211
  detail: update.summary,
159
212
  });
@@ -162,13 +215,19 @@ export class RelayRuntime {
162
215
  agentUpdateJobs() {
163
216
  return this.agentUpdates.list();
164
217
  }
165
- startAgentUpdate(agentId, operation = "update") {
218
+ startAgentUpdate(agentId, operation = "update", actor) {
219
+ this.cache.invalidate("adapterHealth");
220
+ this.cache.invalidate("version");
166
221
  const job = this.agentUpdates.start(agentId, {
167
222
  piCliPath: this.config.piCliPath,
168
223
  hermesCliPath: this.config.hermesCliPath,
169
224
  openClawCliPath: this.config.openClawCliPath,
170
225
  claudeCodeCliPath: this.config.claudeCodeCliPath,
171
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 });
172
231
  this.broadcastStatus(`${job.agentLabel} ${operation} started. Log: ${job.logPath}`, "warn");
173
232
  this.appendActivity({
174
233
  source: "web",
@@ -177,6 +236,7 @@ export class RelayRuntime {
177
236
  agentId,
178
237
  threadId: null,
179
238
  workspace: this.config.workspace,
239
+ actor,
180
240
  detail: `${job.method}: ${job.summary}`,
181
241
  });
182
242
  this.appendAudit({
@@ -184,6 +244,7 @@ export class RelayRuntime {
184
244
  status: "ok",
185
245
  contextKey: WEB_CONTEXT_KEY,
186
246
  agentId,
247
+ actor,
187
248
  description: `${operation} ${agentId}`,
188
249
  detail: job.summary,
189
250
  });
@@ -192,93 +253,130 @@ export class RelayRuntime {
192
253
  agentUpdateLog(id) {
193
254
  return this.agentUpdates.readLog(id);
194
255
  }
195
- deleteAgentUpdateLog(id) {
256
+ deleteAgentUpdateLog(id, actor) {
196
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
+ });
197
268
  this.appendAudit({
198
269
  action: "command",
199
270
  status: "ok",
200
271
  contextKey: WEB_CONTEXT_KEY,
201
272
  agentId: job.agentId,
273
+ actor,
202
274
  description: `delete update log ${id}`,
203
275
  detail: job.logPath,
204
276
  });
205
277
  return job;
206
278
  }
207
- sendAgentUpdateInput(id, input) {
208
- 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;
209
292
  }
210
- cancelAgentUpdate(id) {
211
- 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;
212
306
  }
213
307
  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);
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
+ ]);
255
316
  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,
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),
265
326
  },
266
- cli,
267
- version: {
268
- installed: version.installedLabel,
269
- latest: version.latestVersion,
270
- status: version.status,
271
- detail: version.detail,
272
- },
273
- capabilities: descriptor.capabilities,
274
- notes: descriptor.notes,
275
327
  };
276
- }));
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
+ });
277
375
  }
278
376
  permissions() {
279
377
  return {
280
378
  mode: "users",
281
- 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.",
282
380
  };
283
381
  }
284
382
  tasks() {
@@ -290,10 +388,229 @@ export class RelayRuntime {
290
388
  recent: this.activity({ limit: 20 }),
291
389
  };
292
390
  }
293
- audit(limit = 50) {
294
- 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
+ };
479
+ }
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
+ };
295
596
  }
296
- async supportBundle() {
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) {
297
614
  const bundle = await createSupportBundle({
298
615
  config: this.config,
299
616
  diagnostics: await this.diagnostics(),
@@ -308,12 +625,14 @@ export class RelayRuntime {
308
625
  type: "diagnostics_bundle_exported",
309
626
  threadId: null,
310
627
  workspace: this.config.workspace,
628
+ actor,
311
629
  detail: bundle.path,
312
630
  });
313
631
  this.appendAudit({
314
632
  action: "command",
315
633
  status: "ok",
316
634
  contextKey: WEB_CONTEXT_KEY,
635
+ actor,
317
636
  description: "export diagnostics bundle",
318
637
  detail: bundle.path,
319
638
  });
@@ -322,23 +641,48 @@ export class RelayRuntime {
322
641
  locks() {
323
642
  return this.lockStore.list();
324
643
  }
325
- lockWebSession(ownerName = "Web dashboard") {
326
- 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
+ });
327
660
  this.appendAudit({
328
661
  action: "lock_updated",
329
662
  status: "ok",
330
663
  contextKey: WEB_CONTEXT_KEY,
664
+ actor,
331
665
  description: "lock",
332
- detail: `locked by ${ownerName}`,
666
+ detail: `locked by ${label}`,
333
667
  });
334
668
  return lock;
335
669
  }
336
- unlockWebSession() {
670
+ unlockWebSession(actor) {
337
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
+ });
338
681
  this.appendAudit({
339
682
  action: "lock_updated",
340
683
  status: "ok",
341
684
  contextKey: WEB_CONTEXT_KEY,
685
+ actor,
342
686
  description: "unlock",
343
687
  detail: removed ? "unlocked" : "no lock",
344
688
  });
@@ -407,7 +751,7 @@ export class RelayRuntime {
407
751
  }
408
752
  }
409
753
  }
410
- async login(agentId) {
754
+ async login(agentId, actor) {
411
755
  const { session, dispose } = await this.getControlSession(agentId);
412
756
  try {
413
757
  const info = this.publicInfo(session);
@@ -431,6 +775,16 @@ export class RelayRuntime {
431
775
  };
432
776
  }
433
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
+ });
434
788
  this.appendAudit({
435
789
  action: "command",
436
790
  status: result.success ? "ok" : "failed",
@@ -438,6 +792,7 @@ export class RelayRuntime {
438
792
  agentId: info.agentId,
439
793
  threadId: info.threadId,
440
794
  workspace: info.workspace,
795
+ actor,
441
796
  description: "login",
442
797
  detail: result.message,
443
798
  });
@@ -449,7 +804,7 @@ export class RelayRuntime {
449
804
  }
450
805
  }
451
806
  }
452
- async logout(agentId) {
807
+ async logout(agentId, actor) {
453
808
  const { session, dispose } = await this.getControlSession(agentId);
454
809
  try {
455
810
  const info = this.publicInfo(session);
@@ -483,6 +838,16 @@ export class RelayRuntime {
483
838
  };
484
839
  }
485
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
+ });
486
851
  this.appendAudit({
487
852
  action: "command",
488
853
  status: result.success ? "ok" : "failed",
@@ -490,6 +855,7 @@ export class RelayRuntime {
490
855
  agentId: info.agentId,
491
856
  threadId: info.threadId,
492
857
  workspace: info.workspace,
858
+ actor,
493
859
  description: "logout",
494
860
  detail: result.message,
495
861
  });
@@ -517,18 +883,29 @@ export class RelayRuntime {
517
883
  activity: this.activity({ limit: 100 }).filter((event) => event.threadId === threadId),
518
884
  };
519
885
  }
520
- async clearChatHistory() {
886
+ async clearChatHistory(actor) {
521
887
  const session = await this.getSession(true);
522
- const removed = this.chatStore.clear(this.publicInfo(session).threadId);
888
+ const info = this.publicInfo(session);
889
+ const removed = this.chatStore.clear(info.threadId);
523
890
  const messages = await this.chatHistory();
524
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
+ });
525
902
  return { removed, messages };
526
903
  }
527
904
  activity(options = {}) {
528
905
  const currentInfo = this.registry.get(WEB_CONTEXT_KEY)?.getInfo();
529
906
  return this.activityStore.list(options).map((event) => this.enrichActivityEvent(event, currentInfo));
530
907
  }
531
- async retry() {
908
+ async retry(actor) {
532
909
  const cached = this.queueService.getLastPrompt();
533
910
  if (!cached) {
534
911
  throw new Error("Nothing to retry. Send a message first.");
@@ -537,12 +914,13 @@ export class RelayRuntime {
537
914
  action: "command",
538
915
  status: "ok",
539
916
  contextKey: WEB_CONTEXT_KEY,
917
+ actor,
540
918
  description: "retry",
541
919
  detail: cached.description,
542
920
  });
543
- return this.sendEnvelope(cached);
921
+ return this.sendEnvelope({ ...cached, activityActor: cached.activityActor ?? actor }, actor);
544
922
  }
545
- async sync() {
923
+ async sync(actor) {
546
924
  const session = await this.getSession(true);
547
925
  const info = this.publicInfo(session);
548
926
  if (!(info.capabilities ?? CODEX_AGENT_CAPABILITIES).externalActivity) {
@@ -559,6 +937,7 @@ export class RelayRuntime {
559
937
  threadId: result.info.threadId,
560
938
  workspace: result.info.workspace,
561
939
  agentId: result.info.agentId,
940
+ actor,
562
941
  detail: result.changedFields.length > 0 ? result.changedFields.join(", ") : "already in sync",
563
942
  });
564
943
  this.appendAudit({
@@ -568,6 +947,7 @@ export class RelayRuntime {
568
947
  agentId: result.info.agentId,
569
948
  threadId: result.info.threadId,
570
949
  workspace: result.info.workspace,
950
+ actor,
571
951
  description: "sync",
572
952
  detail: result.changedFields.join(", ") || "none",
573
953
  });
@@ -634,15 +1014,26 @@ export class RelayRuntime {
634
1014
  });
635
1015
  return session.listModels();
636
1016
  }
637
- async setAgent(agentId) {
1017
+ async setAgent(agentId, actor) {
638
1018
  if (!enabledAgents(this.config).includes(agentId)) {
639
1019
  throw new Error(`Agent is not enabled: ${agentId}`);
640
1020
  }
641
1021
  const session = await this.registry.switchAgent(WEB_CONTEXT_KEY, agentId);
642
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
+ });
643
1034
  return this.publicInfo(session);
644
1035
  }
645
- async newSession(options = {}) {
1036
+ async newSession(options = {}, actor) {
646
1037
  const session = options.agentId ? await this.registry.switchAgent(WEB_CONTEXT_KEY, options.agentId) : await this.getSession(true);
647
1038
  this.ensureIdle(session);
648
1039
  if (options.reasoningEffort) {
@@ -667,11 +1058,12 @@ export class RelayRuntime {
667
1058
  threadId: info.threadId,
668
1059
  workspace: info.workspace,
669
1060
  agentId: info.agentId,
1061
+ actor,
670
1062
  detail: "New dashboard session created.",
671
1063
  });
672
1064
  return this.publicInfo(session);
673
1065
  }
674
- async switchSession(threadId) {
1066
+ async switchSession(threadId, actor) {
675
1067
  const session = await this.getSession(true);
676
1068
  this.ensureIdle(session);
677
1069
  const info = await session.switchSession(threadId);
@@ -684,21 +1076,24 @@ export class RelayRuntime {
684
1076
  threadId: info.threadId,
685
1077
  workspace: info.workspace,
686
1078
  agentId: info.agentId,
1079
+ actor,
687
1080
  detail: "Dashboard switched session.",
688
1081
  });
689
1082
  return this.publicInfo(session);
690
1083
  }
691
- async attachSession(threadId) {
692
- return this.switchSession(threadId);
1084
+ async attachSession(threadId, actor) {
1085
+ return this.switchSession(threadId, actor);
693
1086
  }
694
- async setModel(model) {
1087
+ async setModel(model, actor) {
695
1088
  const session = await this.getSession(true);
696
1089
  this.ensureIdle(session);
697
1090
  await session.setModelForCurrentSession(model);
698
1091
  this.updateSession(session);
699
- 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;
700
1095
  }
701
- async setReasoningEffort(effort) {
1096
+ async setReasoningEffort(effort, actor) {
702
1097
  const session = await this.getSession(true);
703
1098
  this.ensureIdle(session);
704
1099
  const options = agentReasoningOptions(session.getInfo().agentId);
@@ -707,9 +1102,11 @@ export class RelayRuntime {
707
1102
  }
708
1103
  await session.setReasoningEffortForCurrentSession(effort);
709
1104
  this.updateSession(session);
710
- 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;
711
1108
  }
712
- async setFastMode(enabled) {
1109
+ async setFastMode(enabled, actor) {
713
1110
  const session = await this.getSession(true);
714
1111
  this.ensureIdle(session);
715
1112
  if (!(session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).fastMode) {
@@ -717,23 +1114,29 @@ export class RelayRuntime {
717
1114
  }
718
1115
  session.setFastMode(enabled);
719
1116
  this.updateSession(session);
720
- 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;
721
1120
  }
722
- async setLaunchProfile(profileId) {
1121
+ async setLaunchProfile(profileId, actor) {
723
1122
  const session = await this.getSession(true);
724
1123
  this.ensureIdle(session);
725
1124
  session.setLaunchProfile(profileId);
726
1125
  this.updateSession(session);
727
- 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;
728
1129
  }
729
- async handback() {
1130
+ async handback(actor) {
730
1131
  const session = await this.getSession(true);
731
1132
  this.ensureIdle(session);
732
1133
  const result = session.handback();
733
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." });
734
1137
  return result;
735
1138
  }
736
- async abort() {
1139
+ async abort(actor) {
737
1140
  const session = await this.getSession(true);
738
1141
  const snapshot = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
739
1142
  if (snapshot?.activity.active && !session.isProcessing()) {
@@ -743,19 +1146,23 @@ export class RelayRuntime {
743
1146
  message: `Cannot abort the external ${snapshot.agentLabel} CLI task from NordRelay. Stop it in the terminal where it is running.`,
744
1147
  at: new Date().toISOString(),
745
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.` });
746
1151
  return;
747
1152
  }
748
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." });
749
1156
  this.broadcast({ type: "status", level: "warn", message: "Current operation aborted.", at: new Date().toISOString() });
750
1157
  }
751
- async sendPrompt(text) {
1158
+ async sendPrompt(text, actor) {
752
1159
  const trimmed = text.trim();
753
1160
  if (!trimmed) {
754
1161
  throw new Error("Prompt is empty.");
755
1162
  }
756
- return this.sendEnvelope(toPromptEnvelope(trimmed));
1163
+ return this.sendEnvelope({ ...toPromptEnvelope(trimmed), activityActor: actor }, actor);
757
1164
  }
758
- async sendUploadPrompt(options) {
1165
+ async sendUploadPrompt(options, actor) {
759
1166
  const text = options.text?.trim() ?? "";
760
1167
  const files = options.files.filter((file) => file.data.byteLength > 0);
761
1168
  if (!text && files.length === 0) {
@@ -790,9 +1197,32 @@ export class RelayRuntime {
790
1197
  const transcript = result.text.trim();
791
1198
  if (transcript) {
792
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
+ });
793
1211
  }
794
1212
  }
795
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
+ }
796
1226
  const audioOnly = stagedFiles.length > 0 && stagedFiles.every((file) => file.mimeType.startsWith("audio/"));
797
1227
  if (this.config.voiceTranscribeOnly && audioOnly && !text) {
798
1228
  return {
@@ -813,14 +1243,15 @@ export class RelayRuntime {
813
1243
  if (stagedFiles.length > 0) {
814
1244
  promptInput.stagedFileInstructions = buildFileInstructions(stagedFiles, outDir);
815
1245
  }
816
- const result = await this.sendEnvelope(toPromptEnvelope(promptInput, outDir));
1246
+ const result = await this.sendEnvelope({ ...toPromptEnvelope(promptInput, outDir), activityActor: actor }, actor);
817
1247
  return {
818
1248
  ...result,
819
1249
  transcript: transcriptParts.join("\n\n") || undefined,
820
1250
  files: uploadFileDtos(stagedFiles),
821
1251
  };
822
1252
  }
823
- async sendEnvelope(envelope) {
1253
+ async sendEnvelope(envelope, actor) {
1254
+ const activityActor = envelope.activityActor ?? actor;
824
1255
  const session = await this.getSession(false);
825
1256
  const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
826
1257
  if (session.isProcessing() || external?.activity.active) {
@@ -833,6 +1264,7 @@ export class RelayRuntime {
833
1264
  threadId: info.threadId,
834
1265
  workspace: info.workspace,
835
1266
  agentId: info.agentId,
1267
+ actor: activityActor,
836
1268
  prompt: envelope.description,
837
1269
  detail: external?.activity.active
838
1270
  ? `Queued because ${external.agentLabel} CLI is still processing another task.`
@@ -846,6 +1278,7 @@ export class RelayRuntime {
846
1278
  threadId: info.threadId,
847
1279
  workspace: info.workspace,
848
1280
  promptId: queued.id,
1281
+ actor: activityActor,
849
1282
  description: envelope.description,
850
1283
  });
851
1284
  if (external?.activity.active) {
@@ -854,7 +1287,7 @@ export class RelayRuntime {
854
1287
  this.broadcastQueue();
855
1288
  return { queued: true, queueId: queued.id };
856
1289
  }
857
- void this.runPrompt(session, envelope).catch((error) => {
1290
+ void this.runPrompt(session, { ...envelope, activityActor }).catch((error) => {
858
1291
  this.broadcast({ type: "turn_error", id: this.currentTurnId ?? "turn", error: friendlyErrorText(error), at: new Date().toISOString() });
859
1292
  });
860
1293
  return { queued: false };
@@ -865,7 +1298,9 @@ export class RelayRuntime {
865
1298
  queuePaused() {
866
1299
  return this.queueService.isPaused();
867
1300
  }
868
- 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;
869
1304
  this.queueService.apply(action, id);
870
1305
  if (id && action === "run") {
871
1306
  void this.drainQueue().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
@@ -873,15 +1308,18 @@ export class RelayRuntime {
873
1308
  this.appendActivity({
874
1309
  source: "web",
875
1310
  status: "info",
876
- type: "queue_updated",
1311
+ type: `queue_${action}`,
877
1312
  threadId: null,
878
1313
  workspace: this.config.workspace,
879
- detail: id ? `${action}: ${id}` : action,
1314
+ actor,
1315
+ prompt: affected?.description,
1316
+ detail: id ? `${action}: ${id}` : `${action}: ${before.length} queued`,
880
1317
  });
881
1318
  this.appendAudit({
882
1319
  action: "queue_updated",
883
1320
  status: "ok",
884
1321
  contextKey: WEB_CONTEXT_KEY,
1322
+ actor,
885
1323
  description: id ? `${action}: ${id}` : action,
886
1324
  });
887
1325
  this.broadcastQueue();
@@ -895,13 +1333,39 @@ export class RelayRuntime {
895
1333
  const session = await this.getSession(true);
896
1334
  return this.artifactService.get(session.getInfo().workspace, turnId);
897
1335
  }
898
- async deleteArtifact(turnId) {
1336
+ async deleteArtifact(turnId, actor) {
899
1337
  const session = await this.getSession(true);
900
- 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;
901
1351
  }
902
- async createArtifactZip(turnId) {
1352
+ async createArtifactZip(turnId, actor) {
903
1353
  const session = await this.getSession(true);
904
- 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;
905
1369
  }
906
1370
  async artifactPreview(turnId, relativePath) {
907
1371
  const session = await this.getSession(true);
@@ -916,7 +1380,7 @@ export class RelayRuntime {
916
1380
  }
917
1381
  return readFormattedLogTail(lines);
918
1382
  }
919
- clearLogs(target = "connector") {
1383
+ clearLogs(target = "connector", actor) {
920
1384
  const result = clearLogFile(target === "update" ? getUpdateLogPath() : target === "agent-updates" ? getAgentUpdateLogPath() : getConnectorLogPath());
921
1385
  this.appendActivity({
922
1386
  source: "web",
@@ -924,11 +1388,20 @@ export class RelayRuntime {
924
1388
  type: "logs_cleared",
925
1389
  threadId: null,
926
1390
  workspace: this.config.workspace,
1391
+ actor,
927
1392
  detail: `Cleared ${target} log.`,
928
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
+ });
929
1402
  return { ok: true, filePath: result.filePath, clearedAt: result.clearedAt.toISOString() };
930
1403
  }
931
- restartConnector() {
1404
+ restartConnector(actor) {
932
1405
  spawnConnectorRestart();
933
1406
  this.broadcastStatus("Restart requested. The dashboard may disconnect briefly.", "warn");
934
1407
  this.appendActivity({
@@ -937,8 +1410,16 @@ export class RelayRuntime {
937
1410
  type: "restart_requested",
938
1411
  threadId: null,
939
1412
  workspace: this.config.workspace,
1413
+ actor,
940
1414
  detail: "Dashboard requested a connector restart.",
941
1415
  });
1416
+ this.appendAudit({
1417
+ action: "command",
1418
+ status: "ok",
1419
+ contextKey: WEB_CONTEXT_KEY,
1420
+ actor,
1421
+ description: "restart connector",
1422
+ });
942
1423
  return { ok: true, message: "Restart requested." };
943
1424
  }
944
1425
  dispose() {
@@ -952,6 +1433,234 @@ export class RelayRuntime {
952
1433
  async getSession(deferThreadStart) {
953
1434
  return this.registry.getOrCreate(WEB_CONTEXT_KEY, { deferThreadStart });
954
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
+ }
955
1664
  async getControlSession(agentId) {
956
1665
  const active = await this.getSession(true);
957
1666
  const activeInfo = this.publicInfo(active);
@@ -1039,169 +1748,14 @@ export class RelayRuntime {
1039
1748
  }
1040
1749
  }
1041
1750
  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
1751
  const workspacePolicy = evaluateWorkspacePolicy(session.getInfo().workspace, this.config);
1051
1752
  if (!workspacePolicy.allowed) {
1052
1753
  throw new Error(workspacePolicy.warning ?? "Current workspace is blocked by policy.");
1053
1754
  }
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
1755
  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;
1756
+ await this.turnService.run(session, envelope);
1198
1757
  }
1199
1758
  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
1759
  await this.drainQueue();
1206
1760
  }
1207
1761
  }
@@ -1234,6 +1788,40 @@ export class RelayRuntime {
1234
1788
  this.registry.updateMetadata(WEB_CONTEXT_KEY, session);
1235
1789
  this.broadcast({ type: "session_update", session: this.publicInfo(session) });
1236
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
+ }
1237
1825
  appendActivity(input) {
1238
1826
  const event = this.activityStore.append(this.enrichActivityInput(input));
1239
1827
  this.broadcast({ type: "activity_update", events: this.activity({ limit: 50 }) });
@@ -1371,6 +1959,123 @@ function hostLogoutCommand(info, config) {
1371
1959
  }
1372
1960
  return "codex logout";
1373
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
+ }
1374
2079
  function normalizeMimeType(value, name) {
1375
2080
  const configured = value?.trim();
1376
2081
  if (configured) {