@nordbyte/nordrelay 0.3.0 → 0.4.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 +45 -2
  2. package/README.md +227 -47
  3. package/dist/agent-activity.js +300 -0
  4. package/dist/agent-adapter.js +17 -30
  5. package/dist/agent-factory.js +27 -0
  6. package/dist/agent.js +123 -9
  7. package/dist/artifacts.js +1 -1
  8. package/dist/audit-log.js +1 -1
  9. package/dist/bot-ui.js +1 -1
  10. package/dist/bot.js +333 -161
  11. package/dist/claude-code-auth.js +121 -0
  12. package/dist/claude-code-cli.js +19 -0
  13. package/dist/claude-code-launch.js +73 -0
  14. package/dist/claude-code-session.js +660 -0
  15. package/dist/claude-code-state.js +590 -0
  16. package/dist/codex-session.js +15 -2
  17. package/dist/config.js +113 -9
  18. package/dist/context-key.js +23 -0
  19. package/dist/hermes-api.js +150 -0
  20. package/dist/hermes-auth.js +96 -0
  21. package/dist/hermes-cli.js +19 -0
  22. package/dist/hermes-launch.js +57 -0
  23. package/dist/hermes-session.js +477 -0
  24. package/dist/hermes-state.js +609 -0
  25. package/dist/index.js +51 -8
  26. package/dist/openclaw-auth.js +27 -0
  27. package/dist/openclaw-cli.js +19 -0
  28. package/dist/openclaw-gateway.js +285 -0
  29. package/dist/openclaw-launch.js +65 -0
  30. package/dist/openclaw-session.js +549 -0
  31. package/dist/openclaw-state.js +409 -0
  32. package/dist/operations.js +84 -3
  33. package/dist/pi-auth.js +59 -0
  34. package/dist/pi-launch.js +61 -0
  35. package/dist/pi-rpc.js +18 -0
  36. package/dist/pi-session.js +103 -15
  37. package/dist/pi-state.js +253 -0
  38. package/dist/relay-runtime.js +1073 -22
  39. package/dist/session-format.js +28 -18
  40. package/dist/session-registry.js +43 -18
  41. package/dist/settings-service.js +80 -26
  42. package/dist/state-backend.js +17 -8
  43. package/dist/web-dashboard-ui.js +18 -0
  44. package/dist/web-dashboard.js +463 -55
  45. package/dist/web-state.js +131 -0
  46. package/docker-compose.yml +1 -1
  47. package/package.json +8 -3
  48. package/plugins/nordrelay/.codex-plugin/plugin.json +7 -4
  49. package/plugins/nordrelay/commands/remote.md +2 -2
  50. package/plugins/nordrelay/scripts/nordrelay.mjs +173 -20
  51. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +2 -2
  52. package/CHANGELOG.md +0 -17
@@ -1,35 +1,70 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
- import { createArtifactZipBundle, getArtifactTurnReport, ensureOutDir, listRecentArtifactReports, removeArtifactTurn, totalArtifactSize, } from "./artifacts.js";
4
+ import { createArtifactZipBundle, collectRecentWorkspaceArtifacts, getArtifactTurnReport, ensureOutDir, listRecentArtifactReports, persistWorkspaceArtifactReport, removeArtifactTurn, totalArtifactSize, } from "./artifacts.js";
4
5
  import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
