@nordbyte/nordrelay 0.3.1 → 0.4.1

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 (55) hide show
  1. package/.env.example +45 -2
  2. package/README.md +221 -35
  3. package/dist/access-control.js +3 -0
  4. package/dist/agent-activity.js +300 -0
  5. package/dist/agent-adapter.js +17 -30
  6. package/dist/agent-factory.js +27 -0
  7. package/dist/agent-feature-matrix.js +42 -0
  8. package/dist/agent-updates.js +294 -0
  9. package/dist/agent.js +123 -9
  10. package/dist/artifacts.js +1 -1
  11. package/dist/audit-log.js +1 -1
  12. package/dist/bot-ui.js +1 -1
  13. package/dist/bot.js +483 -354
  14. package/dist/channel-actions.js +372 -0
  15. package/dist/claude-code-auth.js +121 -0
  16. package/dist/claude-code-cli.js +19 -0
  17. package/dist/claude-code-launch.js +73 -0
  18. package/dist/claude-code-session.js +660 -0
  19. package/dist/claude-code-state.js +590 -0
  20. package/dist/codex-session.js +12 -1
  21. package/dist/config.js +113 -9
  22. package/dist/hermes-api.js +150 -0
  23. package/dist/hermes-auth.js +96 -0
  24. package/dist/hermes-cli.js +19 -0
  25. package/dist/hermes-launch.js +57 -0
  26. package/dist/hermes-session.js +477 -0
  27. package/dist/hermes-state.js +609 -0
  28. package/dist/index.js +51 -8
  29. package/dist/openclaw-auth.js +27 -0
  30. package/dist/openclaw-cli.js +19 -0
  31. package/dist/openclaw-gateway.js +285 -0
  32. package/dist/openclaw-launch.js +65 -0
  33. package/dist/openclaw-session.js +549 -0
  34. package/dist/openclaw-state.js +409 -0
  35. package/dist/operations.js +115 -9
  36. package/dist/pi-auth.js +59 -0
  37. package/dist/pi-launch.js +61 -0
  38. package/dist/pi-rpc.js +18 -0
  39. package/dist/pi-session.js +103 -15
  40. package/dist/pi-state.js +253 -0
  41. package/dist/relay-runtime.js +798 -72
  42. package/dist/session-format.js +98 -19
  43. package/dist/session-registry.js +40 -15
  44. package/dist/settings-service.js +35 -4
  45. package/dist/web-dashboard-assets.js +2 -0
  46. package/dist/web-dashboard-client.js +275 -0
  47. package/dist/web-dashboard-style.js +9 -0
  48. package/dist/web-dashboard-ui.js +18 -0
  49. package/dist/web-dashboard.js +296 -196
  50. package/package.json +8 -3
  51. package/plugins/nordrelay/.codex-plugin/plugin.json +7 -4
  52. package/plugins/nordrelay/commands/remote.md +2 -2
  53. package/plugins/nordrelay/scripts/nordrelay.mjs +187 -12
  54. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +2 -2
  55. package/CHANGELOG.md +0 -26
@@ -1,16 +1,24 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { createArtifactZipBundle, getArtifactTurnReport, ensureOutDir, listRecentArtifactReports, removeArtifactTurn, totalArtifactSize, } from "./artifacts.js";
4
+ import { createArtifactZipBundle, collectRecentWorkspaceArtifacts, getArtifactTurnReport, ensureOutDir, listRecentArtifactReports, persistWorkspaceArtifactReport, removeArtifactTurn, totalArtifactSize, } from "./artifacts.js";
5
5
  import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
