@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
package/dist/bot.js CHANGED
@@ -8,23 +8,29 @@ import { hasTelegramPermission, permissionForCallbackData, permissionForCommand,
8
8
  import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
9
9
  import { collectArtifactReport, collectRecentWorkspaceArtifacts, createArtifactZipBundle, ensureOutDir, formatArtifactSummary, getArtifactTurnReport, isTelegramImagePreview, listRecentArtifactReports, persistWorkspaceArtifactReport, pruneConnectorTurnDirs, removeArtifactTurn, telegramArtifactFilename, totalArtifactSize, } from "./artifacts.js";
10
10
  import { listAgentAdapterDescriptors } from "./agent-adapter.js";
11
+ import { AgentUpdateManager } from "./agent-updates.js";
11
12
  import { AuditLogStore } from "./audit-log.js";
12
13
  import { formatSessionLabel, renderHelpMessage, renderWelcomeFirstTime, renderWelcomeReturning, } from "./bot-ui.js";
13
14
  import { BotPreferencesStore, formatQuietHours, isQuietNow, parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
15
+ import { logTailRequests, parseAgentUpdateId, parseLogsCommand, renderAgentUpdateJobAction, renderAgentUpdateJobsAction, renderAgentUpdateLogAction, renderAgentUpdatePickerAction, renderAgentsAction, renderArtifactReportsAction, renderChannelsAction, renderLogTailsAction, renderQueueListAction, renderQueuedPromptDetailAction, renderSelfUpdateStartedAction, } from "./channel-actions.js";
14
16
  import { listChannelDescriptors } from "./channel-adapter.js";
15
- import { CODEX_REASONING_EFFORTS, CODEX_AGENT_CAPABILITIES, PI_THINKING_LEVELS, agentLabel, agentReasoningLabel, } from "./agent.js";
17
+ import { CODEX_AGENT_CAPABILITIES, agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
18
+ import { getAgentActivityLog, getAgentDiagnostics, getExternalActivityForSession, getExternalSnapshotForSession, } from "./agent-activity.js";
16
19
  import { enabledAgents } from "./agent-factory.js";
17
- import { checkAuthStatus, clearAuthCache, startLogin, startLogout } from "./codex-auth.js";
18
- import { findLaunchProfile, formatLaunchProfileBehavior, formatLaunchProfileLabel, } from "./codex-launch.js";
19
- import { getThreadActivity, getThreadActivityLog, getThreadRolloutSnapshot, } from "./codex-state.js";
20
+ import { checkAuthStatus, clearAuthCache, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
21
+ import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "./claude-code-auth.js";
22
+ import { formatLaunchProfileBehavior } from "./codex-launch.js";
20
23
  import { contextKeyFromCtx, isTelegramContextKey, isTopicContextKey, parseContextKey } from "./context-key.js";
21
24
  import { friendlyErrorText } from "./error-messages.js";
22
25
  import { escapeHTML, formatTelegramHTML } from "./format.js";
23
- import { getConnectorHealth, getUpdateLogPath, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate, } from "./operations.js";
26
+ import { getConnectorHealth, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate, } from "./operations.js";
24
27
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
28
+ import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
29
+ import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
30
+ import { checkPiAuthStatus } from "./pi-auth.js";
25
31
  import { configureRedaction, redactText } from "./redaction.js";
26
32
  import { canWriteWithLock, SessionLockStore } from "./session-locks.js";
27
- import { formatFileSize, renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
33
+ import { renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
28
34
  import { SessionRegistry } from "./session-registry.js";
29
35
  import { getAvailableBackends, transcribeAudio } from "./voice.js";
30
36
  import { getTelegramRateLimitMetrics, telegramRateLimiter } from "./telegram-rate-limit.js";
@@ -63,6 +69,37 @@ function paginateKeyboard(items, page, prefix) {
63
69
  }
64
70
  return keyboard;
65
71
  }
72
+ function actionKeyboard(rows) {
73
+ if (!rows || rows.length === 0) {
74
+ return undefined;
75
+ }
76
+ const keyboard = new InlineKeyboard();
77
+ for (const row of rows) {
78
+ for (const button of row) {
79
+ keyboard.text(button.label, telegramActionData(button.action));
80
+ }
81
+ keyboard.row();
82
+ }
83
+ return keyboard;
84
+ }
85
+ function telegramActionData(action) {
86
+ if (action === "agent-update:jobs") {
87
+ return "upd_jobs";
88
+ }
89
+ const agentUpdateStart = action.match(/^agent-update:start:(.+)$/);
90
+ if (agentUpdateStart?.[1]) {
91
+ return `upd_agent:${agentUpdateStart[1]}`;
92
+ }
93
+ const agentUpdateLog = action.match(/^agent-update:log:(.+)$/);
94
+ if (agentUpdateLog?.[1]) {
95
+ return `upd_log:${agentUpdateLog[1]}`;
96
+ }
97
+ const agentUpdateCancel = action.match(/^agent-update:cancel:(.+)$/);
98
+ if (agentUpdateCancel?.[1]) {
99
+ return `upd_cancel:${agentUpdateCancel[1]}`;
100
+ }
101
+ return action;
102
+ }
66
103
  export function createBot(config, registry) {
67
104
  configureRedaction(config.telegramRedactPatterns);
68
105
  telegramRateLimiter.configure({
@@ -90,6 +127,7 @@ export function createBot(config, registry) {
90
127
  const preferencesStore = new BotPreferencesStore(config.workspace, config.stateBackend);
91
128
  const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
92
129
  const lockStore = new SessionLockStore(config.workspace, config.stateBackend);
130
+ const agentUpdates = new AgentUpdateManager();
93
131
  const drainingQueues = new Set();
94
132
  const externalQueueTimers = new Map();
95
133
  const externalMirrors = new Map();
@@ -97,23 +135,23 @@ export function createBot(config, registry) {
97
135
  const syncInterval = config.codexSyncIntervalMs > 0
98
136
  ? setInterval(() => {
99
137
  try {
100
- registry.syncAllFromCodexState({ reattach: true });
138
+ registry.syncAllFromAgentState({ reattach: true });
101
139
  }
102
140
  catch (error) {
103
- console.error("Failed to sync sessions from Codex state:", error);
141
+ console.error("Failed to sync sessions from agent state:", error);
104
142
  }
105
143
  }, config.codexSyncIntervalMs)
106
144
  : undefined;
107
145
  syncInterval?.unref?.();
108
146
  const externalMonitorInterval = setInterval(() => {
109
147
  void monitorExternalContexts().catch((error) => {
110
- console.error("Failed to monitor external Codex activity:", error);
148
+ console.error("Failed to monitor external agent activity:", error);
111
149
  });
112
150
  }, config.codexExternalBusyCheckMs);
113
151
  externalMonitorInterval.unref?.();
114
152
  setTimeout(() => {
115
153
  void monitorExternalContexts().catch((error) => {
116
- console.error("Failed to run initial external Codex monitor:", error);
154
+ console.error("Failed to run initial external agent monitor:", error);
117
155
  });
118
156
  }, 0).unref?.();
119
157
  registry.onRemove((key) => {
@@ -152,19 +190,7 @@ export function createBot(config, registry) {
152
190
  }
153
191
  return state;
154
192
  };
155
- const getExternalActivity = (session) => {
156
- const info = session?.getInfo();
157
- if (!info || !capabilitiesOf(info).externalActivity) {
158
- return null;
159
- }
160
- const threadId = session?.getActiveThreadId();
161
- if (!threadId) {
162
- return null;
163
- }
164
- return getThreadActivity(threadId, {
165
- staleAfterMs: config.codexExternalBusyStaleMs,
166
- });
167
- };
193
+ const getExternalActivity = (session) => getExternalActivityForSession(session, config);
168
194
  const getBusyReason = (contextKey) => {
169
195
  const state = contextBusy.get(contextKey);
170
196
  const session = registry.get(contextKey);
@@ -191,6 +217,114 @@ export function createBot(config, registry) {
191
217
  const updateSessionMetadata = (contextKey, session) => {
192
218
  registry.updateMetadata(contextKey, session);
193
219
  };
220
+ const checkAgentAuthStatus = async (info) => {
221
+ if (idOf(info) === "pi") {
222
+ return checkPiAuthStatus(info.model);
223
+ }
224
+ if (idOf(info) === "hermes") {
225
+ return checkHermesAuthStatus({
226
+ baseUrl: config.hermesApiBaseUrl,
227
+ apiKey: config.hermesApiKey,
228
+ });
229
+ }
230
+ if (idOf(info) === "openclaw") {
231
+ return checkOpenClawAuthStatus({
232
+ gatewayUrl: config.openClawGatewayUrl,
233
+ token: config.openClawGatewayToken,
234
+ password: config.openClawGatewayPassword,
235
+ });
236
+ }
237
+ if (idOf(info) === "claude-code") {
238
+ return checkClaudeCodeAuthStatus(config.claudeCodeCliPath);
239
+ }
240
+ return checkAuthStatus(config.codexApiKey);
241
+ };
242
+ const agentIdForAuth = (info) => info ? idOf(info) : "codex";
243
+ const labelForAuth = (info) => info ? labelOf(info) : "Codex";
244
+ const checkLoginAuthStatus = async (info) => {
245
+ const agentId = agentIdForAuth(info);
246
+ if (agentId === "hermes") {
247
+ return checkHermesAuthStatus({
248
+ baseUrl: config.hermesApiBaseUrl,
249
+ apiKey: config.hermesApiKey,
250
+ });
251
+ }
252
+ if (agentId === "claude-code") {
253
+ return checkClaudeCodeAuthStatus(config.claudeCodeCliPath);
254
+ }
255
+ return checkAuthStatus(config.codexApiKey);
256
+ };
257
+ const agentUpdateContext = () => ({
258
+ piCliPath: config.piCliPath,
259
+ hermesCliPath: config.hermesCliPath,
260
+ openClawCliPath: config.openClawCliPath,
261
+ claudeCodeCliPath: config.claudeCodeCliPath,
262
+ });
263
+ const startTelegramAgentUpdate = async (ctx, agentId) => {
264
+ try {
265
+ const job = agentUpdates.start(agentId, agentUpdateContext());
266
+ const contextKey = contextKeyFromCtx(ctx);
267
+ if (contextKey) {
268
+ audit({
269
+ action: "command",
270
+ status: "ok",
271
+ contextKey,
272
+ agentId,
273
+ description: `update ${agentId}`,
274
+ detail: job.summary,
275
+ });
276
+ }
277
+ const rendered = renderAgentUpdateJobAction(job);
278
+ await safeReply(ctx, rendered.html, {
279
+ fallbackText: rendered.plain,
280
+ replyMarkup: actionKeyboard(rendered.buttons),
281
+ });
282
+ }
283
+ catch (error) {
284
+ const message = `Failed to start ${agentLabel(agentId)} update: ${friendlyErrorText(error)}`;
285
+ await safeReply(ctx, `<b>Update failed:</b> ${escapeHTML(message)}`, { fallbackText: message });
286
+ }
287
+ };
288
+ const startAgentLogin = (info) => {
289
+ const agentId = agentIdForAuth(info);
290
+ if (agentId === "hermes") {
291
+ return startHermesLogin(config.hermesCliPath);
292
+ }
293
+ if (agentId === "claude-code") {
294
+ return startClaudeCodeLogin(config.claudeCodeCliPath);
295
+ }
296
+ return startCodexLogin();
297
+ };
298
+ const startAgentLogout = (info) => {
299
+ const agentId = agentIdForAuth(info);
300
+ if (agentId === "hermes") {
301
+ return startHermesLogout(config.hermesCliPath);
302
+ }
303
+ if (agentId === "claude-code") {
304
+ return startClaudeCodeLogout(config.claudeCodeCliPath);
305
+ }
306
+ return startCodexLogout();
307
+ };
308
+ const hostLoginCommand = (info) => {
309
+ const agentId = agentIdForAuth(info);
310
+ if (agentId === "hermes") {
311
+ return `${config.hermesCliPath ?? "hermes"} login --no-browser`;
312
+ }
313
+ if (agentId === "claude-code") {
314
+ return `${config.claudeCodeCliPath ?? "claude"} auth login`;
315
+ }
316
+ return "codex login --device-auth";
317
+ };
318
+ const hostLogoutCommand = (info) => {
319
+ const agentId = agentIdForAuth(info);
320
+ if (agentId === "hermes") {
321
+ return `${config.hermesCliPath ?? "hermes"} logout`;
322
+ }
323
+ if (agentId === "claude-code") {
324
+ return `${config.claudeCodeCliPath ?? "claude"} auth logout`;
325
+ }
326
+ return "codex logout";
327
+ };
194
328
  const isTopicContext = (contextKey) => isTopicContextKey(contextKey);
195
329
  const getPreferences = (contextKey) => preferencesStore.get(contextKey);
196
330
  const getEffectiveMirrorMode = (contextKey) => getPreferences(contextKey).mirrorMode ?? config.telegramMirrorMode;
@@ -255,22 +389,10 @@ export function createBot(config, registry) {
255
389
  const createQueuedPromptCancelKeyboard = (contextKey, queueId, label = "Cancel queued message") => new InlineKeyboard().text(label, queueCancelCallbackData("cancel", contextKey, queueId));
256
390
  const renderQueueList = (contextKey, queue) => {
257
391
  const paused = promptStore.isPaused(contextKey);
392
+ const rendered = renderQueueListAction(queue, paused);
258
393
  if (queue.length === 0) {
259
- return {
260
- plain: paused ? "Queue is empty and paused." : "Queue is empty.",
261
- html: escapeHTML(paused ? "Queue is empty and paused." : "Queue is empty."),
262
- };
394
+ return rendered;
263
395
  }
264
- const lines = queue.map((item, index) => {
265
- const age = formatRelativeTime(new Date(item.createdAt));
266
- const attempts = item.attempts && item.attempts > 0 ? ` · attempts ${item.attempts}` : "";
267
- const error = item.lastError ? ` · last error: ${trimLine(item.lastError, 80)}` : "";
268
- const scheduled = item.notBefore && item.notBefore > Date.now()
269
- ? `scheduled ${formatLocalDateTime(new Date(item.notBefore))}`
270
- : index === 0 ? "next" : `after ${index} queued item${index === 1 ? "" : "s"}`;
271
- const eta = scheduled;
272
- return `${index + 1}. ${item.id} · ${age} · ${eta}${attempts}${error} · ${item.description}`;
273
- });
274
396
  const keyboard = new InlineKeyboard();
275
397
  queue.forEach((item, index) => {
276
398
  keyboard
@@ -283,11 +405,7 @@ export function createBot(config, registry) {
283
405
  .text("Down", queueCancelCallbackData("down", contextKey, item.id))
284
406
  .row();
285
407
  });
286
- return {
287
- plain: [paused ? "Queued prompts (paused):" : "Queued prompts:", ...lines].join("\n"),
288
- html: [paused ? "<b>Queued prompts:</b> <code>paused</code>" : "<b>Queued prompts:</b>", ...lines.map(escapeHTML)].join("\n"),
289
- keyboard,
290
- };
408
+ return { ...rendered, keyboard };
291
409
  };
292
410
  const createSystemContext = (contextKey) => {
293
411
  const parsed = parseContextKey(contextKey);
@@ -364,11 +482,9 @@ export function createBot(config, registry) {
364
482
  return;
365
483
  }
366
484
  const previous = externalMirrors.get(contextKey);
367
- const snapshot = getThreadRolloutSnapshot(threadId, {
485
+ const snapshot = getExternalSnapshotForSession(session, config, {
368
486
  afterLine: previous?.lastLine ?? Number.MAX_SAFE_INTEGER,
369
- staleAfterMs: config.codexExternalBusyStaleMs,
370
- }) ?? getThreadRolloutSnapshot(threadId, {
371
- staleAfterMs: config.codexExternalBusyStaleMs,
487
+ }) ?? getExternalSnapshotForSession(session, config, {
372
488
  maxEvents: 0,
373
489
  });
374
490
  if (!snapshot) {
@@ -382,7 +498,7 @@ export function createBot(config, registry) {
382
498
  }
383
499
  const activity = snapshot.activity;
384
500
  if (activity.active && queueLength > 0) {
385
- await updateQueueStatusMessage(contextKey, `Waiting for Codex CLI task... ${queueLength} queued${paused ? " (paused)" : ""}.`);
501
+ await updateQueueStatusMessage(contextKey, `Waiting for ${info.agentLabel} CLI task... ${queueLength} queued${paused ? " (paused)" : ""}.`);
386
502
  return;
387
503
  }
388
504
  if (!activity.active && queueLength > 0 && !paused && !session.isProcessing()) {
@@ -394,10 +510,10 @@ export function createBot(config, registry) {
394
510
  const parsed = parseContextKey(contextKey);
395
511
  const previous = externalMirrors.get(contextKey);
396
512
  let state = previous;
397
- if (!state || state.threadId !== snapshot.threadId || state.rolloutPath !== snapshot.rolloutPath) {
513
+ if (!state || state.threadId !== snapshot.threadId || state.rolloutPath !== snapshot.sourcePath) {
398
514
  state = {
399
515
  threadId: snapshot.threadId,
400
- rolloutPath: snapshot.rolloutPath,
516
+ rolloutPath: snapshot.sourcePath,
401
517
  lastLine: snapshot.lineCount,
402
518
  turnId: snapshot.activity.turnId,
403
519
  startedAt: snapshot.activity.startedAt,
@@ -458,7 +574,7 @@ export function createBot(config, registry) {
458
574
  const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
459
575
  if (terminalEvent) {
460
576
  if (mirrorMode !== "off") {
461
- const doneText = `Codex CLI task ${terminalEvent.status}.`;
577
+ const doneText = `${snapshot.agentLabel} CLI task ${terminalEvent.status}.`;
462
578
  if (state.statusMessageId) {
463
579
  await safeEditMessage(bot, chatId, state.statusMessageId, escapeHTML(doneText), {
464
580
  fallbackText: doneText,
@@ -473,8 +589,8 @@ export function createBot(config, registry) {
473
589
  }
474
590
  const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
475
591
  if (mirrorMode !== "off" && mirrorMode !== "status" && finalAgent?.text && finalAgent.lineNumber !== state.latestAgentLine) {
476
- await sendTextMessage(bot.api, chatId, "<b>Codex CLI final answer:</b>", {
477
- fallbackText: "Codex CLI final answer:",
592
+ await sendTextMessage(bot.api, chatId, `<b>${escapeHTML(snapshot.agentLabel)} CLI final answer:</b>`, {
593
+ fallbackText: `${snapshot.agentLabel} CLI final answer:`,
478
594
  messageThreadId: parsed.messageThreadId,
479
595
  });
480
596
  for (const chunk of splitMarkdownForTelegram(finalAgent.text)) {
@@ -544,7 +660,8 @@ export function createBot(config, registry) {
544
660
  }
545
661
  const busy = getBusyReason(contextKey);
546
662
  if (busy.kind === "external") {
547
- await updateQueueStatusMessage(contextKey, `Waiting for Codex CLI task... ${promptStore.list(contextKey).length} queued${promptStore.isPaused(contextKey) ? " (paused)" : ""}.`);
663
+ const label = busy.activity.agentLabel;
664
+ await updateQueueStatusMessage(contextKey, `Waiting for ${label} CLI task... ${promptStore.list(contextKey).length} queued${promptStore.isPaused(contextKey) ? " (paused)" : ""}.`);
548
665
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
549
666
  return;
550
667
  }
@@ -554,7 +671,7 @@ export function createBot(config, registry) {
554
671
  await updateQueueStatusMessage(contextKey, `CLI task finished, running queued prompt 1/${promptStore.list(contextKey).length}.`);
555
672
  await drainQueuedPrompts(ctx, contextKey, chatId, session);
556
673
  })().catch((error) => {
557
- console.error("Failed to drain queue after external Codex activity:", error);
674
+ console.error("Failed to drain queue after external CLI activity:", error);
558
675
  });
559
676
  }, config.codexExternalBusyCheckMs);
560
677
  timer.unref?.();
@@ -1109,21 +1226,21 @@ export function createBot(config, registry) {
1109
1226
  try {
1110
1227
  const sessionInfo = session.getInfo();
1111
1228
  if (capabilitiesOf(sessionInfo).auth) {
1112
- const authStatus = await checkAuthStatus(config.codexApiKey);
1229
+ const authStatus = await checkAgentAuthStatus(sessionInfo);
1113
1230
  if (!authStatus.authenticated) {
1114
1231
  await safeReply(ctx, [
1115
1232
  `<b>⚠️ ${escapeHTML(labelOf(sessionInfo))} is not authenticated.</b>`,
1116
1233
  "",
1117
1234
  `<code>${escapeHTML(authStatus.detail)}</code>`,
1118
1235
  "",
1119
- "Use /login to start authentication, or set CODEX_API_KEY on the host.",
1236
+ authHelpText(sessionInfo),
1120
1237
  ].join("\n"), {
1121
1238
  fallbackText: [
1122
1239
  `⚠️ ${labelOf(sessionInfo)} is not authenticated.`,
1123
1240
  "",
1124
1241
  authStatus.detail,
1125
1242
  "",
1126
- "Use /login to start authentication, or set CODEX_API_KEY on the host.",
1243
+ authHelpText(sessionInfo),
1127
1244
  ].join("\n"),
1128
1245
  });
1129
1246
  return;
@@ -1135,6 +1252,24 @@ export function createBot(config, registry) {
1135
1252
  });
1136
1253
  return;
1137
1254
  }
1255
+ if (idOf(sessionInfo) === "hermes" && !config.hermesEnabled) {
1256
+ await safeReply(ctx, "<b>⚠️ Hermes is disabled.</b>\nEnable it with <code>NORDRELAY_HERMES_ENABLED=true</code>.", {
1257
+ fallbackText: "⚠️ Hermes is disabled.\nEnable it with NORDRELAY_HERMES_ENABLED=true.",
1258
+ });
1259
+ return;
1260
+ }
1261
+ if (idOf(sessionInfo) === "openclaw" && !config.openClawEnabled) {
1262
+ await safeReply(ctx, "<b>⚠️ OpenClaw is disabled.</b>\nEnable it with <code>NORDRELAY_OPENCLAW_ENABLED=true</code>.", {
1263
+ fallbackText: "⚠️ OpenClaw is disabled.\nEnable it with NORDRELAY_OPENCLAW_ENABLED=true.",
1264
+ });
1265
+ return;
1266
+ }
1267
+ if (idOf(sessionInfo) === "claude-code" && !config.claudeCodeEnabled) {
1268
+ await safeReply(ctx, "<b>⚠️ Claude Code is disabled.</b>\nEnable it with <code>NORDRELAY_CLAUDE_CODE_ENABLED=true</code>.", {
1269
+ fallbackText: "⚠️ Claude Code is disabled.\nEnable it with NORDRELAY_CLAUDE_CODE_ENABLED=true.",
1270
+ });
1271
+ return;
1272
+ }
1138
1273
  if (!(await ensureActiveThread(ctx, contextKey, session))) {
1139
1274
  return;
1140
1275
  }
@@ -1148,12 +1283,13 @@ export function createBot(config, registry) {
1148
1283
  const finalExternalActivity = getExternalActivity(session);
1149
1284
  if (finalExternalActivity?.active) {
1150
1285
  const item = maybeRequeuePromptAtFront(contextKey, envelope);
1151
- const message = `Queued prompt ${item.id} at position 1. The Codex session became active in Codex CLI and is processing another task.`;
1286
+ const label = finalExternalActivity.agentLabel;
1287
+ const message = `Queued prompt ${item.id} at position 1. The ${label} session became active in ${label} CLI and is processing another task.`;
1152
1288
  await safeReply(ctx, escapeHTML(message), {
1153
1289
  fallbackText: message,
1154
1290
  replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
1155
1291
  });
1156
- await updateQueueStatusMessage(contextKey, `Waiting for Codex CLI task... ${promptStore.list(contextKey).length} queued.`);
1292
+ await updateQueueStatusMessage(contextKey, `Waiting for ${label} CLI task... ${promptStore.list(contextKey).length} queued.`);
1157
1293
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
1158
1294
  turnProgress.delete(contextKey);
1159
1295
  return;
@@ -1164,9 +1300,14 @@ export function createBot(config, registry) {
1164
1300
  status: "ok",
1165
1301
  description: envelope.description,
1166
1302
  });
1303
+ const artifactStartedAt = new Date();
1304
+ const artifactTurnId = envelope.artifactOutDir
1305
+ ? path.basename(path.dirname(envelope.artifactOutDir))
1306
+ : randomUUID().slice(0, 12);
1167
1307
  await session.prompt(envelope.input, callbacks);
1168
1308
  updateSessionMetadata(contextKey, session);
1169
1309
  await finalizeResponse();
1310
+ await deliverCliGeneratedArtifacts(contextKey, chatId, session, artifactStartedAt, artifactTurnId, messageThreadId);
1170
1311
  if (envelope.artifactOutDir) {
1171
1312
  if (config.telegramAutoSendArtifacts) {
1172
1313
  await deliverArtifacts(ctx, chatId, envelope.artifactOutDir, session.getInfo().workspace, messageThreadId);
@@ -1321,7 +1462,7 @@ export function createBot(config, registry) {
1321
1462
  const deliverArtifactReportZip = async (ctx, chatId, report, messageThreadId) => {
1322
1463
  const bundle = await createArtifactZipBundle(report.artifacts, report.outDir, {
1323
1464
  maxFileSize: config.maxFileSize,
1324
- bundleName: `codex-artifacts-${report.turnId}.zip`,
1465
+ bundleName: `nordrelay-artifacts-${report.turnId}.zip`,
1325
1466
  });
1326
1467
  if (!bundle) {
1327
1468
  await safeReply(ctx, escapeHTML("Could not create a ZIP bundle for this artifact turn."), {
@@ -1516,9 +1657,9 @@ export function createBot(config, registry) {
1516
1657
  }
1517
1658
  const { contextKey, session } = contextSession;
1518
1659
  const info = session.getInfo();
1519
- const authStatus = capabilitiesOf(info).auth ? await checkAuthStatus(config.codexApiKey) : null;
1660
+ const authStatus = capabilitiesOf(info).auth ? await checkAgentAuthStatus(info) : null;
1520
1661
  const authWarning = authStatus && !authStatus.authenticated
1521
- ? "Not authenticated. Use /login or set CODEX_API_KEY."
1662
+ ? [`${labelOf(info)} is not authenticated.`, authStatus.detail, authHelpText(info)].filter(Boolean).join(" ")
1522
1663
  : undefined;
1523
1664
  const isReturning = registry.hasMetadata(contextKey);
1524
1665
  if (isReturning) {
@@ -1537,48 +1678,12 @@ export function createBot(config, registry) {
1537
1678
  await safeReply(ctx, help.html, { fallbackText: help.plain });
1538
1679
  });
1539
1680
  bot.command("channels", async (ctx) => {
1540
- const descriptors = listChannelDescriptors();
1541
- const lines = descriptors.map((descriptor) => {
1542
- const status = descriptor.status === "available" ? "available" : "planned";
1543
- return `${descriptor.label}: ${status} · ${descriptor.capabilities.join(", ")}`;
1544
- });
1545
- const html = [
1546
- "<b>Channel adapters:</b>",
1547
- ...descriptors.map((descriptor) => {
1548
- const statusIcon = descriptor.status === "available" ? "✅" : "🟡";
1549
- const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
1550
- return `${statusIcon} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(descriptor.status)}</code>\n <code>${escapeHTML(descriptor.capabilities.join(", "))}</code>${notes}`;
1551
- }),
1552
- ].join("\n");
1553
- await safeReply(ctx, html, { fallbackText: ["Channel adapters:", ...lines].join("\n") });
1681
+ const rendered = renderChannelsAction(listChannelDescriptors());
1682
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
1554
1683
  });
1555
1684
  bot.command("agents", async (ctx) => {
1556
- const descriptors = listAgentAdapterDescriptors();
1557
- const plain = [
1558
- "Agent adapters:",
1559
- ...descriptors.map((descriptor) => {
1560
- const enabled = descriptor.id === "codex"
1561
- ? config.codexEnabled
1562
- : descriptor.id === "pi"
1563
- ? config.piEnabled
1564
- : false;
1565
- return `${descriptor.label}: ${descriptor.status}${descriptor.status === "available" ? ` · ${enabled ? "enabled" : "disabled"}` : ""}`;
1566
- }),
1567
- ].join("\n");
1568
- const html = [
1569
- "<b>Agent adapters:</b>",
1570
- ...descriptors.map((descriptor) => {
1571
- const enabled = descriptor.id === "codex"
1572
- ? config.codexEnabled
1573
- : descriptor.id === "pi"
1574
- ? config.piEnabled
1575
- : false;
1576
- const status = descriptor.status === "available" ? `${enabled ? "enabled" : "disabled"}` : "planned";
1577
- const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
1578
- return `${descriptor.status === "available" ? "✅" : "🟡"} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(status)}</code>${notes}`;
1579
- }),
1580
- ].join("\n");
1581
- await safeReply(ctx, html, { fallbackText: plain });
1685
+ const rendered = renderAgentsAction(listAgentAdapterDescriptors(), enabledAgents(config));
1686
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
1582
1687
  });
1583
1688
  bot.command("agent", async (ctx) => {
1584
1689
  const contextSession = await getContextSession(ctx, { deferThreadStart: true });
@@ -1586,11 +1691,6 @@ export function createBot(config, registry) {
1586
1691
  return;
1587
1692
  }
1588
1693
  const { contextKey, session } = contextSession;
1589
- if (!capabilitiesOf(session.getInfo()).modelSelection) {
1590
- const text = `Model selection is not supported for ${labelOf(session.getInfo())}.`;
1591
- await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1592
- return;
1593
- }
1594
1694
  if (isBusy(contextKey)) {
1595
1695
  await safeReply(ctx, escapeHTML("Cannot switch agent while a prompt is running."), {
1596
1696
  fallbackText: "Cannot switch agent while a prompt is running.",
@@ -1627,7 +1727,7 @@ export function createBot(config, registry) {
1627
1727
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1628
1728
  return;
1629
1729
  }
1630
- const authStatus = await checkAuthStatus(config.codexApiKey);
1730
+ const authStatus = info ? await checkAgentAuthStatus(info) : await checkAuthStatus(config.codexApiKey);
1631
1731
  const icon = authStatus.authenticated ? "✅" : "❌";
1632
1732
  const html = [
1633
1733
  `<b>${icon} Auth status:</b> ${authStatus.authenticated ? "authenticated" : "not authenticated"}`,
@@ -1652,8 +1752,8 @@ export function createBot(config, registry) {
1652
1752
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1653
1753
  return;
1654
1754
  }
1655
- const authStatus = await checkAuthStatus(config.codexApiKey);
1656
- if (authStatus.authenticated) {
1755
+ const authStatus = await checkLoginAuthStatus(info);
1756
+ if (agentIdForAuth(info) !== "hermes" && authStatus.authenticated) {
1657
1757
  await safeReply(ctx, `<b>✅ Already authenticated</b> via <code>${escapeHTML(authStatus.method)}</code>.`, {
1658
1758
  fallbackText: `✅ Already authenticated via ${authStatus.method}.`,
1659
1759
  });
@@ -1663,17 +1763,17 @@ export function createBot(config, registry) {
1663
1763
  await safeReply(ctx, [
1664
1764
  "<b>Telegram-initiated login is disabled.</b>",
1665
1765
  "",
1666
- "Run <code>codex login</code> on the host, or set CODEX_API_KEY in .env.",
1766
+ `Run <code>${escapeHTML(hostLoginCommand(info))}</code> on the host.`,
1667
1767
  ].join("\n"), {
1668
1768
  fallbackText: [
1669
1769
  "Telegram-initiated login is disabled.",
1670
1770
  "",
1671
- "Run 'codex login' on the host, or set CODEX_API_KEY in .env.",
1771
+ `Run '${hostLoginCommand(info)}' on the host.`,
1672
1772
  ].join("\n"),
1673
1773
  });
1674
1774
  return;
1675
1775
  }
1676
- const result = await startLogin();
1776
+ const result = await startAgentLogin(info);
1677
1777
  if (result.success) {
1678
1778
  await safeReply(ctx, `<b>🔑 Login initiated.</b>\n\n<code>${escapeHTML(result.message)}</code>`, {
1679
1779
  fallbackText: `🔑 Login initiated.\n\n${result.message}`,
@@ -1695,17 +1795,17 @@ export function createBot(config, registry) {
1695
1795
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1696
1796
  return;
1697
1797
  }
1698
- const authStatus = await checkAuthStatus(config.codexApiKey);
1798
+ const authStatus = await checkLoginAuthStatus(info);
1699
1799
  if (authStatus.method === "api-key") {
1700
1800
  await safeReply(ctx, [
1701
- "<b>Cannot logout via Telegram when using CODEX_API_KEY.</b>",
1801
+ `<b>Cannot logout via Telegram when ${escapeHTML(labelForAuth(info))} uses API-key authentication.</b>`,
1702
1802
  "",
1703
- "Remove CODEX_API_KEY from .env to use CLI-based auth instead.",
1803
+ "Remove the API key from .env to use CLI-based auth instead.",
1704
1804
  ].join("\n"), {
1705
1805
  fallbackText: [
1706
- "Cannot logout via Telegram when using CODEX_API_KEY.",
1806
+ `Cannot logout via Telegram when ${labelForAuth(info)} uses API-key authentication.`,
1707
1807
  "",
1708
- "Remove CODEX_API_KEY from .env to use CLI-based auth instead.",
1808
+ "Remove the API key from .env to use CLI-based auth instead.",
1709
1809
  ].join("\n"),
1710
1810
  });
1711
1811
  return;
@@ -1714,23 +1814,23 @@ export function createBot(config, registry) {
1714
1814
  await safeReply(ctx, [
1715
1815
  "<b>Telegram-initiated auth management is disabled.</b>",
1716
1816
  "",
1717
- "Run <code>codex logout</code> on the host.",
1817
+ `Run <code>${escapeHTML(hostLogoutCommand(info))}</code> on the host.`,
1718
1818
  ].join("\n"), {
1719
1819
  fallbackText: [
1720
1820
  "Telegram-initiated auth management is disabled.",
1721
1821
  "",
1722
- "Run 'codex logout' on the host.",
1822
+ `Run '${hostLogoutCommand(info)}' on the host.`,
1723
1823
  ].join("\n"),
1724
1824
  });
1725
1825
  return;
1726
1826
  }
1727
- if (!authStatus.authenticated) {
1827
+ if (agentIdForAuth(info) !== "hermes" && !authStatus.authenticated) {
1728
1828
  await safeReply(ctx, escapeHTML("Not currently authenticated."), {
1729
1829
  fallbackText: "Not currently authenticated.",
1730
1830
  });
1731
1831
  return;
1732
1832
  }
1733
- const result = await startLogout();
1833
+ const result = await startAgentLogout(info);
1734
1834
  if (result.success) {
1735
1835
  await safeReply(ctx, `<b>🔓 Logged out.</b>\n\n${escapeHTML(result.message)}`, {
1736
1836
  fallbackText: `🔓 Logged out.\n\n${result.message}`,
@@ -1931,16 +2031,19 @@ export function createBot(config, registry) {
1931
2031
  });
1932
2032
  });
1933
2033
  bot.command(["status", "health"], async (ctx) => {
1934
- const health = await getConnectorHealth();
1935
- const authStatus = await checkAuthStatus(config.codexApiKey);
2034
+ const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
2035
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2036
+ const authStatus = contextSession
2037
+ ? await checkAgentAuthStatus(contextSession.session.getInfo())
2038
+ : await checkAuthStatus(config.codexApiKey);
1936
2039
  const html = renderHealthHTML(health, authStatus.authenticated, getUserRole(ctx));
1937
2040
  const plain = renderHealthPlain(health, authStatus.authenticated, getUserRole(ctx));
1938
2041
  await safeReply(ctx, html, { fallbackText: plain });
1939
2042
  });
1940
2043
  bot.command("version", async (ctx) => {
1941
- const health = await getConnectorHealth();
2044
+ const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
1942
2045
  const state = await readConnectorState();
1943
- const versions = await getVersionChecks({ piCliPath: config.piCliPath });
2046
+ const versions = await getVersionChecks({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
1944
2047
  const plain = [
1945
2048
  renderVersionCheckPlain(versions.nordrelay),
1946
2049
  `Runtime status: ${state.status ?? "unknown"}`,
@@ -1948,6 +2051,12 @@ export function createBot(config, registry) {
1948
2051
  renderVersionCheckPlain(versions.codex),
1949
2052
  formatCliPathPlain("Pi CLI", health.piCliPath, health.piCli),
1950
2053
  renderVersionCheckPlain(versions.pi),
2054
+ formatCliPathPlain("Hermes CLI", health.hermesCliPath, health.hermesCli),
2055
+ renderVersionCheckPlain(versions.hermes),
2056
+ formatCliPathPlain("OpenClaw CLI", health.openClawCliPath, health.openClawCli),
2057
+ renderVersionCheckPlain(versions.openclaw),
2058
+ formatCliPathPlain("Claude Code CLI", health.claudeCodeCliPath, health.claudeCodeCli),
2059
+ renderVersionCheckPlain(versions.claudeCode),
1951
2060
  ].join("\n");
1952
2061
  const html = [
1953
2062
  renderVersionCheckHTML(versions.nordrelay),
@@ -1956,6 +2065,12 @@ export function createBot(config, registry) {
1956
2065
  renderVersionCheckHTML(versions.codex),
1957
2066
  formatCliPathHTML("Pi CLI", health.piCliPath, health.piCli),
1958
2067
  renderVersionCheckHTML(versions.pi),
2068
+ formatCliPathHTML("Hermes CLI", health.hermesCliPath, health.hermesCli),
2069
+ renderVersionCheckHTML(versions.hermes),
2070
+ formatCliPathHTML("OpenClaw CLI", health.openClawCliPath, health.openClawCli),
2071
+ renderVersionCheckHTML(versions.openclaw),
2072
+ formatCliPathHTML("Claude Code CLI", health.claudeCodeCliPath, health.claudeCodeCli),
2073
+ renderVersionCheckHTML(versions.claudeCode),
1959
2074
  ].join("\n");
1960
2075
  await safeReply(ctx, html, { fallbackText: plain });
1961
2076
  });
@@ -1993,10 +2108,10 @@ export function createBot(config, registry) {
1993
2108
  return;
1994
2109
  }
1995
2110
  const options = parseActivityOptions((ctx.message?.text ?? "").replace(/^\/activity(?:@\w+)?\s*/i, "").trim());
1996
- const events = filterActivityEvents(getThreadActivityLog(threadId, options.exportFile ? 200 : options.limit), options);
2111
+ const events = filterActivityEvents(getAgentActivityLog(contextSession.session, config, options.exportFile ? 200 : options.limit), options);
1997
2112
  const rendered = renderActivityTimeline(threadId, events, options);
1998
2113
  if (options.exportFile && ctx.chat) {
1999
- const exportPath = path.join(tmpdir(), `codex-activity-${threadId}-${randomUUID().slice(0, 8)}.txt`);
2114
+ const exportPath = path.join(tmpdir(), `nordrelay-activity-${threadId}-${randomUUID().slice(0, 8)}.txt`);
2000
2115
  await writeFile(exportPath, rendered.plain, "utf8");
2001
2116
  try {
2002
2117
  await telegramRateLimiter.run(chatBucket(ctx.chat.id), "sendDocument", () => ctx.api.sendDocument(ctx.chat.id, new InputFile(exportPath, path.basename(exportPath)), {
@@ -2066,15 +2181,17 @@ export function createBot(config, registry) {
2066
2181
  await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2067
2182
  });
2068
2183
  bot.command("diagnostics", async (ctx) => {
2069
- const health = await getConnectorHealth();
2070
- const authStatus = await checkAuthStatus(config.codexApiKey);
2184
+ const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
2071
2185
  const contextKey = contextKeyFromCtx(ctx);
2072
2186
  const queueLength = contextKey ? promptStore.list(contextKey).length : 0;
2073
2187
  const progress = contextKey ? turnProgress.get(contextKey) : undefined;
2074
2188
  const contextSession = contextKey ? await getContextSession(ctx, { deferThreadStart: true }) : null;
2075
- const rolloutDiagnostics = contextSession && capabilitiesOf(contextSession.session.getInfo()).externalActivity
2076
- ? renderRolloutDiagnostics(contextSession.session.getActiveThreadId(), config.codexExternalBusyStaleMs)
2077
- : { plain: "Rollout: no context", html: "<b>Rollout:</b> <code>no context</code>" };
2189
+ const authStatus = contextSession
2190
+ ? await checkAgentAuthStatus(contextSession.session.getInfo())
2191
+ : await checkAuthStatus(config.codexApiKey);
2192
+ const agentDiagnostics = contextSession
2193
+ ? renderAgentDiagnostics(getAgentDiagnostics(contextSession.session, config))
2194
+ : { plain: "Agent state: no context", html: "<b>Agent state:</b> <code>no context</code>" };
2078
2195
  const runtime = {
2079
2196
  rateLimit: getTelegramRateLimitMetrics(),
2080
2197
  externalMirrors: externalMirrors.size,
@@ -2087,8 +2204,8 @@ export function createBot(config, registry) {
2087
2204
  voiceLanguage: contextKey ? getEffectiveVoiceLanguage(contextKey) ?? "auto" : config.voiceDefaultLanguage ?? "auto",
2088
2205
  voiceTranscribeOnly: contextKey ? isVoiceTranscribeOnly(contextKey) : config.voiceTranscribeOnly,
2089
2206
  };
2090
- const plain = `${renderDiagnosticsPlain(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${rolloutDiagnostics.plain}`;
2091
- const html = `${renderDiagnosticsHTML(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${rolloutDiagnostics.html}`;
2207
+ const plain = `${renderDiagnosticsPlain(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${agentDiagnostics.plain}`;
2208
+ const html = `${renderDiagnosticsHTML(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${agentDiagnostics.html}`;
2092
2209
  await safeReply(ctx, html, { fallbackText: plain });
2093
2210
  });
2094
2211
  bot.command("sync", async (ctx) => {
@@ -2103,7 +2220,7 @@ export function createBot(config, registry) {
2103
2220
  await safeReply(ctx, html, { fallbackText: plain });
2104
2221
  return;
2105
2222
  }
2106
- const result = contextSession.session.syncFromCodexState({ reattach: true });
2223
+ const result = contextSession.session.syncFromAgentState({ reattach: true });
2107
2224
  if (result.changed) {
2108
2225
  updateSessionMetadata(contextSession.contextKey, contextSession.session);
2109
2226
  }
@@ -2128,20 +2245,12 @@ export function createBot(config, registry) {
2128
2245
  const rawText = ctx.message?.text ?? "";
2129
2246
  const argument = rawText.replace(/^\/logs(?:@\w+)?\s*/i, "").trim();
2130
2247
  const logRequest = parseLogsCommand(argument);
2131
- const logs = logRequest.target === "all"
2132
- ? [
2133
- { title: "Connector", tail: await readFormattedLogTail(logRequest.lines) },
2134
- { title: "Update", tail: await readFormattedLogTail(logRequest.lines, getUpdateLogPath()) },
2135
- ]
2136
- : [
2137
- {
2138
- title: logRequest.target === "update" ? "Update" : "Connector",
2139
- tail: await readFormattedLogTail(logRequest.lines, logRequest.target === "update" ? getUpdateLogPath() : undefined),
2140
- },
2141
- ];
2142
- const plain = logs.map(({ title, tail }) => renderLogTailPlain(title, tail)).join("\n\n");
2143
- const html = logs.map(({ title, tail }) => renderLogTailHTML(title, tail)).join("\n\n");
2144
- await safeReply(ctx, html, { fallbackText: plain });
2248
+ const logs = await Promise.all(logTailRequests(logRequest.target).map(async (request) => ({
2249
+ title: request.title,
2250
+ tail: await readFormattedLogTail(logRequest.lines, request.path),
2251
+ })));
2252
+ const rendered = renderLogTailsAction(logs);
2253
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2145
2254
  });
2146
2255
  bot.command("restart", async (ctx) => {
2147
2256
  await safeReply(ctx, escapeHTML("Restarting connector..."), {
@@ -2152,24 +2261,92 @@ export function createBot(config, registry) {
2152
2261
  }, 300);
2153
2262
  });
2154
2263
  bot.command("update", async (ctx) => {
2264
+ const rawText = ctx.message?.text ?? "";
2265
+ const argument = rawText.replace(/^\/update(?:@\w+)?\s*/i, "").trim();
2266
+ const tokens = argument.split(/\s+/).filter(Boolean);
2267
+ const subcommand = tokens[0]?.toLowerCase();
2268
+ if (subcommand === "agents" || subcommand === "agent") {
2269
+ const rendered = renderAgentUpdatePickerAction(listAgentAdapterDescriptors());
2270
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain, replyMarkup: actionKeyboard(rendered.buttons) });
2271
+ return;
2272
+ }
2273
+ if (subcommand === "jobs" || subcommand === "status") {
2274
+ const rendered = renderAgentUpdateJobsAction(agentUpdates.list());
2275
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2276
+ return;
2277
+ }
2278
+ if (subcommand === "log" && tokens[1]) {
2279
+ const rendered = renderAgentUpdateLogAction(agentUpdates.readLog(tokens[1]));
2280
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2281
+ return;
2282
+ }
2283
+ if (subcommand === "cancel" && tokens[1]) {
2284
+ const job = agentUpdates.cancel(tokens[1]);
2285
+ const rendered = renderAgentUpdateJobAction(job);
2286
+ await safeReply(ctx, rendered.html, {
2287
+ fallbackText: rendered.plain,
2288
+ replyMarkup: actionKeyboard(rendered.buttons),
2289
+ });
2290
+ return;
2291
+ }
2292
+ if ((subcommand === "input" || subcommand === "send") && tokens[1] && tokens.slice(2).join(" ").trim()) {
2293
+ const job = agentUpdates.sendInput(tokens[1], tokens.slice(2).join(" "));
2294
+ const rendered = renderAgentUpdateJobAction(job);
2295
+ await safeReply(ctx, rendered.html, {
2296
+ fallbackText: rendered.plain,
2297
+ replyMarkup: actionKeyboard(rendered.buttons),
2298
+ });
2299
+ return;
2300
+ }
2301
+ const requestedAgent = parseAgentUpdateId(subcommand);
2302
+ if (requestedAgent) {
2303
+ await startTelegramAgentUpdate(ctx, requestedAgent);
2304
+ return;
2305
+ }
2306
+ if (subcommand) {
2307
+ const usage = "Unknown update target. Use /update, /update agents, /update jobs, /update <agent>, /update log <id>, /update cancel <id>, or /update input <id> <text>.";
2308
+ await safeReply(ctx, escapeHTML(usage), { fallbackText: usage });
2309
+ return;
2310
+ }
2155
2311
  const update = spawnSelfUpdate();
2156
- const plain = [
2157
- "Update started.",
2158
- `Method: ${update.method}`,
2159
- update.summary,
2160
- `Source: ${update.sourceRoot}`,
2161
- `Log: ${update.logPath}`,
2162
- "Use /logs update after the restart or inspect update.log on the host.",
2163
- ].join("\n");
2164
- const html = [
2165
- "<b>Update started.</b>",
2166
- `<b>Method:</b> <code>${escapeHTML(update.method)}</code>`,
2167
- escapeHTML(update.summary),
2168
- `<b>Source:</b> <code>${escapeHTML(update.sourceRoot)}</code>`,
2169
- `<b>Log:</b> <code>${escapeHTML(update.logPath)}</code>`,
2170
- `Use <code>/logs update</code> after the restart or inspect <code>${escapeHTML(getUpdateLogPath())}</code> on the host.`,
2171
- ].join("\n");
2172
- await safeReply(ctx, html, { fallbackText: plain });
2312
+ const rendered = renderSelfUpdateStartedAction(update);
2313
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2314
+ });
2315
+ bot.callbackQuery("upd_jobs", async (ctx) => {
2316
+ await ctx.answerCallbackQuery();
2317
+ const rendered = renderAgentUpdateJobsAction(agentUpdates.list());
2318
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2319
+ });
2320
+ bot.callbackQuery(/^upd_agent:(codex|pi|hermes|openclaw|claude-code)$/, async (ctx) => {
2321
+ const agentId = ctx.match?.[1];
2322
+ if (!agentId) {
2323
+ await ctx.answerCallbackQuery();
2324
+ return;
2325
+ }
2326
+ await ctx.answerCallbackQuery({ text: `Starting ${agentLabel(agentId)} update...` });
2327
+ await startTelegramAgentUpdate(ctx, agentId);
2328
+ });
2329
+ bot.callbackQuery(/^upd_log:(.+)$/, async (ctx) => {
2330
+ const id = ctx.match?.[1];
2331
+ await ctx.answerCallbackQuery();
2332
+ if (!id) {
2333
+ return;
2334
+ }
2335
+ const rendered = renderAgentUpdateLogAction(agentUpdates.readLog(id));
2336
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2337
+ });
2338
+ bot.callbackQuery(/^upd_cancel:(.+)$/, async (ctx) => {
2339
+ const id = ctx.match?.[1];
2340
+ await ctx.answerCallbackQuery({ text: "Cancelling update..." });
2341
+ if (!id) {
2342
+ return;
2343
+ }
2344
+ const job = agentUpdates.cancel(id);
2345
+ const rendered = renderAgentUpdateJobAction(job);
2346
+ await safeReply(ctx, rendered.html, {
2347
+ fallbackText: rendered.plain,
2348
+ replyMarkup: actionKeyboard(rendered.buttons),
2349
+ });
2173
2350
  });
2174
2351
  bot.command("new", async (ctx) => {
2175
2352
  const chatId = ctx.chat?.id;
@@ -2230,8 +2407,14 @@ export function createBot(config, registry) {
2230
2407
  if (!contextSession) {
2231
2408
  return;
2232
2409
  }
2233
- const { session } = contextSession;
2410
+ const { contextKey, session } = contextSession;
2234
2411
  try {
2412
+ const busy = getBusyReason(contextKey);
2413
+ if (busy.kind === "external") {
2414
+ const text = `Cannot abort the external ${busy.activity.agentLabel} CLI task from NordRelay. Stop it in the terminal where it is running; queued Telegram messages will wait.`;
2415
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2416
+ return;
2417
+ }
2235
2418
  await session.abort();
2236
2419
  await safeReply(ctx, escapeHTML("Aborted current operation"), {
2237
2420
  fallbackText: "Aborted current operation",
@@ -2311,7 +2494,7 @@ export function createBot(config, registry) {
2311
2494
  });
2312
2495
  return;
2313
2496
  }
2314
- const rendered = renderQueuedPromptDetail(item);
2497
+ const rendered = renderQueuedPromptDetailAction(item);
2315
2498
  await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2316
2499
  return;
2317
2500
  }
@@ -2469,7 +2652,7 @@ export function createBot(config, registry) {
2469
2652
  });
2470
2653
  return;
2471
2654
  }
2472
- const rendered = renderArtifactReports(filtered);
2655
+ const rendered = renderArtifactReportsAction(filtered);
2473
2656
  await safeReply(ctx, rendered.html, {
2474
2657
  fallbackText: rendered.plain,
2475
2658
  replyMarkup: buildArtifactActionsKeyboard(filtered),
@@ -2495,7 +2678,7 @@ export function createBot(config, registry) {
2495
2678
  }
2496
2679
  return;
2497
2680
  }
2498
- const { html, plain } = renderArtifactReports(reports);
2681
+ const { html, plain } = renderArtifactReportsAction(reports);
2499
2682
  await safeReply(ctx, html, {
2500
2683
  fallbackText: plain,
2501
2684
  replyMarkup: buildArtifactActionsKeyboard(reports),
@@ -2536,28 +2719,29 @@ export function createBot(config, registry) {
2536
2719
  });
2537
2720
  return;
2538
2721
  }
2539
- const selectedLaunchProfile = session.getSelectedLaunchProfile();
2540
- const launchButtons = config.launchProfiles.map((profile, index) => ({
2541
- label: formatLaunchProfileLabel(profile, profile.id === selectedLaunchProfile.id),
2722
+ const profiles = session.listLaunchProfiles();
2723
+ const selectedLaunchProfile = session.getInfo();
2724
+ const launchButtons = profiles.map((profile, index) => ({
2725
+ label: formatAgentLaunchProfileLabel(profile, profile.id === selectedLaunchProfile.launchProfileId),
2542
2726
  callbackData: `launch_${index}`,
2543
2727
  }));
2544
- pendingLaunchPicks.set(contextKey, config.launchProfiles.map((profile) => profile.id));
2728
+ pendingLaunchPicks.set(contextKey, profiles.map((profile) => profile.id));
2545
2729
  pendingLaunchButtons.set(contextKey, launchButtons);
2546
2730
  pendingUnsafeLaunchConfirmations.delete(contextKey);
2547
2731
  const keyboard = paginateKeyboard(launchButtons, 0, "launch");
2548
2732
  const htmlLines = [
2549
- `<b>Selected launch profile:</b> <code>${escapeHTML(selectedLaunchProfile.label)}</code>`,
2550
- `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(selectedLaunchProfile))}</code>`,
2733
+ `<b>Selected launch profile:</b> <code>${escapeHTML(selectedLaunchProfile.launchProfileLabel)}</code>`,
2734
+ `<b>Behavior:</b> <code>${escapeHTML(selectedLaunchProfile.launchProfileBehavior)}</code>`,
2551
2735
  "",
2552
2736
  "Select a profile for new or reattached threads:",
2553
2737
  ];
2554
2738
  const plainLines = [
2555
- `Selected launch profile: ${selectedLaunchProfile.label}`,
2556
- `Behavior: ${formatLaunchProfileBehavior(selectedLaunchProfile)}`,
2739
+ `Selected launch profile: ${selectedLaunchProfile.launchProfileLabel}`,
2740
+ `Behavior: ${selectedLaunchProfile.launchProfileBehavior}`,
2557
2741
  "",
2558
2742
  "Select a profile for new or reattached threads:",
2559
2743
  ];
2560
- if (selectedLaunchProfile.unsafe) {
2744
+ if (selectedLaunchProfile.unsafeLaunch) {
2561
2745
  htmlLines.splice(2, 0, "⚠️ <i>Selected profile uses danger-full-access.</i>");
2562
2746
  plainLines.splice(2, 0, "⚠️ Selected profile uses danger-full-access.");
2563
2747
  }
@@ -2897,6 +3081,10 @@ export function createBot(config, registry) {
2897
3081
  });
2898
3082
  return;
2899
3083
  }
3084
+ const info = session.getInfo();
3085
+ await session.refreshModels({ force: true }).catch((error) => {
3086
+ console.warn(`Failed to refresh ${labelOf(info)} models: ${error instanceof Error ? error.message : String(error)}`);
3087
+ });
2900
3088
  const models = session.listModels();
2901
3089
  if (models.length === 0) {
2902
3090
  await safeReply(ctx, escapeHTML("No models available."), {
@@ -2906,7 +3094,7 @@ export function createBot(config, registry) {
2906
3094
  }
2907
3095
  const currentModel = session.getInfo().model ?? "(default)";
2908
3096
  const modelButtons = models.map((model) => ({
2909
- label: `${model.displayName}${model.slug === currentModel ? " ✓" : ""}`,
3097
+ label: formatModelButtonLabel(model, model.slug === currentModel),
2910
3098
  callbackData: `model_${model.slug}`,
2911
3099
  }));
2912
3100
  pendingModelButtons.set(contextKey, modelButtons);
@@ -2993,7 +3181,7 @@ export function createBot(config, registry) {
2993
3181
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2994
3182
  return;
2995
3183
  }
2996
- const efforts = idOf(info) === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
3184
+ const efforts = agentReasoningOptions(idOf(info));
2997
3185
  const current = info.reasoningEffort;
2998
3186
  const effortButtons = efforts.map((effort) => ({
2999
3187
  label: effort === current ? `${effort} ✓` : effort,
@@ -3011,7 +3199,7 @@ export function createBot(config, registry) {
3011
3199
  });
3012
3200
  };
3013
3201
  bot.command(["effort", "reasoning"], openReasoningPicker);
3014
- bot.callbackQuery(/^agent_(codex|pi)$/, async (ctx) => {
3202
+ bot.callbackQuery(/^agent_(codex|pi|hermes|openclaw|claude-code)$/, async (ctx) => {
3015
3203
  const chatId = ctx.chat?.id;
3016
3204
  const messageId = ctx.callbackQuery.message?.message_id;
3017
3205
  const selectedAgent = ctx.match?.[1];
@@ -3365,7 +3553,7 @@ export function createBot(config, registry) {
3365
3553
  await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3366
3554
  return;
3367
3555
  }
3368
- const profile = findLaunchProfile(config.launchProfiles, profileId);
3556
+ const profile = session.listLaunchProfiles().find((candidate) => candidate.id === profileId);
3369
3557
  if (!profile) {
3370
3558
  clearLaunchSelectionState(contextKey);
3371
3559
  await ctx.answerCallbackQuery({ text: "Launch profile no longer exists" });
@@ -3382,14 +3570,14 @@ export function createBot(config, registry) {
3382
3570
  .text("Cancel", `launchconfirm_no:${profile.id}`);
3383
3571
  const html = [
3384
3572
  `<b>Confirm launch profile:</b> <code>${escapeHTML(profile.label)}</code>`,
3385
- `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(profile))}</code>`,
3573
+ `<b>Behavior:</b> <code>${escapeHTML(profile.behavior)}</code>`,
3386
3574
  "",
3387
3575
  "⚠️ <b>This profile uses danger-full-access.</b>",
3388
3576
  "It will apply to new or reattached threads in this Telegram context.",
3389
3577
  ].join("\n");
3390
3578
  const plain = [
3391
3579
  `Confirm launch profile: ${profile.label}`,
3392
- `Behavior: ${formatLaunchProfileBehavior(profile)}`,
3580
+ `Behavior: ${profile.behavior}`,
3393
3581
  "",
3394
3582
  "WARNING: This profile uses danger-full-access.",
3395
3583
  "It will apply to new or reattached threads in this Telegram context.",
@@ -3410,17 +3598,18 @@ export function createBot(config, registry) {
3410
3598
  }
3411
3599
  await ctx.answerCallbackQuery({ text: `Launch set to ${profile.label}` });
3412
3600
  clearLaunchSelectionState(contextKey);
3413
- const selectedProfile = session.setLaunchProfile(profile.id);
3601
+ session.setLaunchProfile(profile.id);
3414
3602
  updateSessionMetadata(contextKey, session);
3603
+ const info = session.getInfo();
3415
3604
  const html = [
3416
- `<b>Launch profile set to</b> <code>${escapeHTML(selectedProfile.label)}</code>`,
3417
- `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(selectedProfile))}</code>`,
3605
+ `<b>Launch profile set to</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`,
3606
+ `<b>Behavior:</b> <code>${escapeHTML(info.launchProfileBehavior)}</code>`,
3418
3607
  "",
3419
3608
  "Applies to new or reattached threads.",
3420
3609
  ].join("\n");
3421
3610
  const plain = [
3422
- `Launch profile set to ${selectedProfile.label}`,
3423
- `Behavior: ${formatLaunchProfileBehavior(selectedProfile)}`,
3611
+ `Launch profile set to ${info.launchProfileLabel}`,
3612
+ `Behavior: ${info.launchProfileBehavior}`,
3424
3613
  "",
3425
3614
  "Applies to new or reattached threads.",
3426
3615
  ].join("\n");
@@ -3461,7 +3650,7 @@ export function createBot(config, registry) {
3461
3650
  await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3462
3651
  return;
3463
3652
  }
3464
- const profile = findLaunchProfile(config.launchProfiles, profileId);
3653
+ const profile = session.listLaunchProfiles().find((candidate) => candidate.id === profileId);
3465
3654
  if (!profile) {
3466
3655
  clearLaunchSelectionState(contextKey);
3467
3656
  await ctx.answerCallbackQuery({ text: "Launch profile no longer exists" });
@@ -3471,18 +3660,19 @@ export function createBot(config, registry) {
3471
3660
  return;
3472
3661
  }
3473
3662
  clearLaunchSelectionState(contextKey);
3474
- const selectedProfile = session.setLaunchProfile(profile.id);
3663
+ session.setLaunchProfile(profile.id);
3475
3664
  updateSessionMetadata(contextKey, session);
3476
- await ctx.answerCallbackQuery({ text: `Launch set to ${selectedProfile.label}` });
3665
+ const info = session.getInfo();
3666
+ await ctx.answerCallbackQuery({ text: `Launch set to ${info.launchProfileLabel}` });
3477
3667
  const html = [
3478
- `<b>Launch profile set to</b> <code>${escapeHTML(selectedProfile.label)}</code>`,
3479
- `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(selectedProfile))}</code>`,
3668
+ `<b>Launch profile set to</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`,
3669
+ `<b>Behavior:</b> <code>${escapeHTML(info.launchProfileBehavior)}</code>`,
3480
3670
  "",
3481
3671
  "⚠️ <i>danger-full-access confirmed for new or reattached threads.</i>",
3482
3672
  ].join("\n");
3483
3673
  const plain = [
3484
- `Launch profile set to ${selectedProfile.label}`,
3485
- `Behavior: ${formatLaunchProfileBehavior(selectedProfile)}`,
3674
+ `Launch profile set to ${info.launchProfileLabel}`,
3675
+ `Behavior: ${info.launchProfileBehavior}`,
3486
3676
  "",
3487
3677
  "danger-full-access confirmed for new or reattached threads.",
3488
3678
  ].join("\n");
@@ -3519,9 +3709,7 @@ export function createBot(config, registry) {
3519
3709
  try {
3520
3710
  const result = await session.setModelForCurrentSession(slug);
3521
3711
  updateSessionMetadata(contextKey, session);
3522
- const scope = result.appliedToActiveThread
3523
- ? "applied to the current idle thread and future threads"
3524
- : "applies to new threads";
3712
+ const scope = formatAgentSettingScope(session.getInfo(), result.appliedToActiveThread);
3525
3713
  const html = `<b>Model set to</b> <code>${escapeHTML(result.value)}</code> — ${escapeHTML(scope)}.`;
3526
3714
  const plainText = `Model set to ${result.value} — ${scope}.`;
3527
3715
  if (messageId) {
@@ -3542,7 +3730,7 @@ export function createBot(config, registry) {
3542
3730
  }
3543
3731
  }
3544
3732
  });
3545
- bot.callbackQuery(/^effort_(off|minimal|low|medium|high|xhigh)$/, async (ctx) => {
3733
+ bot.callbackQuery(/^effort_(off|none|minimal|low|medium|high|xhigh)$/, async (ctx) => {
3546
3734
  const chatId = ctx.chat?.id;
3547
3735
  const messageId = ctx.callbackQuery.message?.message_id;
3548
3736
  const effort = ctx.match?.[1];
@@ -3568,9 +3756,7 @@ export function createBot(config, registry) {
3568
3756
  const result = await session.setReasoningEffortForCurrentSession(effort);
3569
3757
  updateSessionMetadata(contextKey, session);
3570
3758
  const label = agentReasoningLabel(idOf(session.getInfo()));
3571
- const scope = result.appliedToActiveThread
3572
- ? "applied to the current idle thread and future threads"
3573
- : "applies to new threads";
3759
+ const scope = formatAgentSettingScope(session.getInfo(), result.appliedToActiveThread);
3574
3760
  const html = `⚡ ${escapeHTML(label)} set to <code>${escapeHTML(effort)}</code> — ${escapeHTML(scope)}.`;
3575
3761
  await safeEditMessage(bot, chatId, messageId, html, {
3576
3762
  fallbackText: `⚡ ${label} set to ${effort} — ${scope}.`,
@@ -3901,7 +4087,7 @@ export async function registerCommands(bot) {
3901
4087
  { command: "help", description: "Command reference" },
3902
4088
  { command: "channels", description: "Messaging adapter status" },
3903
4089
  { command: "agents", description: "Agent adapter status" },
3904
- { command: "agent", description: "Select Codex or Pi" },
4090
+ { command: "agent", description: "Select agent" },
3905
4091
  { command: "new", description: "Start a new thread" },
3906
4092
  { command: "session", description: "Current thread details" },
3907
4093
  { command: "sessions", description: "Browse & switch threads" },
@@ -3940,23 +4126,12 @@ export async function registerCommands(bot) {
3940
4126
  { command: "unlock", description: "Release session write lock" },
3941
4127
  { command: "locks", description: "List session write locks" },
3942
4128
  { command: "restart", description: "Admin: restart connector" },
3943
- { command: "update", description: "Admin: update connector" },
4129
+ { command: "update", description: "Admin: update connector or agents" },
3944
4130
  { command: "handback", description: "Hand session back to CLI" },
3945
4131
  { command: "attach", description: "Bind a session to this topic" },
3946
4132
  { command: "switch", description: "Switch to a thread by ID" },
3947
4133
  ]);
3948
4134
  }
3949
- function renderArtifactReports(reports) {
3950
- const lines = reports.slice(0, 5).map((report, index) => {
3951
- const size = formatFileSize(totalArtifactSize(report.artifacts));
3952
- const skipped = report.skippedCount > 0 ? `, ${report.skippedCount} skipped` : "";
3953
- return `${index + 1}. ${report.turnId} · ${formatRelativeTime(report.updatedAt)} · ${report.artifacts.length} file${report.artifacts.length === 1 ? "" : "s"} · ${size}${skipped}`;
3954
- });
3955
- const usage = "Tap an action below, or use /artifacts latest, /artifacts zip latest, /artifacts images, /artifacts docs, /artifacts search <text>, or /artifacts delete <turn-id>.";
3956
- const plain = ["Recent artifacts:", ...lines, "", usage].join("\n");
3957
- const html = ["<b>Recent artifacts:</b>", ...lines.map(escapeHTML), "", escapeHTML(usage)].join("\n");
3958
- return { html, plain };
3959
- }
3960
4135
  function renderVersionCheckPlain(check) {
3961
4136
  const icon = versionStatusIcon(check);
3962
4137
  const label = check.label === "NordRelay" ? "NordRelay" : `${check.label} version`;
@@ -4002,73 +4177,6 @@ function formatVersionCheckDetailHTML(check) {
4002
4177
  function versionStatusIcon(check) {
4003
4178
  return check.status === "current" ? "✅" : "⚠️";
4004
4179
  }
4005
- function parseLogsCommand(argument) {
4006
- const tokens = argument.split(/\s+/).filter(Boolean);
4007
- let target = "connector";
4008
- let lines = 80;
4009
- for (const token of tokens) {
4010
- const normalized = token.toLowerCase();
4011
- if (normalized === "connector" || normalized === "main") {
4012
- target = "connector";
4013
- continue;
4014
- }
4015
- if (normalized === "update" || normalized === "updates") {
4016
- target = "update";
4017
- continue;
4018
- }
4019
- if (normalized === "all") {
4020
- target = "all";
4021
- continue;
4022
- }
4023
- const parsedLines = Number.parseInt(token, 10);
4024
- if (!Number.isNaN(parsedLines)) {
4025
- lines = parsedLines;
4026
- }
4027
- }
4028
- return { target, lines };
4029
- }
4030
- function renderLogTailPlain(title, tail) {
4031
- return [
4032
- `${title} log tail`,
4033
- `File: ${tail.filePath}`,
4034
- `Updated: ${tail.updatedAt ? formatLogDate(tail.updatedAt) : "-"}`,
4035
- `Lines: ${tail.lineCount}/${tail.requestedLines}`,
4036
- "",
4037
- tail.plain || "(empty)",
4038
- ].join("\n");
4039
- }
4040
- function renderLogTailHTML(title, tail) {
4041
- const body = tail.plain
4042
- ? tail.plain.split("\n").map(renderLogLineHTML).join("\n")
4043
- : "<code>(empty)</code>";
4044
- return [
4045
- `<b>${escapeHTML(title)} log tail</b>`,
4046
- `<b>File:</b> <code>${escapeHTML(tail.filePath)}</code>`,
4047
- `<b>Updated:</b> <code>${escapeHTML(tail.updatedAt ? formatLogDate(tail.updatedAt) : "-")}</code>`,
4048
- `<b>Lines:</b> <code>${tail.lineCount}/${tail.requestedLines}</code>`,
4049
- "",
4050
- body,
4051
- ].join("\n");
4052
- }
4053
- function formatLogDate(date) {
4054
- return [
4055
- `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
4056
- `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
4057
- ].join(" ");
4058
- }
4059
- function renderLogLineHTML(line) {
4060
- const structured = line.match(/^(?<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|unknown time\s*)\s+(?<level>INFO|WARN|ERROR)\s+(?<message>.*)$/);
4061
- if (structured?.groups) {
4062
- const level = structured.groups.level;
4063
- const levelHtml = level === "INFO" ? escapeHTML(level) : `<b>${escapeHTML(level)}</b>`;
4064
- return [
4065
- `<code>${escapeHTML(structured.groups.timestamp.trim())}</code>`,
4066
- levelHtml,
4067
- escapeHTML(structured.groups.message),
4068
- ].join(" ");
4069
- }
4070
- return escapeHTML(line);
4071
- }
4072
4180
  function renderAuditEvents(events) {
4073
4181
  if (events.length === 0) {
4074
4182
  return {
@@ -4108,29 +4216,6 @@ function renderSessionLocks(locks) {
4108
4216
  html: ["<b>Session locks:</b>", ...lines.map((line) => escapeHTML(line))].join("\n"),
4109
4217
  };
4110
4218
  }
4111
- function renderQueuedPromptDetail(item) {
4112
- const lines = [
4113
- "Queued prompt:",
4114
- `ID: ${item.id}`,
4115
- `Created: ${formatLocalDateTime(new Date(item.createdAt))}`,
4116
- item.notBefore ? `Scheduled: ${formatLocalDateTime(new Date(item.notBefore))}` : undefined,
4117
- `Attempts: ${item.attempts ?? 0}`,
4118
- item.lastError ? `Last error: ${item.lastError}` : undefined,
4119
- `Description: ${item.description}`,
4120
- ].filter((line) => Boolean(line));
4121
- return {
4122
- plain: lines.join("\n"),
4123
- html: [
4124
- "<b>Queued prompt:</b>",
4125
- `<b>ID:</b> <code>${escapeHTML(item.id)}</code>`,
4126
- `<b>Created:</b> <code>${escapeHTML(formatLocalDateTime(new Date(item.createdAt)))}</code>`,
4127
- item.notBefore ? `<b>Scheduled:</b> <code>${escapeHTML(formatLocalDateTime(new Date(item.notBefore)))}</code>` : undefined,
4128
- `<b>Attempts:</b> <code>${item.attempts ?? 0}</code>`,
4129
- item.lastError ? `<b>Last error:</b> ${escapeHTML(item.lastError)}` : undefined,
4130
- `<b>Description:</b> ${escapeHTML(item.description)}`,
4131
- ].filter((line) => Boolean(line)).join("\n"),
4132
- };
4133
- }
4134
4219
  function formatLockOwner(lock) {
4135
4220
  if (!lock) {
4136
4221
  return "nobody";
@@ -4259,7 +4344,7 @@ function renderExternalMirrorStatus(snapshot, queueLength) {
4259
4344
  ? formatDurationSeconds((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
4260
4345
  : "-";
4261
4346
  const lines = [
4262
- "Codex CLI task running.",
4347
+ `${snapshot.agentLabel} CLI task running.`,
4263
4348
  `Thread: ${snapshot.threadId}`,
4264
4349
  `Elapsed: ${elapsed}`,
4265
4350
  `Prompt: ${prompt}`,
@@ -4269,7 +4354,7 @@ function renderExternalMirrorStatus(snapshot, queueLength) {
4269
4354
  return {
4270
4355
  plain: lines.join("\n"),
4271
4356
  html: [
4272
- "<b>Codex CLI task running.</b>",
4357
+ `<b>${escapeHTML(snapshot.agentLabel)} CLI task running.</b>`,
4273
4358
  `<b>Thread:</b> <code>${escapeHTML(snapshot.threadId)}</code>`,
4274
4359
  `<b>Elapsed:</b> <code>${escapeHTML(elapsed)}</code>`,
4275
4360
  `<b>Prompt:</b> <code>${escapeHTML(prompt)}</code>`,
@@ -4302,8 +4387,8 @@ function renderExternalMirrorEvent(event) {
4302
4387
  function renderActivityTimeline(threadId, events, options = { limit: 16, filter: "all", exportFile: false }) {
4303
4388
  if (events.length === 0) {
4304
4389
  return {
4305
- plain: `Activity:\nThread: ${threadId}\nFilter: ${options.filter}\nNo rollout events found.`,
4306
- html: `<b>Activity:</b>\n<b>Thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Filter:</b> <code>${escapeHTML(options.filter)}</code>\n<code>No rollout events found.</code>`,
4390
+ plain: `Activity:\nThread: ${threadId}\nFilter: ${options.filter}\nNo activity events found.`,
4391
+ html: `<b>Activity:</b>\n<b>Thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Filter:</b> <code>${escapeHTML(options.filter)}</code>\n<code>No activity events found.</code>`,
4307
4392
  };
4308
4393
  }
4309
4394
  const lines = events.map((event) => {
@@ -4379,50 +4464,36 @@ function filterActivityEvents(events, options) {
4379
4464
  function isActivityFilter(value) {
4380
4465
  return value === "all" || value === "tools" || value === "errors" || value === "user" || value === "agent" || value === "tasks";
4381
4466
  }
4382
- function renderRolloutDiagnostics(threadId, staleAfterMs) {
4383
- if (!threadId) {
4384
- return {
4385
- plain: "Rollout: no active thread",
4386
- html: "<b>Rollout:</b> <code>no active thread</code>",
4387
- };
4388
- }
4389
- const snapshot = getThreadRolloutSnapshot(threadId, { staleAfterMs, maxEvents: 0 });
4390
- if (!snapshot) {
4391
- return {
4392
- plain: `Rollout:\nThread: ${threadId}\nStatus: unavailable`,
4393
- html: [
4394
- "<b>Rollout:</b>",
4395
- `<b>Thread:</b> <code>${escapeHTML(threadId)}</code>`,
4396
- "<b>Status:</b> <code>unavailable</code>",
4397
- ].join("\n"),
4398
- };
4399
- }
4400
- const activity = snapshot.activity;
4401
- const status = activity.active ? "active" : activity.stale ? "stale" : "idle";
4402
- const reason = activity.active
4403
- ? "open task without terminal event"
4404
- : activity.stale
4405
- ? "open task exceeded stale timeout"
4406
- : "no open task";
4407
- const lines = [
4408
- "Rollout:",
4409
- `Path: ${snapshot.rolloutPath}`,
4410
- `Status: ${status}`,
4411
- `Reason: ${reason}`,
4412
- `Turn: ${activity.turnId ?? "-"}`,
4413
- `Lines: ${snapshot.lineCount}`,
4414
- `Updated: ${activity.updatedAt?.toISOString() ?? "-"}`,
4415
- ];
4467
+ function formatAgentLaunchProfileLabel(profile, selected) {
4468
+ const prefix = selected ? "✅" : profile.unsafe ? "⚠️" : "🚀";
4469
+ return `${prefix} ${profile.label} · ${trimLine(profile.behavior, 24)}`;
4470
+ }
4471
+ function formatModelButtonLabel(model, selected) {
4472
+ const meta = [
4473
+ model.contextWindow ? formatCompactNumber(model.contextWindow) : undefined,
4474
+ model.supportsImages === true ? "img" : model.supportsImages === false ? "text" : undefined,
4475
+ model.supportsThinking === true ? "think" : undefined,
4476
+ ].filter(Boolean).join(" ");
4477
+ return trimLine(`${selected ? "✅ " : ""}${model.displayName}${meta ? ` · ${meta}` : ""}`, 58);
4478
+ }
4479
+ function formatCompactNumber(value) {
4480
+ if (value >= 1_000_000_000)
4481
+ return `${Math.round(value / 100_000_000) / 10}B`;
4482
+ if (value >= 1_000_000)
4483
+ return `${Math.round(value / 100_000) / 10}M`;
4484
+ if (value >= 1_000)
4485
+ return `${Math.round(value / 100) / 10}K`;
4486
+ return String(value);
4487
+ }
4488
+ function renderAgentDiagnostics(diagnostics) {
4416
4489
  return {
4417
- plain: lines.join("\n"),
4490
+ plain: [
4491
+ `${diagnostics.agentLabel} state:`,
4492
+ ...diagnostics.lines.map((line) => `${line.label}: ${line.value}`),
4493
+ ].join("\n"),
4418
4494
  html: [
4419
- "<b>Rollout:</b>",
4420
- `<b>Path:</b> <code>${escapeHTML(snapshot.rolloutPath)}</code>`,
4421
- `<b>Status:</b> <code>${escapeHTML(status)}</code>`,
4422
- `<b>Reason:</b> <code>${escapeHTML(reason)}</code>`,
4423
- `<b>Turn:</b> <code>${escapeHTML(activity.turnId ?? "-")}</code>`,
4424
- `<b>Lines:</b> <code>${snapshot.lineCount}</code>`,
4425
- `<b>Updated:</b> <code>${escapeHTML(activity.updatedAt?.toISOString() ?? "-")}</code>`,
4495
+ `<b>${escapeHTML(diagnostics.agentLabel)} state:</b>`,
4496
+ ...diagnostics.lines.map((line) => `<b>${escapeHTML(line.label)}:</b> <code>${escapeHTML(line.value)}</code>`),
4426
4497
  ].join("\n"),
4427
4498
  };
4428
4499
  }
@@ -4462,6 +4533,11 @@ function renderDiagnosticsPlain(config, registry, health, authenticated, role, q
4462
4533
  `Telegram transport: ${config.telegramTransport}`,
4463
4534
  `Codex CLI: ${health.codexCli}`,
4464
4535
  `Pi CLI: ${health.piCli}`,
4536
+ `Hermes CLI: ${health.hermesCli}`,
4537
+ `OpenClaw CLI: ${health.openClawCli}`,
4538
+ `Claude Code CLI: ${health.claudeCodeCli}`,
4539
+ `Hermes API: ${config.hermesApiBaseUrl}`,
4540
+ `OpenClaw Gateway: ${config.openClawGatewayUrl}`,
4465
4541
  `Enabled agents/default: ${enabledAgents(config).join(", ")} / ${config.defaultAgent}`,
4466
4542
  `State DB: ${health.databasePath ?? "-"}`,
4467
4543
  `Log file: ${health.logFile}`,
@@ -4502,6 +4578,11 @@ function renderDiagnosticsHTML(config, registry, health, authenticated, role, qu
4502
4578
  `<b>Telegram transport:</b> <code>${escapeHTML(config.telegramTransport)}</code>`,
4503
4579
  `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4504
4580
  `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
4581
+ `<b>Hermes CLI:</b> <code>${escapeHTML(health.hermesCli)}</code>`,
4582
+ `<b>OpenClaw CLI:</b> <code>${escapeHTML(health.openClawCli)}</code>`,
4583
+ `<b>Claude Code CLI:</b> <code>${escapeHTML(health.claudeCodeCli)}</code>`,
4584
+ `<b>Hermes API:</b> <code>${escapeHTML(config.hermesApiBaseUrl)}</code>`,
4585
+ `<b>OpenClaw Gateway:</b> <code>${escapeHTML(config.openClawGatewayUrl)}</code>`,
4505
4586
  `<b>Enabled agents/default:</b> <code>${escapeHTML(`${enabledAgents(config).join(", ")} / ${config.defaultAgent}`)}</code>`,
4506
4587
  `<b>State DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
4507
4588
  `<b>Log file:</b> <code>${escapeHTML(health.logFile)}</code>`,
@@ -4539,6 +4620,9 @@ function renderHealthPlain(health, authenticated, role) {
4539
4620
  `Workspace: ${health.state.workspace ?? "-"}`,
4540
4621
  `Codex CLI: ${health.codexCli}`,
4541
4622
  `Pi CLI: ${health.piCli}`,
4623
+ `Hermes CLI: ${health.hermesCli}`,
4624
+ `OpenClaw CLI: ${health.openClawCli}`,
4625
+ `Claude Code CLI: ${health.claudeCodeCli}`,
4542
4626
  `Codex state DB: ${health.databasePath ?? "-"}`,
4543
4627
  `Log: ${health.logFile}`,
4544
4628
  ].join("\n");
@@ -4555,6 +4639,9 @@ function renderHealthHTML(health, authenticated, role) {
4555
4639
  `<b>Workspace:</b> <code>${escapeHTML(health.state.workspace ?? "-")}</code>`,
4556
4640
  `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4557
4641
  `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
4642
+ `<b>Hermes CLI:</b> <code>${escapeHTML(health.hermesCli)}</code>`,
4643
+ `<b>OpenClaw CLI:</b> <code>${escapeHTML(health.openClawCli)}</code>`,
4644
+ `<b>Claude Code CLI:</b> <code>${escapeHTML(health.claudeCodeCli)}</code>`,
4558
4645
  `<b>Codex state DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
4559
4646
  `<b>Log:</b> <code>${escapeHTML(health.logFile)}</code>`,
4560
4647
  ].join("\n");
@@ -4622,6 +4709,48 @@ function labelOf(info) {
4622
4709
  function idOf(info) {
4623
4710
  return info.agentId ?? "codex";
4624
4711
  }
4712
+ function authHelpText(info) {
4713
+ const agentId = idOf(info);
4714
+ if (agentId === "pi") {
4715
+ return "Configure the required Pi provider environment variable on the host.";
4716
+ }
4717
+ if (agentId === "hermes") {
4718
+ return "Start the Hermes API Server, configure HERMES_API_KEY when required, or use /login to start Hermes CLI auth.";
4719
+ }
4720
+ if (agentId === "openclaw") {
4721
+ return "Start the OpenClaw Gateway and configure OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD when the gateway requires one.";
4722
+ }
4723
+ if (agentId === "claude-code") {
4724
+ return "Use /login to start Claude Code CLI auth, or run 'claude auth login' on the host.";
4725
+ }
4726
+ return "Use /login to start authentication, or set CODEX_API_KEY on the host.";
4727
+ }
4728
+ function formatAgentSettingScope(info, appliedToActiveThread) {
4729
+ const agentId = idOf(info);
4730
+ if (agentId === "hermes") {
4731
+ return appliedToActiveThread
4732
+ ? "applies to the next Hermes run in this session"
4733
+ : "applies to new Hermes sessions";
4734
+ }
4735
+ if (agentId === "pi") {
4736
+ return appliedToActiveThread
4737
+ ? "applied to the current idle Pi session and future turns"
4738
+ : "applies to new Pi sessions";
4739
+ }
4740
+ if (agentId === "openclaw") {
4741
+ return appliedToActiveThread
4742
+ ? "applies to the next OpenClaw run in this session"
4743
+ : "applies to new OpenClaw sessions";
4744
+ }
4745
+ if (agentId === "claude-code") {
4746
+ return appliedToActiveThread
4747
+ ? "applies to the next Claude Code run in this session"
4748
+ : "applies to new Claude Code sessions";
4749
+ }
4750
+ return appliedToActiveThread
4751
+ ? "applied to the current idle thread and future threads"
4752
+ : "applies to new threads";
4753
+ }
4625
4754
  function requiresTurnApproval(info) {
4626
4755
  return info.unsafeLaunch || info.approvalPolicy !== "never";
4627
4756
  }