5
- import { CODEX_AGENT_CAPABILITIES, CODEX_REASONING_EFFORTS, PI_THINKING_LEVELS, agentLabel, agentReasoningLabel, } from "./agent.js";
6
- import { enabledAgents } from "./agent-factory.js";
7
- import { checkAuthStatus } from "./codex-auth.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 { createAgentSessionService, enabledAgents } from "./agent-factory.js";
10
+ import { AuditLogStore } from "./audit-log.js";
11
+ import { checkAuthStatus, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
12
+ import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "./claude-code-auth.js";
8
13
  import { friendlyErrorText } from "./error-messages.js";
9
- import { getConnectorHealth, getVersionChecks, readFormattedLogTail } from "./operations.js";
14
+ import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
15
+ import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
16
+ import { getConnectorHealth, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate } from "./operations.js";
17
+ import { checkPiAuthStatus } from "./pi-auth.js";
10
18
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
11
19
  import { renderSessionInfoPlain } from "./session-format.js";
20
+ import { SessionLockStore } from "./session-locks.js";
12
21
  import { SessionRegistry } from "./session-registry.js";
13
22
  import { transcribeAudio } from "./voice.js";
23
+ import { WebActivityStore, WebChatStore, } from "./web-state.js";
14
24
  import { evaluateWorkspacePolicy, filterAllowedWorkspaces } from "./workspace-policy.js";
15
- const WEB_CONTEXT_KEY = "0";
25
+ const WEB_CONTEXT_KEY = "web:dashboard";
16
26
  const MAX_WEB_SESSION_PAGE_SIZE = 50;
27
+ const MAX_CHAT_HISTORY = 250;
28
+ const MAX_TEXT_PREVIEW_BYTES = 256 * 1024;
17
29
  export class RelayRuntime {
18
30
  config;
19
31
  registry;
20
32
  promptStore;
33
+ chatStore;
34
+ activityStore;
35
+ auditStore;
36
+ lockStore;
21
37
  subscribers = new Set();
38
+ externalMonitor;
22
39
  draining = false;
23
40
  currentTurnId = null;
24
41
  accumulatedText = "";
42
+ currentTurnStartedAt = 0;
43
+ currentProgress = null;
44
+ externalMirror = null;
25
45
  constructor(config) {
26
46
  this.config = config;
27
- this.registry = new SessionRegistry(config);
47
+ this.registry = new SessionRegistry(config, {
48
+ fileName: "web-contexts.json",
49
+ sqliteKey: "web-contexts",
50
+ });
28
51
  this.promptStore = new PromptStore(config.workspace, config.stateBackend);
52
+ this.chatStore = new WebChatStore(config.workspace, config.stateBackend, MAX_CHAT_HISTORY);
53
+ this.activityStore = new WebActivityStore(config.workspace, config.stateBackend, config.auditMaxEvents);
54
+ this.auditStore = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
55
+ this.lockStore = new SessionLockStore(config.workspace, config.stateBackend);
56
+ if (config.codexExternalBusyCheckMs > 0) {
57
+ this.externalMonitor = setInterval(() => {
58
+ void this.monitorExternalActivity().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
59
+ }, config.codexExternalBusyCheckMs);
60
+ this.externalMonitor.unref?.();
61
+ }
29
62
  }
30
63
  subscribe(callback) {
31
64
  this.subscribers.add(callback);
32
65
  void this.snapshot().then((data) => callback({ type: "snapshot", data })).catch(() => { });
66
+ void this.chatHistory().then((messages) => callback({ type: "chat_history", messages })).catch(() => { });
67
+ callback({ type: "activity_update", events: this.activity({ limit: 50 }) });
33
68
  return () => this.subscribers.delete(callback);
34
69
  }
35
70
  async snapshot() {
@@ -39,6 +74,7 @@ export class RelayRuntime {
39
74
  session: info,
40
75
  sessionText: renderSessionInfoPlain(info),
41
76
  queue: this.queue(),
77
+ queuePaused: this.queuePaused(),
42
78
  processing: session.isProcessing(),
43
79
  enabledAgents: enabledAgents(this.config),
44
80
  workspaces: filterAllowedWorkspaces(session.listWorkspaces(), this.config),
@@ -46,11 +82,366 @@ export class RelayRuntime {
46
82
  }
47
83
  async status() {
48
84
  return {
49
- health: await getConnectorHealth(),
50
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath }),
85
+ health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
86
+ versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
87
+ snapshot: await this.snapshot(),
88
+ };
89
+ }
90
+ async version() {
91
+ return {
92
+ health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
93
+ state: await readConnectorState(),
94
+ versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
95
+ };
96
+ }
97
+ updateConnector() {
98
+ const update = spawnSelfUpdate();
99
+ this.broadcastStatus(`Update started with ${update.method}. Log: ${update.logPath}`, "warn");
100
+ this.appendActivity({
101
+ source: "web",
102
+ status: "info",
103
+ type: "update_started",
104
+ threadId: null,
105
+ workspace: this.config.workspace,
106
+ detail: `${update.method}: ${update.summary}`,
107
+ });
108
+ this.appendAudit({
109
+ action: "command",
110
+ status: "ok",
111
+ contextKey: WEB_CONTEXT_KEY,
112
+ description: "update",
113
+ detail: update.summary,
114
+ });
115
+ return update;
116
+ }
117
+ async diagnostics() {
118
+ return {
119
+ health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
120
+ versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
51
121
  snapshot: await this.snapshot(),
122
+ runtime: {
123
+ stateBackend: this.config.stateBackend,
124
+ sourceWorkspace: this.config.workspace,
125
+ queuePaused: this.promptStore.isPaused(WEB_CONTEXT_KEY),
126
+ externalMirror: this.externalMirror ? { ...this.externalMirror } : null,
127
+ agentDiagnostics: getAgentDiagnostics(await this.getSession(true), this.config),
128
+ },
129
+ };
130
+ }
131
+ async adapterHealth() {
132
+ const health = await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath });
133
+ const versions = await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath });
134
+ return Promise.all(listAgentAdapterDescriptors().map(async (descriptor) => {
135
+ const enabled = enabledAgents(this.config).includes(descriptor.id);
136
+ const auth = descriptor.capabilities.auth && enabled
137
+ ? await this.authStatus(descriptor.id).catch((error) => ({
138
+ agentId: descriptor.id,
139
+ agentLabel: descriptor.label,
140
+ supported: descriptor.capabilities.auth,
141
+ authenticated: false,
142
+ detail: friendlyErrorText(error),
143
+ loginSupported: descriptor.capabilities.login,
144
+ logoutSupported: descriptor.capabilities.logout,
145
+ }))
146
+ : null;
147
+ const cli = cliHealthForAgent(descriptor.id, health);
148
+ const version = versionCheckForAgent(descriptor.id, versions);
149
+ return {
150
+ id: descriptor.id,
151
+ label: descriptor.label,
152
+ enabled,
153
+ status: descriptor.status === "available" ? (enabled ? "enabled" : "disabled") : "planned",
154
+ auth: {
155
+ supported: descriptor.capabilities.auth,
156
+ authenticated: auth ? auth.authenticated : null,
157
+ method: auth?.method,
158
+ detail: auth?.detail,
159
+ },
160
+ cli,
161
+ version: {
162
+ installed: version.installedLabel,
163
+ latest: version.latestVersion,
164
+ status: version.status,
165
+ detail: version.detail,
166
+ },
167
+ capabilities: descriptor.capabilities,
168
+ notes: descriptor.notes,
169
+ };
170
+ }));
171
+ }
172
+ permissions() {
173
+ return {
174
+ telegramAllowAnyChat: this.config.telegramAllowAnyChat,
175
+ telegramAdminUserIds: this.config.telegramAdminUserIds,
176
+ telegramAllowedUserIds: this.config.telegramAllowedUserIds,
177
+ telegramReadOnlyUserIds: this.config.telegramReadOnlyUserIds,
178
+ telegramAllowedChatIds: this.config.telegramAllowedChatIds,
179
+ telegramRolePolicies: this.config.telegramRolePolicies,
180
+ };
181
+ }
182
+ tasks() {
183
+ return {
184
+ current: this.currentProgress ? { ...this.currentProgress, tools: [...this.currentProgress.tools] } : null,
185
+ external: this.externalTask(),
186
+ queue: this.queue(),
187
+ queuePaused: this.queuePaused(),
188
+ recent: this.activity({ limit: 20 }),
189
+ };
190
+ }
191
+ audit(limit = 50) {
192
+ return this.auditStore.list(limit);
193
+ }
194
+ locks() {
195
+ return this.lockStore.list();
196
+ }
197
+ lockWebSession(ownerName = "Web dashboard") {
198
+ const lock = this.lockStore.set(WEB_CONTEXT_KEY, 0, ownerName, this.config.sessionLockTtlMs);
199
+ this.appendAudit({
200
+ action: "lock_updated",
201
+ status: "ok",
202
+ contextKey: WEB_CONTEXT_KEY,
203
+ description: "lock",
204
+ detail: `locked by ${ownerName}`,
205
+ });
206
+ return lock;
207
+ }
208
+ unlockWebSession() {
209
+ const removed = this.lockStore.clear(WEB_CONTEXT_KEY);
210
+ this.appendAudit({
211
+ action: "lock_updated",
212
+ status: "ok",
213
+ contextKey: WEB_CONTEXT_KEY,
214
+ description: "unlock",
215
+ detail: removed ? "unlocked" : "no lock",
216
+ });
217
+ return { removed, locks: this.locks() };
218
+ }
219
+ async controlOptions(agentId) {
220
+ const { session, dispose } = await this.getControlSession(agentId);
221
+ try {
222
+ const info = this.publicInfo(session);
223
+ const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
224
+ if (capabilities.modelSelection) {
225
+ await session.refreshModels().catch((error) => {
226
+ console.warn(`Failed to refresh ${agentLabel(info.agentId)} models: ${error instanceof Error ? error.message : String(error)}`);
227
+ });
228
+ }
229
+ return {
230
+ models: capabilities.modelSelection ? session.listModels() : [],
231
+ reasoningLabel: agentReasoningLabel(info.agentId),
232
+ reasoningOptions: agentReasoningOptions(info.agentId),
233
+ launchProfiles: capabilities.launchProfiles ? session.listLaunchProfiles() : [],
234
+ workspaces: filterAllowedWorkspaces(session.listWorkspaces(), this.config),
235
+ capabilities,
236
+ };
237
+ }
238
+ finally {
239
+ if (dispose) {
240
+ session.dispose();
241
+ }
242
+ }
243
+ }
244
+ async authStatus(agentId) {
245
+ const { session, dispose } = await this.getControlSession(agentId);
246
+ try {
247
+ const info = this.publicInfo(session);
248
+ const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
249
+ if (!capabilities.auth) {
250
+ return {
251
+ agentId: info.agentId,
252
+ agentLabel: info.agentLabel,
253
+ supported: false,
254
+ authenticated: null,
255
+ detail: `${info.agentLabel} authentication is managed outside NordRelay.`,
256
+ loginSupported: false,
257
+ logoutSupported: false,
258
+ hostLoginCommand: hostLoginCommand(info, this.config),
259
+ hostLogoutCommand: hostLogoutCommand(info, this.config),
260
+ };
261
+ }
262
+ const status = await this.checkAgentAuth(info);
263
+ return {
264
+ agentId: info.agentId,
265
+ agentLabel: info.agentLabel,
266
+ supported: true,
267
+ authenticated: status.authenticated,
268
+ method: status.method,
269
+ detail: status.detail,
270
+ loginSupported: capabilities.login,
271
+ logoutSupported: capabilities.logout,
272
+ hostLoginCommand: hostLoginCommand(info, this.config),
273
+ hostLogoutCommand: hostLogoutCommand(info, this.config),
274
+ };
275
+ }
276
+ finally {
277
+ if (dispose) {
278
+ session.dispose();
279
+ }
280
+ }
281
+ }
282
+ async login(agentId) {
283
+ const { session, dispose } = await this.getControlSession(agentId);
284
+ try {
285
+ const info = this.publicInfo(session);
286
+ const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
287
+ if (!capabilities.login) {
288
+ return {
289
+ ...(await this.authStatus(info.agentId)),
290
+ result: {
291
+ success: false,
292
+ message: `${info.agentLabel} login is not managed by NordRelay. Run ${hostLoginCommand(info, this.config)} on the host.`,
293
+ },
294
+ };
295
+ }
296
+ if (!this.config.enableTelegramLogin) {
297
+ return {
298
+ ...(await this.authStatus(info.agentId)),
299
+ result: {
300
+ success: false,
301
+ message: `Remote login is disabled. Run ${hostLoginCommand(info, this.config)} on the host.`,
302
+ },
303
+ };
304
+ }
305
+ const result = await this.startAgentLogin(info);
306
+ this.appendAudit({
307
+ action: "command",
308
+ status: result.success ? "ok" : "failed",
309
+ contextKey: WEB_CONTEXT_KEY,
310
+ agentId: info.agentId,
311
+ threadId: info.threadId,
312
+ workspace: info.workspace,
313
+ description: "login",
314
+ detail: result.message,
315
+ });
316
+ return { ...(await this.authStatus(info.agentId)), result };
317
+ }
318
+ finally {
319
+ if (dispose) {
320
+ session.dispose();
321
+ }
322
+ }
323
+ }
324
+ async logout(agentId) {
325
+ const { session, dispose } = await this.getControlSession(agentId);
326
+ try {
327
+ const info = this.publicInfo(session);
328
+ const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
329
+ if (!capabilities.logout) {
330
+ return {
331
+ ...(await this.authStatus(info.agentId)),
332
+ result: {
333
+ success: false,
334
+ message: `${info.agentLabel} logout is not managed by NordRelay. Run ${hostLogoutCommand(info, this.config)} on the host.`,
335
+ },
336
+ };
337
+ }
338
+ if (!this.config.enableTelegramLogin) {
339
+ return {
340
+ ...(await this.authStatus(info.agentId)),
341
+ result: {
342
+ success: false,
343
+ message: `Remote auth management is disabled. Run ${hostLogoutCommand(info, this.config)} on the host.`,
344
+ },
345
+ };
346
+ }
347
+ const current = await this.checkAgentAuth(info);
348
+ if (current.method === "api-key") {
349
+ return {
350
+ ...(await this.authStatus(info.agentId)),
351
+ result: {
352
+ success: false,
353
+ message: "Cannot logout while API-key authentication is configured. Remove the API key from .env to use CLI auth.",
354
+ },
355
+ };
356
+ }
357
+ const result = await this.startAgentLogout(info);
358
+ this.appendAudit({
359
+ action: "command",
360
+ status: result.success ? "ok" : "failed",
361
+ contextKey: WEB_CONTEXT_KEY,
362
+ agentId: info.agentId,
363
+ threadId: info.threadId,
364
+ workspace: info.workspace,
365
+ description: "logout",
366
+ detail: result.message,
367
+ });
368
+ return { ...(await this.authStatus(info.agentId)), result };
369
+ }
370
+ finally {
371
+ if (dispose) {
372
+ session.dispose();
373
+ }
374
+ }
375
+ }
376
+ async chatHistory(limit = 200) {
377
+ const session = await this.getSession(true);
378
+ return this.chatStore.list(this.publicInfo(session).threadId, limit);
379
+ }
380
+ async sessionDetail(threadId) {
381
+ const session = await this.getSession(true);
382
+ const record = session.getSessionRecord(threadId);
383
+ return {
384
+ record,
385
+ active: this.publicInfo(session),
386
+ messages: this.chatStore.list(threadId, 100),
387
+ activity: this.activity({ limit: 100 }).filter((event) => event.threadId === threadId),
52
388
  };
53
389
  }