6
- import { CODEX_AGENT_CAPABILITIES, CODEX_REASONING_EFFORTS, PI_THINKING_LEVELS, agentLabel, agentReasoningLabel, } from "./agent.js";
7
- import { enabledAgents } from "./agent-factory.js";
8
- import { checkAuthStatus } from "./codex-auth.js";
9
- import { getThreadRolloutSnapshot, } from "./codex-state.js";
6
+ import { CODEX_AGENT_CAPABILITIES, agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
7
+ import { getAgentDiagnostics, getExternalSnapshotForSession, } from "./agent-activity.js";
8
+ import { listAgentAdapterDescriptors } from "./agent-adapter.js";
9
+ import { AgentUpdateManager } from "./agent-updates.js";
10
+ import { createAgentSessionService, enabledAgents } from "./agent-factory.js";
11
+ import { AuditLogStore } from "./audit-log.js";
12
+ import { checkAuthStatus, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
13
+ import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "./claude-code-auth.js";
10
14
  import { friendlyErrorText } from "./error-messages.js";
11
- import { getConnectorHealth, getVersionChecks, readFormattedLogTail, spawnConnectorRestart } from "./operations.js";
15
+ import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
16
+ import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
17
+ import { clearLogFile, getAgentUpdateLogPath, getConnectorHealth, getConnectorLogPath, getPackageVersion, getUpdateLogPath, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate } from "./operations.js";
18
+ import { checkPiAuthStatus } from "./pi-auth.js";
12
19
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
13
- import { renderSessionInfoPlain } from "./session-format.js";
20
+ import { renderSessionInfoPlain, renderSessionUsageRows } from "./session-format.js";
21
+ import { SessionLockStore } from "./session-locks.js";
14
22
  import { SessionRegistry } from "./session-registry.js";
15
23
  import { transcribeAudio } from "./voice.js";
16
24
  import { WebActivityStore, WebChatStore, } from "./web-state.js";
@@ -25,12 +33,16 @@ export class RelayRuntime {
25
33
  promptStore;
26
34
  chatStore;
27
35
  activityStore;
36
+ auditStore;
37
+ lockStore;
38
+ agentUpdates;
28
39
  subscribers = new Set();
29
40
  externalMonitor;
30
41
  draining = false;
31
42
  currentTurnId = null;
32
43
  accumulatedText = "";
33
44
  currentTurnStartedAt = 0;
45
+ currentProgress = null;
34
46
  externalMirror = null;
35
47
  constructor(config) {
36
48
  this.config = config;
@@ -41,6 +53,11 @@ export class RelayRuntime {
41
53
  this.promptStore = new PromptStore(config.workspace, config.stateBackend);
42
54
  this.chatStore = new WebChatStore(config.workspace, config.stateBackend, MAX_CHAT_HISTORY);
43
55
  this.activityStore = new WebActivityStore(config.workspace, config.stateBackend, config.auditMaxEvents);
56
+ this.auditStore = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
57
+ this.lockStore = new SessionLockStore(config.workspace, config.stateBackend);
58
+ this.agentUpdates = new AgentUpdateManager({
59
+ onUpdate: (job) => this.broadcast({ type: "agent_update", job }),
60
+ });
44
61
  if (config.codexExternalBusyCheckMs > 0) {
45
62
  this.externalMonitor = setInterval(() => {
46
63
  void this.monitorExternalActivity().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
@@ -70,48 +87,361 @@ export class RelayRuntime {
70
87
  }
71
88
  async status() {
72
89
  return {
73
- health: await getConnectorHealth(),
74
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath }),
90
+ health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
91
+ versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
92
+ snapshot: await this.snapshot(),
93
+ };
94
+ }
95
+ async bootstrapStatus() {
96
+ return {
97
+ health: {
98
+ version: await getPackageVersion(),
99
+ state: await readConnectorState(),
100
+ },
75
101
  snapshot: await this.snapshot(),
76
102
  };
77
103
  }
104
+ async version() {
105
+ return {
106
+ health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
107
+ state: await readConnectorState(),
108
+ versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
109
+ };
110
+ }
111
+ updateConnector() {
112
+ const update = spawnSelfUpdate();
113
+ this.broadcastStatus(`Update started with ${update.method}. Log: ${update.logPath}`, "warn");
114
+ this.appendActivity({
115
+ source: "web",
116
+ status: "info",
117
+ type: "update_started",
118
+ threadId: null,
119
+ workspace: this.config.workspace,
120
+ detail: `${update.method}: ${update.summary}`,
121
+ });
122
+ this.appendAudit({
123
+ action: "command",
124
+ status: "ok",
125
+ contextKey: WEB_CONTEXT_KEY,
126
+ description: "update",
127
+ detail: update.summary,
128
+ });
129
+ return update;
130
+ }
131
+ agentUpdateJobs() {
132
+ return this.agentUpdates.list();
133
+ }
134
+ startAgentUpdate(agentId) {
135
+ const job = this.agentUpdates.start(agentId, {
136
+ piCliPath: this.config.piCliPath,
137
+ hermesCliPath: this.config.hermesCliPath,
138
+ openClawCliPath: this.config.openClawCliPath,
139
+ claudeCodeCliPath: this.config.claudeCodeCliPath,
140
+ });
141
+ this.broadcastStatus(`${job.agentLabel} update started. Log: ${job.logPath}`, "warn");
142
+ this.appendActivity({
143
+ source: "web",
144
+ status: "info",
145
+ type: "agent_update_started",
146
+ agentId,
147
+ threadId: null,
148
+ workspace: this.config.workspace,
149
+ detail: `${job.method}: ${job.summary}`,
150
+ });
151
+ this.appendAudit({
152
+ action: "command",
153
+ status: "ok",
154
+ contextKey: WEB_CONTEXT_KEY,
155
+ agentId,
156
+ description: `update ${agentId}`,
157
+ detail: job.summary,
158
+ });
159
+ return job;
160
+ }
161
+ agentUpdateLog(id) {
162
+ return this.agentUpdates.readLog(id);
163
+ }
164
+ sendAgentUpdateInput(id, input) {
165
+ return this.agentUpdates.sendInput(id, input);
166
+ }
167
+ cancelAgentUpdate(id) {
168
+ return this.agentUpdates.cancel(id);
169
+ }
78
170
  async diagnostics() {
79
171
  return {
80
- health: await getConnectorHealth(),
81
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath }),
172
+ health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
173
+ versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
82
174
  snapshot: await this.snapshot(),
83
175
  runtime: {
84
176
  stateBackend: this.config.stateBackend,
85
177
  sourceWorkspace: this.config.workspace,
86
178
  queuePaused: this.promptStore.isPaused(WEB_CONTEXT_KEY),
87
179
  externalMirror: this.externalMirror ? { ...this.externalMirror } : null,
180
+ agentDiagnostics: getAgentDiagnostics(await this.getSession(true), this.config),
88
181
  },
89
182
  };
90
183
  }
91
- async controlOptions() {
92
- const session = await this.getSession(true);
93
- const info = this.publicInfo(session);
94
- const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
95
- return {
96
- models: capabilities.modelSelection ? session.listModels() : [],
97
- reasoningLabel: agentReasoningLabel(info.agentId),
98
- reasoningOptions: info.agentId === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS,
99
- launchProfiles: capabilities.launchProfiles
100
- ? this.config.launchProfiles.map((profile) => ({
101
- id: profile.id,
102
- label: profile.label,
103
- behavior: `${profile.sandboxMode} / ${profile.approvalPolicy}`,
104
- unsafe: profile.unsafe,
184
+ async adapterHealth() {
185
+ const health = await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath });
186
+ const versions = await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath });
187
+ return Promise.all(listAgentAdapterDescriptors().map(async (descriptor) => {
188
+ const enabled = enabledAgents(this.config).includes(descriptor.id);
189
+ const auth = descriptor.capabilities.auth && enabled
190
+ ? await this.authStatus(descriptor.id).catch((error) => ({
191
+ agentId: descriptor.id,
192
+ agentLabel: descriptor.label,
193
+ supported: descriptor.capabilities.auth,
194
+ authenticated: false,
195
+ detail: friendlyErrorText(error),
196
+ loginSupported: descriptor.capabilities.login,
197
+ logoutSupported: descriptor.capabilities.logout,
105
198
  }))
106
- : [],
107
- workspaces: filterAllowedWorkspaces(session.listWorkspaces(), this.config),
108
- capabilities,
199
+ : null;
200
+ const cli = cliHealthForAgent(descriptor.id, health);
201
+ const version = versionCheckForAgent(descriptor.id, versions);
202
+ return {
203
+ id: descriptor.id,
204
+ label: descriptor.label,
205
+ enabled,
206
+ status: descriptor.status === "available" ? (enabled ? "enabled" : "disabled") : "planned",
207
+ auth: {
208
+ supported: descriptor.capabilities.auth,
209
+ authenticated: auth ? auth.authenticated : null,
210
+ method: auth?.method,
211
+ detail: auth?.detail,
212
+ },
213
+ cli,
214
+ version: {
215
+ installed: version.installedLabel,
216
+ latest: version.latestVersion,
217
+ status: version.status,
218
+ detail: version.detail,
219
+ },
220
+ capabilities: descriptor.capabilities,
221
+ notes: descriptor.notes,
222
+ };
223
+ }));
224
+ }
225
+ permissions() {
226
+ return {
227
+ telegramAllowAnyChat: this.config.telegramAllowAnyChat,
228
+ telegramAdminUserIds: this.config.telegramAdminUserIds,
229
+ telegramAllowedUserIds: this.config.telegramAllowedUserIds,
230
+ telegramReadOnlyUserIds: this.config.telegramReadOnlyUserIds,
231
+ telegramAllowedChatIds: this.config.telegramAllowedChatIds,
232
+ telegramRolePolicies: this.config.telegramRolePolicies,
109
233
  };
110
234
  }
235
+ tasks() {
236
+ return {
237
+ current: this.currentProgress ? { ...this.currentProgress, tools: [...this.currentProgress.tools] } : null,
238
+ external: this.externalTask(),
239
+ queue: this.queue(),
240
+ queuePaused: this.queuePaused(),
241
+ recent: this.activity({ limit: 20 }),
242
+ };
243
+ }
244
+ audit(limit = 50) {
245
+ return this.auditStore.list(limit);
246
+ }
247
+ locks() {
248
+ return this.lockStore.list();
249
+ }
250
+ lockWebSession(ownerName = "Web dashboard") {
251
+ const lock = this.lockStore.set(WEB_CONTEXT_KEY, 0, ownerName, this.config.sessionLockTtlMs);
252
+ this.appendAudit({
253
+ action: "lock_updated",
254
+ status: "ok",
255
+ contextKey: WEB_CONTEXT_KEY,
256
+ description: "lock",
257
+ detail: `locked by ${ownerName}`,
258
+ });
259
+ return lock;
260
+ }
261
+ unlockWebSession() {
262
+ const removed = this.lockStore.clear(WEB_CONTEXT_KEY);
263
+ this.appendAudit({
264
+ action: "lock_updated",
265
+ status: "ok",
266
+ contextKey: WEB_CONTEXT_KEY,
267
+ description: "unlock",
268
+ detail: removed ? "unlocked" : "no lock",
269
+ });
270
+ return { removed, locks: this.locks() };
271
+ }
272
+ async controlOptions(agentId) {
273
+ const { session, dispose } = await this.getControlSession(agentId);
274
+ try {
275
+ const info = this.publicInfo(session);
276
+ const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
277
+ if (capabilities.modelSelection) {
278
+ await session.refreshModels().catch((error) => {
279
+ console.warn(`Failed to refresh ${agentLabel(info.agentId)} models: ${error instanceof Error ? error.message : String(error)}`);
280
+ });
281
+ }
282
+ return {
283
+ models: capabilities.modelSelection ? session.listModels() : [],
284
+ reasoningLabel: agentReasoningLabel(info.agentId),
285
+ reasoningOptions: agentReasoningOptions(info.agentId),
286
+ launchProfiles: capabilities.launchProfiles ? session.listLaunchProfiles() : [],
287
+ workspaces: filterAllowedWorkspaces(session.listWorkspaces(), this.config),
288
+ capabilities,
289
+ };
290
+ }
291
+ finally {
292
+ if (dispose) {
293
+ session.dispose();
294
+ }
295
+ }
296
+ }
297
+ async authStatus(agentId) {
298
+ const { session, dispose } = await this.getControlSession(agentId);
299
+ try {
300
+ const info = this.publicInfo(session);
301
+ const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
302
+ if (!capabilities.auth) {
303
+ return {
304
+ agentId: info.agentId,
305
+ agentLabel: info.agentLabel,
306
+ supported: false,
307
+ authenticated: null,
308
+ detail: `${info.agentLabel} authentication is managed outside NordRelay.`,
309
+ loginSupported: false,
310
+ logoutSupported: false,
311
+ hostLoginCommand: hostLoginCommand(info, this.config),
312
+ hostLogoutCommand: hostLogoutCommand(info, this.config),
313
+ };
314
+ }
315
+ const status = await this.checkAgentAuth(info);
316
+ return {
317
+ agentId: info.agentId,
318
+ agentLabel: info.agentLabel,
319
+ supported: true,
320
+ authenticated: status.authenticated,
321
+ method: status.method,
322
+ detail: status.detail,
323
+ loginSupported: capabilities.login,
324
+ logoutSupported: capabilities.logout,
325
+ hostLoginCommand: hostLoginCommand(info, this.config),
326
+ hostLogoutCommand: hostLogoutCommand(info, this.config),
327
+ };
328
+ }
329
+ finally {
330
+ if (dispose) {
331
+ session.dispose();
332
+ }
333
+ }
334
+ }
335
+ async login(agentId) {
336
+ const { session, dispose } = await this.getControlSession(agentId);
337
+ try {
338
+ const info = this.publicInfo(session);
339
+ const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
340
+ if (!capabilities.login) {
341
+ return {
342
+ ...(await this.authStatus(info.agentId)),
343
+ result: {
344
+ success: false,
345
+ message: `${info.agentLabel} login is not managed by NordRelay. Run ${hostLoginCommand(info, this.config)} on the host.`,
346
+ },
347
+ };
348
+ }
349
+ if (!this.config.enableTelegramLogin) {
350
+ return {
351
+ ...(await this.authStatus(info.agentId)),
352
+ result: {
353
+ success: false,
354
+ message: `Remote login is disabled. Run ${hostLoginCommand(info, this.config)} on the host.`,
355
+ },
356
+ };
357
+ }
358
+ const result = await this.startAgentLogin(info);
359
+ this.appendAudit({
360
+ action: "command",
361
+ status: result.success ? "ok" : "failed",
362
+ contextKey: WEB_CONTEXT_KEY,
363
+ agentId: info.agentId,
364
+ threadId: info.threadId,
365
+ workspace: info.workspace,
366
+ description: "login",
367
+ detail: result.message,
368
+ });
369
+ return { ...(await this.authStatus(info.agentId)), result };
370
+ }
371
+ finally {
372
+ if (dispose) {
373
+ session.dispose();
374
+ }
375
+ }
376
+ }
377
+ async logout(agentId) {
378
+ const { session, dispose } = await this.getControlSession(agentId);
379
+ try {
380
+ const info = this.publicInfo(session);
381
+ const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
382
+ if (!capabilities.logout) {
383
+ return {
384
+ ...(await this.authStatus(info.agentId)),
385
+ result: {
386
+ success: false,
387
+ message: `${info.agentLabel} logout is not managed by NordRelay. Run ${hostLogoutCommand(info, this.config)} on the host.`,
388
+ },
389
+ };
390
+ }
391
+ if (!this.config.enableTelegramLogin) {
392
+ return {
393
+ ...(await this.authStatus(info.agentId)),
394
+ result: {
395
+ success: false,
396
+ message: `Remote auth management is disabled. Run ${hostLogoutCommand(info, this.config)} on the host.`,
397
+ },
398
+ };
399
+ }
400
+ const current = await this.checkAgentAuth(info);
401
+ if (current.method === "api-key") {
402
+ return {
403
+ ...(await this.authStatus(info.agentId)),
404
+ result: {
405
+ success: false,
406
+ message: "Cannot logout while API-key authentication is configured. Remove the API key from .env to use CLI auth.",
407
+ },
408
+ };
409
+ }
410
+ const result = await this.startAgentLogout(info);
411
+ this.appendAudit({
412
+ action: "command",
413
+ status: result.success ? "ok" : "failed",
414
+ contextKey: WEB_CONTEXT_KEY,
415
+ agentId: info.agentId,
416
+ threadId: info.threadId,
417
+ workspace: info.workspace,
418
+ description: "logout",
419
+ detail: result.message,
420
+ });
421
+ return { ...(await this.authStatus(info.agentId)), result };
422
+ }
423
+ finally {
424
+ if (dispose) {
425
+ session.dispose();
426
+ }
427
+ }
428
+ }
111
429
  async chatHistory(limit = 200) {
112
430
  const session = await this.getSession(true);
113
431
  return this.chatStore.list(this.publicInfo(session).threadId, limit);
114
432
  }
433
+ async sessionDetail(threadId) {
434
+ const session = await this.getSession(true);
435
+ const record = session.getSessionRecord(threadId);
436
+ const active = this.publicInfo(session);
437
+ return {
438
+ record,
439
+ active,
440
+ usageRows: active.threadId === threadId ? renderSessionUsageRows(active) : [],
441
+ messages: this.chatStore.list(threadId, 100),
442
+ activity: this.activity({ limit: 100 }).filter((event) => event.threadId === threadId),
443
+ };
444
+ }
115
445
  async clearChatHistory() {
116
446
  const session = await this.getSession(true);
117
447
  const removed = this.chatStore.clear(this.publicInfo(session).threadId);
@@ -120,27 +450,87 @@ export class RelayRuntime {
120
450
  return { removed, messages };
121
451
  }
122
452
  activity(options = {}) {
123
- return this.activityStore.list(options);
453
+ return this.activityStore.list(options).map((event) => this.enrichActivityEvent(event));
124
454
  }
125
- async listSessions(limit = 80, query = "") {
126
- return this.filteredSessions(await this.getSession(true), query, Math.max(1, limit * 3)).slice(0, limit);
455
+ async retry() {
456
+ const cached = this.promptStore.getLastPrompt(WEB_CONTEXT_KEY);
457
+ if (!cached) {
458
+ throw new Error("Nothing to retry. Send a message first.");
459
+ }
460
+ this.appendAudit({
461
+ action: "command",
462
+ status: "ok",
463
+ contextKey: WEB_CONTEXT_KEY,
464
+ description: "retry",
465
+ detail: cached.description,
466
+ });
467
+ return this.sendEnvelope(cached);
127
468
  }
128
- async listSessionsPage(page = 1, pageSize = MAX_WEB_SESSION_PAGE_SIZE, query = "") {
469
+ async sync() {
129
470
  const session = await this.getSession(true);
130
- const effectivePage = Math.max(1, Math.floor(page));
131
- const effectivePageSize = Math.min(MAX_WEB_SESSION_PAGE_SIZE, Math.max(1, Math.floor(pageSize)));
132
- const offset = (effectivePage - 1) * effectivePageSize;
133
- const requested = Math.min(5_000, Math.max(100, (offset + effectivePageSize + 1) * 3));
134
- const records = this.filteredSessions(session, query, requested);
135
- return {
136
- sessions: records.slice(offset, offset + effectivePageSize),
137
- pagination: {
138
- page: effectivePage,
139
- pageSize: effectivePageSize,
140
- hasPrevious: effectivePage > 1,
141
- hasNext: records.length > offset + effectivePageSize,
142
- },
143
- };
471
+ const info = this.publicInfo(session);
472
+ if (!(info.capabilities ?? CODEX_AGENT_CAPABILITIES).externalActivity) {
473
+ throw new Error(`${info.agentLabel} has no external state watcher to sync.`);
474
+ }
475
+ const result = session.syncFromAgentState({ reattach: true });
476
+ if (result.changed) {
477
+ this.updateSession(session);
478
+ }
479
+ this.appendActivity({
480
+ source: "web",
481
+ status: "info",
482
+ type: "session_sync",
483
+ threadId: result.info.threadId,
484
+ workspace: result.info.workspace,
485
+ agentId: result.info.agentId,
486
+ detail: result.changedFields.length > 0 ? result.changedFields.join(", ") : "already in sync",
487
+ });
488
+ this.appendAudit({
489
+ action: "command",
490
+ status: "ok",
491
+ contextKey: WEB_CONTEXT_KEY,
492
+ agentId: result.info.agentId,
493
+ threadId: result.info.threadId,
494
+ workspace: result.info.workspace,
495
+ description: "sync",
496
+ detail: result.changedFields.join(", ") || "none",
497
+ });
498
+ return result;
499
+ }
500
+ async listSessions(limit = 80, query = "", agentId) {
501
+ const { session, dispose } = await this.getControlSession(agentId);
502
+ try {
503
+ return this.filteredSessions(session, query, Math.max(1, limit * 3)).slice(0, limit);
504
+ }
505
+ finally {
506
+ if (dispose) {
507
+ session.dispose();
508
+ }
509
+ }
510
+ }
511
+ async listSessionsPage(page = 1, pageSize = MAX_WEB_SESSION_PAGE_SIZE, query = "", agentId) {
512
+ const { session, dispose } = await this.getControlSession(agentId);
513
+ try {
514
+ const effectivePage = Math.max(1, Math.floor(page));
515
+ const effectivePageSize = Math.min(MAX_WEB_SESSION_PAGE_SIZE, Math.max(1, Math.floor(pageSize)));
516
+ const offset = (effectivePage - 1) * effectivePageSize;
517
+ const requested = Math.min(5_000, Math.max(100, (offset + effectivePageSize + 1) * 3));
518
+ const records = this.filteredSessions(session, query, requested);
519
+ return {
520
+ sessions: records.slice(offset, offset + effectivePageSize),
521
+ pagination: {
522
+ page: effectivePage,
523
+ pageSize: effectivePageSize,
524
+ hasPrevious: effectivePage > 1,
525
+ hasNext: records.length > offset + effectivePageSize,
526
+ },
527
+ };
528
+ }
529
+ finally {
530
+ if (dispose) {
531
+ session.dispose();
532
+ }
533
+ }
144
534
  }
145
535
  filteredSessions(session, query, limit) {
146
536
  const normalized = query.trim().toLowerCase();
@@ -161,7 +551,12 @@ export class RelayRuntime {
161
551
  });
162
552
  }
163
553
  async listModels() {
164
- return (await this.getSession(true)).listModels();
554
+ const session = await this.getSession(true);
555
+ const info = this.publicInfo(session);
556
+ await session.refreshModels({ force: true }).catch((error) => {
557
+ console.warn(`Failed to refresh ${agentLabel(info.agentId)} models: ${error instanceof Error ? error.message : String(error)}`);
558
+ });
559
+ return session.listModels();
165
560
  }
166
561
  async setAgent(agentId) {
167
562
  if (!enabledAgents(this.config).includes(agentId)) {
@@ -175,7 +570,7 @@ export class RelayRuntime {
175
570
  const session = options.agentId ? await this.registry.switchAgent(WEB_CONTEXT_KEY, options.agentId) : await this.getSession(true);
176
571
  this.ensureIdle(session);
177
572
  if (options.reasoningEffort) {
178
- const reasoningOptions = session.getInfo().agentId === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
573
+ const reasoningOptions = agentReasoningOptions(session.getInfo().agentId);
179
574
  if (!reasoningOptions.includes(options.reasoningEffort)) {
180
575
  throw new Error(`Invalid ${agentReasoningLabel(session.getInfo().agentId)} value: ${options.reasoningEffort}`);
181
576
  }
@@ -230,7 +625,7 @@ export class RelayRuntime {
230
625
  async setReasoningEffort(effort) {
231
626
  const session = await this.getSession(true);
232
627
  this.ensureIdle(session);
233
- const options = session.getInfo().agentId === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
628
+ const options = agentReasoningOptions(session.getInfo().agentId);
234
629
  if (!options.includes(effort)) {
235
630
  throw new Error(`Invalid ${agentReasoningLabel(session.getInfo().agentId)} value: ${effort}`);
236
631
  }
@@ -264,6 +659,16 @@ export class RelayRuntime {
264
659
  }
265
660
  async abort() {
266
661
  const session = await this.getSession(true);
662
+ const snapshot = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
663
+ if (snapshot?.activity.active && !session.isProcessing()) {
664
+ this.broadcast({
665
+ type: "status",
666
+ level: "warn",
667
+ message: `Cannot abort the external ${snapshot.agentLabel} CLI task from NordRelay. Stop it in the terminal where it is running.`,
668
+ at: new Date().toISOString(),
669
+ });
670
+ return;
671
+ }
267
672
  await session.abort();
268
673
  this.broadcast({ type: "status", level: "warn", message: "Current operation aborted.", at: new Date().toISOString() });
269
674
  }
@@ -341,7 +746,8 @@ export class RelayRuntime {
341
746
  }
342
747
  async sendEnvelope(envelope) {
343
748
  const session = await this.getSession(false);
344
- if (session.isProcessing()) {
749
+ const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
750
+ if (session.isProcessing() || external?.activity.active) {
345
751
  const queued = this.promptStore.enqueue(WEB_CONTEXT_KEY, envelope);
346
752
  const info = this.publicInfo(session);
347
753
  this.appendActivity({
@@ -352,8 +758,23 @@ export class RelayRuntime {
352
758
  workspace: info.workspace,
353
759
  agentId: info.agentId,
354
760
  prompt: envelope.description,
355
- detail: `Queued at position ${this.promptStore.list(WEB_CONTEXT_KEY).length}.`,
761
+ detail: external?.activity.active
762
+ ? `Queued because ${external.agentLabel} CLI is still processing another task.`
763
+ : `Queued at position ${this.promptStore.list(WEB_CONTEXT_KEY).length}.`,
764
+ });
765
+ this.appendAudit({
766
+ action: "prompt_queued",
767
+ status: "ok",
768
+ contextKey: WEB_CONTEXT_KEY,
769
+ agentId: info.agentId,
770
+ threadId: info.threadId,
771
+ workspace: info.workspace,
772
+ promptId: queued.id,
773
+ description: envelope.description,
356
774
  });
775
+ if (external?.activity.active) {
776
+ this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.promptStore.list(WEB_CONTEXT_KEY).length} queued.`, "info");
777
+ }
357
778
  this.broadcastQueue();
358
779
  return { queued: true, queueId: queued.id };
359
780
  }
@@ -397,6 +818,12 @@ export class RelayRuntime {
397
818
  workspace: this.config.workspace,
398
819
  detail: id ? `${action}: ${id}` : action,
399
820
  });
821
+ this.appendAudit({
822
+ action: "queue_updated",
823
+ status: "ok",
824
+ contextKey: WEB_CONTEXT_KEY,
825
+ description: id ? `${action}: ${id}` : action,
826
+ });
400
827
  this.broadcastQueue();
401
828
  return this.queue();
402
829
  }
@@ -457,11 +884,25 @@ export class RelayRuntime {
457
884
  }
458
885
  async logs(target = "connector", lines = 100) {
459
886
  if (target === "update") {
460
- const { getUpdateLogPath } = await import("./operations.js");
461
887
  return readFormattedLogTail(lines, getUpdateLogPath());
462
888
  }
889
+ if (target === "agent-updates") {
890
+ return readFormattedLogTail(lines, getAgentUpdateLogPath());
891
+ }
463
892
  return readFormattedLogTail(lines);
464
893
  }
894
+ clearLogs(target = "connector") {
895
+ const result = clearLogFile(target === "update" ? getUpdateLogPath() : target === "agent-updates" ? getAgentUpdateLogPath() : getConnectorLogPath());
896
+ this.appendActivity({
897
+ source: "web",
898
+ status: "info",
899
+ type: "logs_cleared",
900
+ threadId: null,
901
+ workspace: this.config.workspace,
902
+ detail: `Cleared ${target} log.`,
903
+ });
904
+ return { ok: true, filePath: result.filePath, clearedAt: result.clearedAt.toISOString() };
905
+ }
465
906
  restartConnector() {
466
907
  spawnConnectorRestart();
467
908
  this.broadcastStatus("Restart requested. The dashboard may disconnect briefly.", "warn");
@@ -479,29 +920,28 @@ export class RelayRuntime {
479
920
  if (this.externalMonitor) {
480
921
  clearInterval(this.externalMonitor);
481
922
  }
923
+ this.agentUpdates.cancelAll();
482
924
  this.registry.disposeAll();
483
925
  this.subscribers.clear();
484
926
  }
485
927
  async monitorExternalActivity() {
486
928
  const session = await this.getSession(true);
487
929
  const info = this.publicInfo(session);
488
- if (!info.capabilities.externalActivity || info.agentId !== "codex" || !info.threadId || session.isProcessing()) {
930
+ if (!info.capabilities.externalActivity || !info.threadId || session.isProcessing()) {
489
931
  return;
490
932
  }
491
- const snapshot = getThreadRolloutSnapshot(info.threadId, {
933
+ const snapshot = getExternalSnapshotForSession(session, this.config, {
492
934
  afterLine: this.externalMirror?.threadId === info.threadId ? this.externalMirror.lastLine : Number.MAX_SAFE_INTEGER,
493
- staleAfterMs: this.config.codexExternalBusyStaleMs,
494
- }) ?? getThreadRolloutSnapshot(info.threadId, {
495
- staleAfterMs: this.config.codexExternalBusyStaleMs,
935
+ }) ?? getExternalSnapshotForSession(session, this.config, {
496
936
  maxEvents: 0,
497
937
  });
498
938
  if (!snapshot) {
499
939
  return;
500
940
  }
501
- if (!this.externalMirror || this.externalMirror.threadId !== snapshot.threadId || this.externalMirror.rolloutPath !== snapshot.rolloutPath) {
941
+ if (!this.externalMirror || this.externalMirror.threadId !== snapshot.threadId || this.externalMirror.rolloutPath !== snapshot.sourcePath) {
502
942
  this.externalMirror = {
503
943
  threadId: snapshot.threadId,
504
- rolloutPath: snapshot.rolloutPath,
944
+ rolloutPath: snapshot.sourcePath,
505
945
  lastLine: snapshot.lineCount,
506
946
  turnId: snapshot.activity.turnId,
507
947
  startedAt: snapshot.activity.startedAt?.toISOString() ?? null,
@@ -555,16 +995,21 @@ export class RelayRuntime {
555
995
  workspace: info.workspace,
556
996
  agentId: info.agentId,
557
997
  prompt: snapshot.latestUserMessage ?? undefined,
558
- detail: `Codex CLI task ${terminalEvent.status ?? "finished"}.`,
998
+ detail: `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`,
559
999
  durationMs: durationFromDates(externalStartedAt, terminalEvent.timestamp),
560
1000
  });
561
- this.broadcastStatus(`Codex CLI task ${terminalEvent.status ?? "finished"}.`, terminalEvent.status === "failed" ? "error" : terminalEvent.status === "aborted" ? "warn" : "info");
1001
+ if (externalStartedAt && terminalEvent.turnId) {
1002
+ await this.persistWorkspaceArtifactsForTurn(info.workspace, terminalEvent.turnId, externalStartedAt);
1003
+ }
1004
+ mirror.latestStatus = `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`;
1005
+ this.broadcastStatus(`${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`, terminalEvent.status === "failed" ? "error" : terminalEvent.status === "aborted" ? "warn" : "info");
562
1006
  this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
1007
+ await this.drainQueue();
563
1008
  }
564
1009
  mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
565
1010
  }
566
1011
  startExternalTurn(snapshot) {
567
- const prompt = snapshot.latestUserMessage ?? "Codex CLI task";
1012
+ const prompt = snapshot.latestUserMessage ?? `${snapshot.agentLabel} CLI task`;
568
1013
  this.chatStore.append({
569
1014
  threadId: snapshot.threadId,
570
1015
  role: "user",
@@ -586,7 +1031,7 @@ export class RelayRuntime {
586
1031
  type: "cli_turn_started",
587
1032
  threadId: snapshot.threadId,
588
1033
  prompt,
589
- detail: `Rollout: ${snapshot.rolloutPath}`,
1034
+ detail: `${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
590
1035
  });
591
1036
  }
592
1037
  broadcastExternalEvents(snapshot, events) {
@@ -619,12 +1064,79 @@ export class RelayRuntime {
619
1064
  async getSession(deferThreadStart) {
620
1065
  return this.registry.getOrCreate(WEB_CONTEXT_KEY, { deferThreadStart });
621
1066
  }
1067
+ async getControlSession(agentId) {
1068
+ const active = await this.getSession(true);
1069
+ const activeInfo = this.publicInfo(active);
1070
+ if (!agentId || agentId === activeInfo.agentId) {
1071
+ return { session: active, dispose: false };
1072
+ }
1073
+ if (!enabledAgents(this.config).includes(agentId)) {
1074
+ throw new Error(`Agent is not enabled: ${agentId}`);
1075
+ }
1076
+ const session = await createAgentSessionService(this.config, agentId, {
1077
+ deferThreadStart: true,
1078
+ workspace: activeInfo.workspace,
1079
+ });
1080
+ return { session, dispose: true };
1081
+ }
622
1082
  async ensureActiveThread(session) {
623
1083
  if (!session.hasActiveThread()) {
624
1084
  await session.newThread();
625
1085
  this.updateSession(session);
626
1086
  }
627
1087
  }
1088
+ async checkAgentAuth(info) {
1089
+ if (info.agentId === "pi") {
1090
+ return checkPiAuthStatus(info.model);
1091
+ }
1092
+ if (info.agentId === "hermes") {
1093
+ return checkHermesAuthStatus({
1094
+ baseUrl: this.config.hermesApiBaseUrl,
1095
+ apiKey: this.config.hermesApiKey,
1096
+ });
1097
+ }
1098
+ if (info.agentId === "openclaw") {
1099
+ return checkOpenClawAuthStatus({
1100
+ gatewayUrl: this.config.openClawGatewayUrl,
1101
+ token: this.config.openClawGatewayToken,
1102
+ password: this.config.openClawGatewayPassword,
1103
+ });
1104
+ }
1105
+ if (info.agentId === "claude-code") {
1106
+ return checkClaudeCodeAuthStatus(this.config.claudeCodeCliPath);
1107
+ }
1108
+ return checkAuthStatus(this.config.codexApiKey);
1109
+ }
1110
+ async startAgentLogin(info) {
1111
+ if (info.agentId === "hermes") {
1112
+ return startHermesLogin(this.config.hermesCliPath);
1113
+ }
1114
+ if (info.agentId === "claude-code") {
1115
+ return startClaudeCodeLogin(this.config.claudeCodeCliPath);
1116
+ }
1117
+ if (info.agentId === "codex") {
1118
+ return startCodexLogin();
1119
+ }
1120
+ return {
1121
+ success: false,
1122
+ message: `${info.agentLabel} login is not managed by NordRelay. Run the agent login flow on the host.`,
1123
+ };
1124
+ }
1125
+ async startAgentLogout(info) {
1126
+ if (info.agentId === "hermes") {
1127
+ return startHermesLogout(this.config.hermesCliPath);
1128
+ }
1129
+ if (info.agentId === "claude-code") {
1130
+ return startClaudeCodeLogout(this.config.claudeCodeCliPath);
1131
+ }
1132
+ if (info.agentId === "codex") {
1133
+ return startCodexLogout();
1134
+ }
1135
+ return {
1136
+ success: false,
1137
+ message: `${info.agentLabel} logout is not managed by NordRelay. Run the agent logout flow on the host.`,
1138
+ };
1139
+ }
628
1140
  ensureIdle(session) {
629
1141
  if (session.isProcessing()) {
630
1142
  throw new Error("The active session is still processing a turn.");
@@ -634,9 +1146,9 @@ export class RelayRuntime {
634
1146
  await this.ensureActiveThread(session);
635
1147
  const info = session.getInfo();
636
1148
  if ((info.capabilities ?? CODEX_AGENT_CAPABILITIES).auth) {
637
- const auth = await checkAuthStatus(this.config.codexApiKey);
1149
+ const auth = await this.checkAgentAuth(info);
638
1150
  if (!auth.authenticated) {
639
- throw new Error(`Codex is not authenticated: ${auth.detail}`);
1151
+ throw new Error(`${agentLabel(info.agentId)} is not authenticated: ${auth.detail}`);
640
1152
  }
641
1153
  }
642
1154
  const workspacePolicy = evaluateWorkspacePolicy(session.getInfo().workspace, this.config);
@@ -647,8 +1159,24 @@ export class RelayRuntime {
647
1159
  this.currentTurnId = turnId;
648
1160
  this.currentTurnStartedAt = Date.now();
649
1161
  this.accumulatedText = "";
1162
+ this.currentProgress = {
1163
+ id: turnId,
1164
+ source: "web",
1165
+ status: "running",
1166
+ prompt: envelope.description,
1167
+ agentId: info.agentId,
1168
+ agentLabel: info.agentLabel,
1169
+ threadId: info.threadId,
1170
+ workspace: info.workspace,
1171
+ startedAt: new Date(this.currentTurnStartedAt).toISOString(),
1172
+ updatedAt: new Date(this.currentTurnStartedAt).toISOString(),
1173
+ durationMs: 0,
1174
+ outputChars: 0,
1175
+ tools: [],
1176
+ };
650
1177
  this.promptStore.setLastPrompt(WEB_CONTEXT_KEY, envelope);
651
- const startedAt = new Date().toISOString();
1178
+ const startedDate = new Date();
1179
+ const startedAt = startedDate.toISOString();
652
1180
  this.chatStore.append({
653
1181
  threadId: info.threadId ?? "pending",
654
1182
  role: "user",
@@ -666,22 +1194,45 @@ export class RelayRuntime {
666
1194
  agentId: info.agentId,
667
1195
  prompt: envelope.description,
668
1196
  });
1197
+ this.appendAudit({
1198
+ action: "prompt_started",
1199
+ status: "ok",
1200
+ contextKey: WEB_CONTEXT_KEY,
1201
+ agentId: info.agentId,
1202
+ threadId: info.threadId,
1203
+ workspace: info.workspace,
1204
+ description: envelope.description,
1205
+ });
669
1206
  this.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: startedAt, source: "web" });
670
1207
  const callbacks = {
671
1208
  onTextDelta: (delta) => {
672
1209
  this.accumulatedText += delta;
1210
+ this.updateCurrentProgress({ outputChars: this.accumulatedText.length });
673
1211
  this.broadcast({ type: "text_delta", id: turnId, delta });
674
1212
  },
675
- onToolStart: (toolName, toolCallId) => this.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName }),
676
- onToolUpdate: (toolCallId, partialResult) => this.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult }),
677
- onToolEnd: (toolCallId, isError) => this.broadcast({ type: "tool_end", id: turnId, toolCallId, isError }),
678
- onTodoUpdate: (items) => this.broadcast({ type: "todo_update", id: turnId, items }),
1213
+ onToolStart: (toolName, toolCallId) => {
1214
+ this.addCurrentTool(toolName);
1215
+ this.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName });
1216
+ },
1217
+ onToolUpdate: (toolCallId, partialResult) => {
1218
+ this.updateCurrentProgress();
1219
+ this.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult });
1220
+ },
1221
+ onToolEnd: (toolCallId, isError) => {
1222
+ this.updateCurrentProgress({ currentTool: undefined });
1223
+ this.broadcast({ type: "tool_end", id: turnId, toolCallId, isError });
1224
+ },
1225
+ onTodoUpdate: (items) => {
1226
+ this.updateCurrentProgress({ detail: `Plan: ${items.filter((item) => item.completed).length}/${items.length} done` });
1227
+ this.broadcast({ type: "todo_update", id: turnId, items });
1228
+ },
679
1229
  onTurnComplete: () => { },
680
1230
  onAgentEnd: () => this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() }),
681
1231
  };
682
1232
  try {
683
1233
  await session.prompt(envelope.input, callbacks);
684
1234
  this.updateSession(session);
1235
+ await this.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, startedDate);
685
1236
  if (this.accumulatedText.trim()) {
686
1237
  this.chatStore.append({
687
1238
  threadId: info.threadId ?? "pending",
@@ -701,6 +1252,16 @@ export class RelayRuntime {
701
1252
  prompt: envelope.description,
702
1253
  durationMs: Date.now() - this.currentTurnStartedAt,
703
1254
  });
1255
+ this.appendAudit({
1256
+ action: "prompt_completed",
1257
+ status: "ok",
1258
+ contextKey: WEB_CONTEXT_KEY,
1259
+ agentId: info.agentId,
1260
+ threadId: info.threadId,
1261
+ workspace: info.workspace,
1262
+ description: envelope.description,
1263
+ });
1264
+ this.updateCurrentProgress({ status: "completed" });
704
1265
  this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() });
705
1266
  this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
706
1267
  }
@@ -724,12 +1285,27 @@ export class RelayRuntime {
724
1285
  detail: errorText,
725
1286
  durationMs: Date.now() - this.currentTurnStartedAt,
726
1287
  });
1288
+ this.appendAudit({
1289
+ action: "prompt_failed",
1290
+ status: "failed",
1291
+ contextKey: WEB_CONTEXT_KEY,
1292
+ agentId: info.agentId,
1293
+ threadId: info.threadId,
1294
+ workspace: info.workspace,
1295
+ description: envelope.description,
1296
+ detail: errorText,
1297
+ });
1298
+ this.updateCurrentProgress({ status: "failed", detail: errorText });
727
1299
  this.broadcast({ type: "turn_error", id: turnId, error: errorText, at: new Date().toISOString() });
728
1300
  this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
729
1301
  throw error;
730
1302
  }
731
1303
  finally {
732
1304
  this.currentTurnId = null;
1305
+ if (this.currentProgress) {
1306
+ this.currentProgress.durationMs = Date.now() - this.currentTurnStartedAt;
1307
+ this.currentProgress.updatedAt = new Date().toISOString();
1308
+ }
733
1309
  await this.drainQueue();
734
1310
  }
735
1311
  }
@@ -741,6 +1317,11 @@ export class RelayRuntime {
741
1317
  try {
742
1318
  const session = await this.getSession(false);
743
1319
  while (!session.isProcessing()) {
1320
+ const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
1321
+ if (external?.activity.active) {
1322
+ this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.queue().length} queued.`, "info");
1323
+ return;
1324
+ }
744
1325
  const next = this.promptStore.dequeue(WEB_CONTEXT_KEY);
745
1326
  this.broadcastQueue();
746
1327
  if (!next) {
@@ -758,10 +1339,85 @@ export class RelayRuntime {
758
1339
  this.broadcast({ type: "session_update", session: this.publicInfo(session) });
759
1340
  }
760
1341
  appendActivity(input) {
761
- const event = this.activityStore.append(input);
1342
+ const event = this.activityStore.append(this.enrichActivityInput(input));
762
1343
  this.broadcast({ type: "activity_update", events: this.activity({ limit: 50 }) });
763
1344
  return event;
764
1345
  }
1346
+ enrichActivityInput(input) {
1347
+ return this.enrichActivityFields(input);
1348
+ }
1349
+ enrichActivityEvent(event) {
1350
+ return this.enrichActivityFields(event);
1351
+ }
1352
+ enrichActivityFields(event) {
1353
+ const info = this.registry.get(WEB_CONTEXT_KEY)?.getInfo();
1354
+ if (!info) {
1355
+ return !event.threadId && !event.workspace ? { ...event, workspace: this.config.workspace } : event;
1356
+ }
1357
+ if (event.threadId && info.threadId && event.threadId === info.threadId) {
1358
+ return { ...event, workspace: event.workspace ?? info.workspace, agentId: event.agentId ?? info.agentId };
1359
+ }
1360
+ if (!event.threadId && !event.workspace) {
1361
+ return { ...event, workspace: this.config.workspace };
1362
+ }
1363
+ return event;
1364
+ }
1365
+ appendAudit(input) {
1366
+ return this.auditStore.append({ ...input, channelId: "web" });
1367
+ }
1368
+ updateCurrentProgress(patch = {}) {
1369
+ if (!this.currentProgress) {
1370
+ return;
1371
+ }
1372
+ if ("currentTool" in patch) {
1373
+ this.currentProgress.currentTool = patch.currentTool;
1374
+ const { currentTool: _currentTool, ...rest } = patch;
1375
+ Object.assign(this.currentProgress, rest);
1376
+ }
1377
+ else {
1378
+ Object.assign(this.currentProgress, patch);
1379
+ }
1380
+ this.currentProgress.durationMs = Date.now() - this.currentTurnStartedAt;
1381
+ this.currentProgress.updatedAt = new Date().toISOString();
1382
+ }
1383
+ addCurrentTool(toolName) {
1384
+ if (!this.currentProgress) {
1385
+ return;
1386
+ }
1387
+ const existing = this.currentProgress.tools.find((tool) => tool.name === toolName);
1388
+ if (existing) {
1389
+ existing.count += 1;
1390
+ }
1391
+ else {
1392
+ this.currentProgress.tools.push({ name: toolName, count: 1 });
1393
+ }
1394
+ this.updateCurrentProgress({ currentTool: toolName, lastTool: toolName });
1395
+ }
1396
+ externalTask() {
1397
+ if (!this.externalMirror) {
1398
+ return null;
1399
+ }
1400
+ const startedAt = this.externalMirror.startedAt ?? new Date().toISOString();
1401
+ const startedMs = new Date(startedAt).getTime();
1402
+ return {
1403
+ id: this.externalMirror.turnId ?? "cli",
1404
+ source: "cli",
1405
+ status: this.externalMirror.latestStatus?.includes("failed")
1406
+ ? "failed"
1407
+ : this.externalMirror.latestStatus?.includes("aborted")
1408
+ ? "aborted"
1409
+ : this.externalMirror.latestStatus?.includes("finished") || this.externalMirror.latestStatus?.includes("completed")
1410
+ ? "completed"
1411
+ : "running",
1412
+ threadId: this.externalMirror.threadId,
1413
+ startedAt,
1414
+ updatedAt: new Date().toISOString(),
1415
+ durationMs: Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : 0,
1416
+ outputChars: 0,
1417
+ tools: [],
1418
+ detail: this.externalMirror.latestStatus ?? this.externalMirror.rolloutPath,
1419
+ };
1420
+ }
765
1421
  broadcastQueue() {
766
1422
  this.broadcast({ type: "queue_update", queue: this.queue(), paused: this.queuePaused() });
767
1423
  }
@@ -788,6 +1444,20 @@ export class RelayRuntime {
788
1444
  capabilities: info.capabilities ?? CODEX_AGENT_CAPABILITIES,
789
1445
  };
790
1446
  }
1447
+ async persistWorkspaceArtifactsForTurn(workspace, turnId, startedAt) {
1448
+ const report = await collectRecentWorkspaceArtifacts(workspace, {
1449
+ since: startedAt,
1450
+ until: new Date(),
1451
+ maxFileSize: this.config.maxFileSize,
1452
+ limit: 20,
1453
+ ignoreDirs: this.config.artifactIgnoreDirs,
1454
+ ignoreGlobs: this.config.artifactIgnoreGlobs,
1455
+ });
1456
+ if (report.artifacts.length === 0 && report.skippedCount === 0 && !report.omittedCount) {
1457
+ return;
1458
+ }
1459
+ await persistWorkspaceArtifactReport(workspace, turnId, report);
1460
+ }
791
1461
  }
792
1462
  function queueItemDto(item) {
793
1463
  return {
@@ -820,7 +1490,63 @@ function externalStatusLine(snapshot, queueLength) {
820
1490
  ? formatDuration((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
821
1491
  : "-";
822
1492
  const tool = snapshot.latestToolName ?? "-";
823
- return `Codex CLI running · ${elapsed} · tool ${tool} · ${queueLength} queued`;
1493
+ return `${snapshot.agentLabel} CLI running · ${elapsed} · tool ${tool} · ${queueLength} queued`;
1494
+ }
1495
+ function cliHealthForAgent(agentId, health) {
1496
+ if (agentId === "pi") {
1497
+ return { path: health.piCliPath, label: health.piCli, version: health.piCliVersion };
1498
+ }
1499
+ if (agentId === "hermes") {
1500
+ return { path: health.hermesCliPath, label: health.hermesCli, version: health.hermesCliVersion };
1501
+ }
1502
+ if (agentId === "openclaw") {
1503
+ return { path: health.openClawCliPath, label: health.openClawCli, version: health.openClawCliVersion };
1504
+ }
1505
+ if (agentId === "claude-code") {
1506
+ return { path: health.claudeCodeCliPath, label: health.claudeCodeCli, version: health.claudeCodeCliVersion };
1507
+ }
1508
+ return { path: health.codexCliPath, label: health.codexCli, version: health.codexCliVersion };
1509
+ }
1510
+ function versionCheckForAgent(agentId, versions) {
1511
+ if (agentId === "pi")
1512
+ return versions.pi;
1513
+ if (agentId === "hermes")
1514
+ return versions.hermes;
1515
+ if (agentId === "openclaw")
1516
+ return versions.openclaw;
1517
+ if (agentId === "claude-code")
1518
+ return versions.claudeCode;
1519
+ return versions.codex;
1520
+ }
1521
+ function hostLoginCommand(info, config) {
1522
+ if (info.agentId === "hermes") {
1523
+ return `${config.hermesCliPath ?? "hermes"} login --no-browser`;
1524
+ }
1525
+ if (info.agentId === "claude-code") {
1526
+ return `${config.claudeCodeCliPath ?? "claude"} auth login`;
1527
+ }
1528
+ if (info.agentId === "pi") {
1529
+ return `${config.piCliPath ?? "pi"} auth login`;
1530
+ }
1531
+ if (info.agentId === "openclaw") {
1532
+ return `${config.openClawCliPath ?? "openclaw"} login`;
1533
+ }
1534
+ return "codex login --device-auth";
1535
+ }
1536
+ function hostLogoutCommand(info, config) {
1537
+ if (info.agentId === "hermes") {
1538
+ return `${config.hermesCliPath ?? "hermes"} logout`;
1539
+ }
1540
+ if (info.agentId === "claude-code") {
1541
+ return `${config.claudeCodeCliPath ?? "claude"} auth logout`;
1542
+ }
1543
+ if (info.agentId === "pi") {
1544
+ return `${config.piCliPath ?? "pi"} auth logout`;
1545
+ }
1546
+ if (info.agentId === "openclaw") {
1547
+ return `${config.openClawCliPath ?? "openclaw"} logout`;
1548
+ }
1549
+ return "codex logout";
824
1550
  }
825
1551
  function durationFromDates(start, end) {
826
1552
  if (!start || !end) {