390
+ async clearChatHistory() {
391
+ const session = await this.getSession(true);
392
+ const removed = this.chatStore.clear(this.publicInfo(session).threadId);
393
+ const messages = await this.chatHistory();
394
+ this.broadcast({ type: "chat_history", messages });
395
+ return { removed, messages };
396
+ }
397
+ activity(options = {}) {
398
+ return this.activityStore.list(options);
399
+ }
400
+ async retry() {
401
+ const cached = this.promptStore.getLastPrompt(WEB_CONTEXT_KEY);
402
+ if (!cached) {
403
+ throw new Error("Nothing to retry. Send a message first.");
404
+ }
405
+ this.appendAudit({
406
+ action: "command",
407
+ status: "ok",
408
+ contextKey: WEB_CONTEXT_KEY,
409
+ description: "retry",
410
+ detail: cached.description,
411
+ });
412
+ return this.sendEnvelope(cached);
413
+ }
414
+ async sync() {
415
+ const session = await this.getSession(true);
416
+ const info = this.publicInfo(session);
417
+ if (!(info.capabilities ?? CODEX_AGENT_CAPABILITIES).externalActivity) {
418
+ throw new Error(`${info.agentLabel} has no external state watcher to sync.`);
419
+ }
420
+ const result = session.syncFromAgentState({ reattach: true });
421
+ if (result.changed) {
422
+ this.updateSession(session);
423
+ }
424
+ this.appendActivity({
425
+ source: "web",
426
+ status: "info",
427
+ type: "session_sync",
428
+ threadId: result.info.threadId,
429
+ workspace: result.info.workspace,
430
+ agentId: result.info.agentId,
431
+ detail: result.changedFields.length > 0 ? result.changedFields.join(", ") : "already in sync",
432
+ });
433
+ this.appendAudit({
434
+ action: "command",
435
+ status: "ok",
436
+ contextKey: WEB_CONTEXT_KEY,
437
+ agentId: result.info.agentId,
438
+ threadId: result.info.threadId,
439
+ workspace: result.info.workspace,
440
+ description: "sync",
441
+ detail: result.changedFields.join(", ") || "none",
442
+ });
443
+ return result;
444
+ }
54
445
  async listSessions(limit = 80, query = "") {
55
446
  return this.filteredSessions(await this.getSession(true), query, Math.max(1, limit * 3)).slice(0, limit);
56
447
  }
@@ -90,7 +481,12 @@ export class RelayRuntime {
90
481
  });
91
482
  }
92
483
  async listModels() {
93
- return (await this.getSession(true)).listModels();
484
+ const session = await this.getSession(true);
485
+ const info = this.publicInfo(session);
486
+ await session.refreshModels({ force: true }).catch((error) => {
487
+ console.warn(`Failed to refresh ${agentLabel(info.agentId)} models: ${error instanceof Error ? error.message : String(error)}`);
488
+ });
489
+ return session.listModels();
94
490
  }
95
491
  async setAgent(agentId) {
96
492
  if (!enabledAgents(this.config).includes(agentId)) {
@@ -101,10 +497,32 @@ export class RelayRuntime {
101
497
  return this.publicInfo(session);
102
498
  }
103
499
  async newSession(options = {}) {
104
- const session = await this.getSession(true);
500
+ const session = options.agentId ? await this.registry.switchAgent(WEB_CONTEXT_KEY, options.agentId) : await this.getSession(true);
105
501
  this.ensureIdle(session);
502
+ if (options.reasoningEffort) {
503
+ const reasoningOptions = agentReasoningOptions(session.getInfo().agentId);
504
+ if (!reasoningOptions.includes(options.reasoningEffort)) {
505
+ throw new Error(`Invalid ${agentReasoningLabel(session.getInfo().agentId)} value: ${options.reasoningEffort}`);
506
+ }
507
+ session.setReasoningEffort(options.reasoningEffort);
508
+ }
509
+ if (options.launchProfileId && (session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).launchProfiles) {
510
+ session.setLaunchProfile(options.launchProfileId);
511
+ }
512
+ if (typeof options.fastMode === "boolean" && (session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).fastMode) {
513
+ session.setFastMode(options.fastMode);
514
+ }
106
515
  const info = await session.newThread(options.workspace, options.model);
107
516
  this.updateSession(session);
517
+ this.appendActivity({
518
+ source: "web",
519
+ status: "info",
520
+ type: "session_new",
521
+ threadId: info.threadId,
522
+ workspace: info.workspace,
523
+ agentId: info.agentId,
524
+ detail: "New dashboard session created.",
525
+ });
108
526
  return this.publicInfo(session);
109
527
  }
110
528
  async switchSession(threadId) {
@@ -112,6 +530,16 @@ export class RelayRuntime {
112
530
  this.ensureIdle(session);
113
531
  const info = await session.switchSession(threadId);
114
532
  this.updateSession(session);
533
+ this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
534
+ this.appendActivity({
535
+ source: "web",
536
+ status: "info",
537
+ type: "session_switch",
538
+ threadId: info.threadId,
539
+ workspace: info.workspace,
540
+ agentId: info.agentId,
541
+ detail: "Dashboard switched session.",
542
+ });
115
543
  return this.publicInfo(session);
116
544
  }
117
545
  async attachSession(threadId) {
@@ -127,7 +555,7 @@ export class RelayRuntime {
127
555
  async setReasoningEffort(effort) {
128
556
  const session = await this.getSession(true);
129
557
  this.ensureIdle(session);
130
- const options = session.getInfo().agentId === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
558
+ const options = agentReasoningOptions(session.getInfo().agentId);
131
559
  if (!options.includes(effort)) {
132
560
  throw new Error(`Invalid ${agentReasoningLabel(session.getInfo().agentId)} value: ${effort}`);
133
561
  }
@@ -161,6 +589,16 @@ export class RelayRuntime {
161
589
  }
162
590
  async abort() {
163
591
  const session = await this.getSession(true);
592
+ const snapshot = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
593
+ if (snapshot?.activity.active && !session.isProcessing()) {
594
+ this.broadcast({
595
+ type: "status",
596
+ level: "warn",
597
+ message: `Cannot abort the external ${snapshot.agentLabel} CLI task from NordRelay. Stop it in the terminal where it is running.`,
598
+ at: new Date().toISOString(),
599
+ });
600
+ return;
601
+ }
164
602
  await session.abort();
165
603
  this.broadcast({ type: "status", level: "warn", message: "Current operation aborted.", at: new Date().toISOString() });
166
604
  }
@@ -238,8 +676,35 @@ export class RelayRuntime {
238
676
  }
239
677
  async sendEnvelope(envelope) {
240
678
  const session = await this.getSession(false);
241
- if (session.isProcessing()) {
679
+ const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
680
+ if (session.isProcessing() || external?.activity.active) {
242
681
  const queued = this.promptStore.enqueue(WEB_CONTEXT_KEY, envelope);
682
+ const info = this.publicInfo(session);
683
+ this.appendActivity({
684
+ source: "web",
685
+ status: "queued",
686
+ type: "prompt_queued",
687
+ threadId: info.threadId,
688
+ workspace: info.workspace,
689
+ agentId: info.agentId,
690
+ prompt: envelope.description,
691
+ detail: external?.activity.active
692
+ ? `Queued because ${external.agentLabel} CLI is still processing another task.`
693
+ : `Queued at position ${this.promptStore.list(WEB_CONTEXT_KEY).length}.`,
694
+ });
695
+ this.appendAudit({
696
+ action: "prompt_queued",
697
+ status: "ok",
698
+ contextKey: WEB_CONTEXT_KEY,
699
+ agentId: info.agentId,
700
+ threadId: info.threadId,
701
+ workspace: info.workspace,
702
+ promptId: queued.id,
703
+ description: envelope.description,
704
+ });
705
+ if (external?.activity.active) {
706
+ this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.promptStore.list(WEB_CONTEXT_KEY).length} queued.`, "info");
707
+ }
243
708
  this.broadcastQueue();
244
709
  return { queued: true, queueId: queued.id };
245
710
  }
@@ -251,6 +716,9 @@ export class RelayRuntime {
251
716
  queue() {
252
717
  return this.promptStore.list(WEB_CONTEXT_KEY).map(queueItemDto);
253
718
  }
719
+ queuePaused() {
720
+ return this.promptStore.isPaused(WEB_CONTEXT_KEY);
721
+ }
254
722
  queueAction(action, id) {
255
723
  if (action === "pause")
256
724
  this.promptStore.pause(WEB_CONTEXT_KEY);
@@ -272,6 +740,20 @@ export class RelayRuntime {
272
740
  this.promptStore.enqueueFront(WEB_CONTEXT_KEY, item);
273
741
  void this.drainQueue().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
274
742
  }
743
+ this.appendActivity({
744
+ source: "web",
745
+ status: "info",
746
+ type: "queue_updated",
747
+ threadId: null,
748
+ workspace: this.config.workspace,
749
+ detail: id ? `${action}: ${id}` : action,
750
+ });
751
+ this.appendAudit({
752
+ action: "queue_updated",
753
+ status: "ok",
754
+ contextKey: WEB_CONTEXT_KEY,
755
+ description: id ? `${action}: ${id}` : action,
756
+ });
275
757
  this.broadcastQueue();
276
758
  return this.queue();
277
759
  }
@@ -298,6 +780,38 @@ export class RelayRuntime {
298
780
  });
299
781
  return bundle ? { path: bundle.localPath, name: bundle.name } : null;
300
782
  }
783
+ async artifactPreview(turnId, relativePath) {
784
+ const report = await this.artifact(turnId);
785
+ const artifact = report?.artifacts.find((candidate) => candidate.relativePath.split(path.sep).join("/") === relativePath);
786
+ if (!artifact) {
787
+ return null;
788
+ }
789
+ const extension = path.extname(artifact.name).toLowerCase();
790
+ if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"].includes(extension)) {
791
+ return {
792
+ kind: "image",
793
+ name: artifact.name,
794
+ sizeBytes: artifact.sizeBytes,
795
+ };
796
+ }
797
+ if (!isPreviewableTextFile(extension, artifact.sizeBytes)) {
798
+ return {
799
+ kind: "unsupported",
800
+ name: artifact.name,
801
+ sizeBytes: artifact.sizeBytes,
802
+ detail: artifact.sizeBytes > MAX_TEXT_PREVIEW_BYTES ? "File is too large for inline preview." : "File type is not previewable.",
803
+ };
804
+ }
805
+ const buffer = await readFile(artifact.localPath);
806
+ const truncated = buffer.byteLength > MAX_TEXT_PREVIEW_BYTES;
807
+ return {
808
+ kind: "text",
809
+ name: artifact.name,
810
+ sizeBytes: artifact.sizeBytes,
811
+ truncated,
812
+ text: buffer.subarray(0, MAX_TEXT_PREVIEW_BYTES).toString("utf8"),
813
+ };
814
+ }
301
815
  async logs(target = "connector", lines = 100) {
302
816
  if (target === "update") {
303
817
  const { getUpdateLogPath } = await import("./operations.js");
@@ -305,19 +819,239 @@ export class RelayRuntime {
305
819
  }
306
820
  return readFormattedLogTail(lines);
307
821
  }
822
+ restartConnector() {
823
+ spawnConnectorRestart();
824
+ this.broadcastStatus("Restart requested. The dashboard may disconnect briefly.", "warn");
825
+ this.appendActivity({
826
+ source: "web",
827
+ status: "info",
828
+ type: "restart_requested",
829
+ threadId: null,
830
+ workspace: this.config.workspace,
831
+ detail: "Dashboard requested a connector restart.",
832
+ });
833
+ return { ok: true, message: "Restart requested." };
834
+ }
308
835
  dispose() {
836
+ if (this.externalMonitor) {
837
+ clearInterval(this.externalMonitor);
838
+ }
309
839
  this.registry.disposeAll();
310
840
  this.subscribers.clear();
311
841
  }
842
+ async monitorExternalActivity() {
843
+ const session = await this.getSession(true);
844
+ const info = this.publicInfo(session);
845
+ if (!info.capabilities.externalActivity || !info.threadId || session.isProcessing()) {
846
+ return;
847
+ }
848
+ const snapshot = getExternalSnapshotForSession(session, this.config, {
849
+ afterLine: this.externalMirror?.threadId === info.threadId ? this.externalMirror.lastLine : Number.MAX_SAFE_INTEGER,
850
+ }) ?? getExternalSnapshotForSession(session, this.config, {
851
+ maxEvents: 0,
852
+ });
853
+ if (!snapshot) {
854
+ return;
855
+ }
856
+ if (!this.externalMirror || this.externalMirror.threadId !== snapshot.threadId || this.externalMirror.rolloutPath !== snapshot.sourcePath) {
857
+ this.externalMirror = {
858
+ threadId: snapshot.threadId,
859
+ rolloutPath: snapshot.sourcePath,
860
+ lastLine: snapshot.lineCount,
861
+ turnId: snapshot.activity.turnId,
862
+ startedAt: snapshot.activity.startedAt?.toISOString() ?? null,
863
+ };
864
+ if (snapshot.activity.active) {
865
+ this.startExternalTurn(snapshot);
866
+ }
867
+ return;
868
+ }
869
+ const mirror = this.externalMirror;
870
+ if (snapshot.activity.active) {
871
+ if (mirror.turnId !== snapshot.activity.turnId) {
872
+ mirror.turnId = snapshot.activity.turnId;
873
+ mirror.startedAt = snapshot.activity.startedAt?.toISOString() ?? null;
874
+ mirror.latestAgentLine = undefined;
875
+ this.startExternalTurn(snapshot);
876
+ }
877
+ this.broadcastExternalEvents(snapshot, snapshot.events.filter((event) => event.lineNumber > mirror.lastLine));
878
+ mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
879
+ mirror.latestStatus = externalStatusLine(snapshot, this.queue().length);
880
+ this.broadcastStatus(mirror.latestStatus, "info");
881
+ return;
882
+ }
883
+ const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
884
+ if (terminalEvent && terminalEvent.lineNumber > mirror.lastLine) {
885
+ const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
886
+ const finalText = finalAgent?.text ?? snapshot.latestAgentMessage;
887
+ const finalLine = finalAgent?.lineNumber ?? snapshot.lineCount;
888
+ if (finalText && finalLine !== mirror.latestAgentLine) {
889
+ this.chatStore.append({
890
+ threadId: snapshot.threadId,
891
+ role: "agent",
892
+ text: finalText,
893
+ source: "cli",
894
+ turnId: terminalEvent.turnId ?? undefined,
895
+ });
896
+ this.broadcast({ type: "text_delta", id: terminalEvent.turnId ?? "cli", delta: finalText });
897
+ mirror.latestAgentLine = finalLine;
898
+ }
899
+ const externalStartedAt = mirror.startedAt ? new Date(mirror.startedAt) : snapshot.activity.startedAt;
900
+ this.broadcast({
901
+ type: "turn_complete",
902
+ id: terminalEvent.turnId ?? "cli",
903
+ at: terminalEvent.timestamp?.toISOString() ?? new Date().toISOString(),
904
+ });
905
+ this.appendActivity({
906
+ source: "cli",
907
+ status: terminalEvent.status === "aborted" ? "aborted" : terminalEvent.status === "failed" ? "failed" : "completed",
908
+ type: "cli_turn_finished",
909
+ threadId: snapshot.threadId,
910
+ workspace: info.workspace,
911
+ agentId: info.agentId,
912
+ prompt: snapshot.latestUserMessage ?? undefined,
913
+ detail: `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`,
914
+ durationMs: durationFromDates(externalStartedAt, terminalEvent.timestamp),
915
+ });
916
+ if (externalStartedAt && terminalEvent.turnId) {
917
+ await this.persistWorkspaceArtifactsForTurn(info.workspace, terminalEvent.turnId, externalStartedAt);
918
+ }
919
+ mirror.latestStatus = `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`;
920
+ this.broadcastStatus(`${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`, terminalEvent.status === "failed" ? "error" : terminalEvent.status === "aborted" ? "warn" : "info");
921
+ this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
922
+ await this.drainQueue();
923
+ }
924
+ mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
925
+ }
926
+ startExternalTurn(snapshot) {
927
+ const prompt = snapshot.latestUserMessage ?? `${snapshot.agentLabel} CLI task`;
928
+ this.chatStore.append({
929
+ threadId: snapshot.threadId,
930
+ role: "user",
931
+ text: prompt,
932
+ source: "cli",
933
+ turnId: snapshot.activity.turnId ?? undefined,
934
+ timestamp: snapshot.activity.startedAt?.toISOString(),
935
+ });
936
+ this.broadcast({
937
+ type: "turn_start",
938
+ id: snapshot.activity.turnId ?? "cli",
939
+ prompt,
940
+ at: snapshot.activity.startedAt?.toISOString() ?? new Date().toISOString(),
941
+ source: "cli",
942
+ });
943
+ this.appendActivity({
944
+ source: "cli",
945
+ status: "running",
946
+ type: "cli_turn_started",
947
+ threadId: snapshot.threadId,
948
+ prompt,
949
+ detail: `${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
950
+ });
951
+ }
952
+ broadcastExternalEvents(snapshot, events) {
953
+ for (const event of events) {
954
+ if (event.kind === "tool" && event.status === "started") {
955
+ this.broadcast({
956
+ type: "tool_start",
957
+ id: snapshot.activity.turnId ?? "cli",
958
+ toolCallId: `cli-${event.lineNumber}`,
959
+ toolName: event.toolName ?? "tool",
960
+ });
961
+ this.appendActivity({
962
+ source: "cli",
963
+ status: "running",
964
+ type: "cli_tool_started",
965
+ threadId: snapshot.threadId,
966
+ detail: event.toolName ?? "tool",
967
+ });
968
+ }
969
+ if (event.kind === "tool" && event.status === "finished") {
970
+ this.broadcast({
971
+ type: "tool_end",
972
+ id: snapshot.activity.turnId ?? "cli",
973
+ toolCallId: `cli-${event.lineNumber}`,
974
+ isError: false,
975
+ });
976
+ }
977
+ }
978
+ }
312
979
  async getSession(deferThreadStart) {
313
980
  return this.registry.getOrCreate(WEB_CONTEXT_KEY, { deferThreadStart });
314
981
  }
982
+ async getControlSession(agentId) {
983
+ const active = await this.getSession(true);
984
+ const activeInfo = this.publicInfo(active);
985
+ if (!agentId || agentId === activeInfo.agentId) {
986
+ return { session: active, dispose: false };
987
+ }
988
+ if (!enabledAgents(this.config).includes(agentId)) {
989
+ throw new Error(`Agent is not enabled: ${agentId}`);
990
+ }
991
+ const session = await createAgentSessionService(this.config, agentId, {
992
+ deferThreadStart: true,
993
+ workspace: activeInfo.workspace,
994
+ });
995
+ return { session, dispose: true };
996
+ }
315
997
  async ensureActiveThread(session) {
316
998
  if (!session.hasActiveThread()) {
317
999
  await session.newThread();
318
1000
  this.updateSession(session);
319
1001
  }
320
1002
  }
1003
+ async checkAgentAuth(info) {
1004
+ if (info.agentId === "pi") {
1005
+ return checkPiAuthStatus(info.model);
1006
+ }
1007
+ if (info.agentId === "hermes") {
1008
+ return checkHermesAuthStatus({
1009
+ baseUrl: this.config.hermesApiBaseUrl,
1010
+ apiKey: this.config.hermesApiKey,
1011
+ });
1012
+ }
1013
+ if (info.agentId === "openclaw") {
1014
+ return checkOpenClawAuthStatus({
1015
+ gatewayUrl: this.config.openClawGatewayUrl,
1016
+ token: this.config.openClawGatewayToken,
1017
+ password: this.config.openClawGatewayPassword,
1018
+ });
1019
+ }
1020
+ if (info.agentId === "claude-code") {
1021
+ return checkClaudeCodeAuthStatus(this.config.claudeCodeCliPath);
1022
+ }
1023
+ return checkAuthStatus(this.config.codexApiKey);
1024
+ }
1025
+ async startAgentLogin(info) {
1026
+ if (info.agentId === "hermes") {
1027
+ return startHermesLogin(this.config.hermesCliPath);
1028
+ }
1029
+ if (info.agentId === "claude-code") {
1030
+ return startClaudeCodeLogin(this.config.claudeCodeCliPath);
1031
+ }
1032
+ if (info.agentId === "codex") {
1033
+ return startCodexLogin();
1034
+ }
1035
+ return {
1036
+ success: false,
1037
+ message: `${info.agentLabel} login is not managed by NordRelay. Run the agent login flow on the host.`,
1038
+ };
1039
+ }
1040
+ async startAgentLogout(info) {
1041
+ if (info.agentId === "hermes") {
1042
+ return startHermesLogout(this.config.hermesCliPath);
1043
+ }
1044
+ if (info.agentId === "claude-code") {
1045
+ return startClaudeCodeLogout(this.config.claudeCodeCliPath);
1046
+ }
1047
+ if (info.agentId === "codex") {
1048
+ return startCodexLogout();
1049
+ }
1050
+ return {
1051
+ success: false,
1052
+ message: `${info.agentLabel} logout is not managed by NordRelay. Run the agent logout flow on the host.`,
1053
+ };
1054
+ }
321
1055
  ensureIdle(session) {
322
1056
  if (session.isProcessing()) {
323
1057
  throw new Error("The active session is still processing a turn.");
@@ -327,9 +1061,9 @@ export class RelayRuntime {
327
1061
  await this.ensureActiveThread(session);
328
1062
  const info = session.getInfo();
329
1063
  if ((info.capabilities ?? CODEX_AGENT_CAPABILITIES).auth) {
330
- const auth = await checkAuthStatus(this.config.codexApiKey);
1064
+ const auth = await this.checkAgentAuth(info);
331
1065
  if (!auth.authenticated) {
332
- throw new Error(`Codex is not authenticated: ${auth.detail}`);
1066
+ throw new Error(`${agentLabel(info.agentId)} is not authenticated: ${auth.detail}`);
333
1067
  }
334
1068
  }
335
1069
  const workspacePolicy = evaluateWorkspacePolicy(session.getInfo().workspace, this.config);
@@ -338,32 +1072,155 @@ export class RelayRuntime {
338
1072
  }
339
1073
  const turnId = randomUUID().slice(0, 12);
340
1074
  this.currentTurnId = turnId;
1075
+ this.currentTurnStartedAt = Date.now();
341
1076
  this.accumulatedText = "";
1077
+ this.currentProgress = {
1078
+ id: turnId,
1079
+ source: "web",
1080
+ status: "running",
1081
+ prompt: envelope.description,
1082
+ agentId: info.agentId,
1083
+ agentLabel: info.agentLabel,
1084
+ threadId: info.threadId,
1085
+ workspace: info.workspace,
1086
+ startedAt: new Date(this.currentTurnStartedAt).toISOString(),
1087
+ updatedAt: new Date(this.currentTurnStartedAt).toISOString(),
1088
+ durationMs: 0,
1089
+ outputChars: 0,
1090
+ tools: [],
1091
+ };
342
1092
  this.promptStore.setLastPrompt(WEB_CONTEXT_KEY, envelope);
343
- this.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: new Date().toISOString() });
1093
+ const startedDate = new Date();
1094
+ const startedAt = startedDate.toISOString();
1095
+ this.chatStore.append({
1096
+ threadId: info.threadId ?? "pending",
1097
+ role: "user",
1098
+ text: envelope.description,
1099
+ source: "web",
1100
+ turnId,
1101
+ timestamp: startedAt,
1102
+ });
1103
+ this.appendActivity({
1104
+ source: "web",
1105
+ status: "running",
1106
+ type: "prompt_started",
1107
+ threadId: info.threadId,
1108
+ workspace: info.workspace,
1109
+ agentId: info.agentId,
1110
+ prompt: envelope.description,
1111
+ });
1112
+ this.appendAudit({
1113
+ action: "prompt_started",
1114
+ status: "ok",
1115
+ contextKey: WEB_CONTEXT_KEY,
1116
+ agentId: info.agentId,
1117
+ threadId: info.threadId,
1118
+ workspace: info.workspace,
1119
+ description: envelope.description,
1120
+ });
1121
+ this.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: startedAt, source: "web" });
344
1122
  const callbacks = {
345
1123
  onTextDelta: (delta) => {
346
1124
  this.accumulatedText += delta;
1125
+ this.updateCurrentProgress({ outputChars: this.accumulatedText.length });
347
1126
  this.broadcast({ type: "text_delta", id: turnId, delta });
348
1127
  },
349
- onToolStart: (toolName, toolCallId) => this.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName }),
350
- onToolUpdate: (toolCallId, partialResult) => this.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult }),
351
- onToolEnd: (toolCallId, isError) => this.broadcast({ type: "tool_end", id: turnId, toolCallId, isError }),
352
- onTodoUpdate: (items) => this.broadcast({ type: "todo_update", id: turnId, items }),
1128
+ onToolStart: (toolName, toolCallId) => {
1129
+ this.addCurrentTool(toolName);
1130
+ this.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName });
1131
+ },
1132
+ onToolUpdate: (toolCallId, partialResult) => {
1133
+ this.updateCurrentProgress();
1134
+ this.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult });
1135
+ },
1136
+ onToolEnd: (toolCallId, isError) => {
1137
+ this.updateCurrentProgress({ currentTool: undefined });
1138
+ this.broadcast({ type: "tool_end", id: turnId, toolCallId, isError });
1139
+ },
1140
+ onTodoUpdate: (items) => {
1141
+ this.updateCurrentProgress({ detail: `Plan: ${items.filter((item) => item.completed).length}/${items.length} done` });
1142
+ this.broadcast({ type: "todo_update", id: turnId, items });
1143
+ },
353
1144
  onTurnComplete: () => { },
354
1145
  onAgentEnd: () => this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() }),
355
1146
  };
356
1147
  try {
357
1148
  await session.prompt(envelope.input, callbacks);
358
1149
  this.updateSession(session);
1150
+ await this.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, startedDate);
1151
+ if (this.accumulatedText.trim()) {
1152
+ this.chatStore.append({
1153
+ threadId: info.threadId ?? "pending",
1154
+ role: "agent",
1155
+ text: this.accumulatedText,
1156
+ source: "web",
1157
+ turnId,
1158
+ });
1159
+ }
1160
+ this.appendActivity({
1161
+ source: "web",
1162
+ status: "completed",
1163
+ type: "prompt_completed",
1164
+ threadId: info.threadId,
1165
+ workspace: info.workspace,
1166
+ agentId: info.agentId,
1167
+ prompt: envelope.description,
1168
+ durationMs: Date.now() - this.currentTurnStartedAt,
1169
+ });
1170
+ this.appendAudit({
1171
+ action: "prompt_completed",
1172
+ status: "ok",
1173
+ contextKey: WEB_CONTEXT_KEY,
1174
+ agentId: info.agentId,
1175
+ threadId: info.threadId,
1176
+ workspace: info.workspace,
1177
+ description: envelope.description,
1178
+ });
1179
+ this.updateCurrentProgress({ status: "completed" });
359
1180
  this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() });
1181
+ this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
360
1182
  }
361
1183
  catch (error) {
362
- this.broadcast({ type: "turn_error", id: turnId, error: friendlyErrorText(error), at: new Date().toISOString() });
1184
+ const errorText = friendlyErrorText(error);
1185
+ this.chatStore.append({
1186
+ threadId: info.threadId ?? "pending",
1187
+ role: "system",
1188
+ text: `Error: ${errorText}`,
1189
+ source: "web",
1190
+ turnId,
1191
+ });
1192
+ this.appendActivity({
1193
+ source: "web",
1194
+ status: "failed",
1195
+ type: "prompt_failed",
1196
+ threadId: info.threadId,
1197
+ workspace: info.workspace,
1198
+ agentId: info.agentId,
1199
+ prompt: envelope.description,
1200
+ detail: errorText,
1201
+ durationMs: Date.now() - this.currentTurnStartedAt,
1202
+ });
1203
+ this.appendAudit({
1204
+ action: "prompt_failed",
1205
+ status: "failed",
1206
+ contextKey: WEB_CONTEXT_KEY,
1207
+ agentId: info.agentId,
1208
+ threadId: info.threadId,
1209
+ workspace: info.workspace,
1210
+ description: envelope.description,
1211
+ detail: errorText,
1212
+ });
1213
+ this.updateCurrentProgress({ status: "failed", detail: errorText });
1214
+ this.broadcast({ type: "turn_error", id: turnId, error: errorText, at: new Date().toISOString() });
1215
+ this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
363
1216
  throw error;
364
1217
  }
365
1218
  finally {
366
1219
  this.currentTurnId = null;
1220
+ if (this.currentProgress) {
1221
+ this.currentProgress.durationMs = Date.now() - this.currentTurnStartedAt;
1222
+ this.currentProgress.updatedAt = new Date().toISOString();
1223
+ }
367
1224
  await this.drainQueue();
368
1225
  }
369
1226
  }
@@ -375,6 +1232,11 @@ export class RelayRuntime {
375
1232
  try {
376
1233
  const session = await this.getSession(false);
377
1234
  while (!session.isProcessing()) {
1235
+ const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
1236
+ if (external?.activity.active) {
1237
+ this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.queue().length} queued.`, "info");
1238
+ return;
1239
+ }
378
1240
  const next = this.promptStore.dequeue(WEB_CONTEXT_KEY);
379
1241
  this.broadcastQueue();
380
1242
  if (!next) {
@@ -391,8 +1253,69 @@ export class RelayRuntime {
391
1253
  this.registry.updateMetadata(WEB_CONTEXT_KEY, session);
392
1254
  this.broadcast({ type: "session_update", session: this.publicInfo(session) });
393
1255
  }
1256
+ appendActivity(input) {
1257
+ const event = this.activityStore.append(input);
1258
+ this.broadcast({ type: "activity_update", events: this.activity({ limit: 50 }) });
1259
+ return event;
1260
+ }
1261
+ appendAudit(input) {
1262
+ return this.auditStore.append({ ...input, channelId: "web" });
1263
+ }
1264
+ updateCurrentProgress(patch = {}) {
1265
+ if (!this.currentProgress) {
1266
+ return;
1267
+ }
1268
+ if ("currentTool" in patch) {
1269
+ this.currentProgress.currentTool = patch.currentTool;
1270
+ const { currentTool: _currentTool, ...rest } = patch;
1271
+ Object.assign(this.currentProgress, rest);
1272
+ }
1273
+ else {
1274
+ Object.assign(this.currentProgress, patch);
1275
+ }
1276
+ this.currentProgress.durationMs = Date.now() - this.currentTurnStartedAt;
1277
+ this.currentProgress.updatedAt = new Date().toISOString();
1278
+ }
1279
+ addCurrentTool(toolName) {
1280
+ if (!this.currentProgress) {
1281
+ return;
1282
+ }
1283
+ const existing = this.currentProgress.tools.find((tool) => tool.name === toolName);
1284
+ if (existing) {
1285
+ existing.count += 1;
1286
+ }
1287
+ else {
1288
+ this.currentProgress.tools.push({ name: toolName, count: 1 });
1289
+ }
1290
+ this.updateCurrentProgress({ currentTool: toolName, lastTool: toolName });
1291
+ }
1292
+ externalTask() {
1293
+ if (!this.externalMirror) {
1294
+ return null;
1295
+ }
1296
+ const startedAt = this.externalMirror.startedAt ?? new Date().toISOString();
1297
+ const startedMs = new Date(startedAt).getTime();
1298
+ return {
1299
+ id: this.externalMirror.turnId ?? "cli",
1300
+ source: "cli",
1301
+ status: this.externalMirror.latestStatus?.includes("failed")
1302
+ ? "failed"
1303
+ : this.externalMirror.latestStatus?.includes("aborted")
1304
+ ? "aborted"
1305
+ : this.externalMirror.latestStatus?.includes("finished") || this.externalMirror.latestStatus?.includes("completed")
1306
+ ? "completed"
1307
+ : "running",
1308
+ threadId: this.externalMirror.threadId,
1309
+ startedAt,
1310
+ updatedAt: new Date().toISOString(),
1311
+ durationMs: Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : 0,
1312
+ outputChars: 0,
1313
+ tools: [],
1314
+ detail: this.externalMirror.latestStatus ?? this.externalMirror.rolloutPath,
1315
+ };
1316
+ }
394
1317
  broadcastQueue() {
395
- this.broadcast({ type: "queue_update", queue: this.queue() });
1318
+ this.broadcast({ type: "queue_update", queue: this.queue(), paused: this.queuePaused() });
396
1319
  }
397
1320
  broadcastStatus(message, level = "info") {
398
1321
  this.broadcast({ type: "status", message, level, at: new Date().toISOString() });
@@ -417,6 +1340,20 @@ export class RelayRuntime {
417
1340
  capabilities: info.capabilities ?? CODEX_AGENT_CAPABILITIES,
418
1341
  };
419
1342
  }
1343
+ async persistWorkspaceArtifactsForTurn(workspace, turnId, startedAt) {
1344
+ const report = await collectRecentWorkspaceArtifacts(workspace, {
1345
+ since: startedAt,
1346
+ until: new Date(),
1347
+ maxFileSize: this.config.maxFileSize,
1348
+ limit: 20,
1349
+ ignoreDirs: this.config.artifactIgnoreDirs,
1350
+ ignoreGlobs: this.config.artifactIgnoreGlobs,
1351
+ });
1352
+ if (report.artifacts.length === 0 && report.skippedCount === 0 && !report.omittedCount) {
1353
+ return;
1354
+ }
1355
+ await persistWorkspaceArtifactReport(workspace, turnId, report);
1356
+ }
420
1357
  }
421
1358
  function queueItemDto(item) {
422
1359
  return {
@@ -444,6 +1381,86 @@ function artifactDto(report) {
444
1381
  })),
445
1382
  };
446
1383
  }
1384
+ function externalStatusLine(snapshot, queueLength) {
1385
+ const elapsed = snapshot.activity.startedAt
1386
+ ? formatDuration((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
1387
+ : "-";
1388
+ const tool = snapshot.latestToolName ?? "-";
1389
+ return `${snapshot.agentLabel} CLI running · ${elapsed} · tool ${tool} · ${queueLength} queued`;
1390
+ }
1391
+ function cliHealthForAgent(agentId, health) {
1392
+ if (agentId === "pi") {
1393
+ return { path: health.piCliPath, label: health.piCli, version: health.piCliVersion };
1394
+ }
1395
+ if (agentId === "hermes") {
1396
+ return { path: health.hermesCliPath, label: health.hermesCli, version: health.hermesCliVersion };
1397
+ }
1398
+ if (agentId === "openclaw") {
1399
+ return { path: health.openClawCliPath, label: health.openClawCli, version: health.openClawCliVersion };
1400
+ }
1401
+ if (agentId === "claude-code") {
1402
+ return { path: health.claudeCodeCliPath, label: health.claudeCodeCli, version: health.claudeCodeCliVersion };
1403
+ }
1404
+ return { path: health.codexCliPath, label: health.codexCli, version: health.codexCliVersion };
1405
+ }
1406
+ function versionCheckForAgent(agentId, versions) {
1407
+ if (agentId === "pi")
1408
+ return versions.pi;
1409
+ if (agentId === "hermes")
1410
+ return versions.hermes;
1411
+ if (agentId === "openclaw")
1412
+ return versions.openclaw;
1413
+ if (agentId === "claude-code")
1414
+ return versions.claudeCode;
1415
+ return versions.codex;
1416
+ }
1417
+ function hostLoginCommand(info, config) {
1418
+ if (info.agentId === "hermes") {
1419
+ return `${config.hermesCliPath ?? "hermes"} login --no-browser`;
1420
+ }
1421
+ if (info.agentId === "claude-code") {
1422
+ return `${config.claudeCodeCliPath ?? "claude"} auth login`;
1423
+ }
1424
+ if (info.agentId === "pi") {
1425
+ return `${config.piCliPath ?? "pi"} auth login`;
1426
+ }
1427
+ if (info.agentId === "openclaw") {
1428
+ return `${config.openClawCliPath ?? "openclaw"} login`;
1429
+ }
1430
+ return "codex login --device-auth";
1431
+ }
1432
+ function hostLogoutCommand(info, config) {
1433
+ if (info.agentId === "hermes") {
1434
+ return `${config.hermesCliPath ?? "hermes"} logout`;
1435
+ }
1436
+ if (info.agentId === "claude-code") {
1437
+ return `${config.claudeCodeCliPath ?? "claude"} auth logout`;
1438
+ }
1439
+ if (info.agentId === "pi") {
1440
+ return `${config.piCliPath ?? "pi"} auth logout`;
1441
+ }
1442
+ if (info.agentId === "openclaw") {
1443
+ return `${config.openClawCliPath ?? "openclaw"} logout`;
1444
+ }
1445
+ return "codex logout";
1446
+ }
1447
+ function durationFromDates(start, end) {
1448
+ if (!start || !end) {
1449
+ return undefined;
1450
+ }
1451
+ return Math.max(0, end.getTime() - start.getTime());
1452
+ }
1453
+ function formatDuration(seconds) {
1454
+ if (!Number.isFinite(seconds) || seconds < 0) {
1455
+ return "-";
1456
+ }
1457
+ if (seconds < 60) {
1458
+ return `${Math.round(seconds)}s`;
1459
+ }
1460
+ const minutes = Math.floor(seconds / 60);
1461
+ const remainder = Math.round(seconds % 60);
1462
+ return `${minutes}m ${remainder}s`;
1463
+ }
447
1464
  function normalizeMimeType(value, name) {
448
1465
  const configured = value?.trim();
449
1466
  if (configured) {
@@ -477,3 +1494,37 @@ function uploadFileDtos(files) {
477
1494
  sizeBytes: file.sizeBytes,
478
1495
  }));
479
1496
  }
1497
+ function isPreviewableTextFile(extension, sizeBytes) {
1498
+ if (sizeBytes > MAX_TEXT_PREVIEW_BYTES * 4) {
1499
+ return false;
1500
+ }
1501
+ return [
1502
+ "",
1503
+ ".c",
1504
+ ".conf",
1505
+ ".cpp",
1506
+ ".css",
1507
+ ".csv",
1508
+ ".env",
1509
+ ".go",
1510
+ ".html",
1511
+ ".java",
1512
+ ".js",
1513
+ ".json",
1514
+ ".jsx",
1515
+ ".log",
1516
+ ".md",
1517
+ ".py",
1518
+ ".rb",
1519
+ ".rs",
1520
+ ".sh",
1521
+ ".sql",
1522
+ ".toml",
1523
+ ".ts",
1524
+ ".tsx",
1525
+ ".txt",
1526
+ ".xml",
1527
+ ".yaml",
1528
+ ".yml",
1529
+ ].includes(extension);
1530
+ }