@nordbyte/nordrelay 0.3.1 → 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 (48) hide show
  1. package/.env.example +45 -2
  2. package/README.md +204 -30
  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 +328 -159
  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 +12 -1
  17. package/dist/config.js +113 -9
  18. package/dist/hermes-api.js +150 -0
  19. package/dist/hermes-auth.js +96 -0
  20. package/dist/hermes-cli.js +19 -0
  21. package/dist/hermes-launch.js +57 -0
  22. package/dist/hermes-session.js +477 -0
  23. package/dist/hermes-state.js +609 -0
  24. package/dist/index.js +51 -8
  25. package/dist/openclaw-auth.js +27 -0
  26. package/dist/openclaw-cli.js +19 -0
  27. package/dist/openclaw-gateway.js +285 -0
  28. package/dist/openclaw-launch.js +65 -0
  29. package/dist/openclaw-session.js +549 -0
  30. package/dist/openclaw-state.js +409 -0
  31. package/dist/operations.js +83 -2
  32. package/dist/pi-auth.js +59 -0
  33. package/dist/pi-launch.js +61 -0
  34. package/dist/pi-rpc.js +18 -0
  35. package/dist/pi-session.js +103 -15
  36. package/dist/pi-state.js +253 -0
  37. package/dist/relay-runtime.js +673 -51
  38. package/dist/session-format.js +28 -18
  39. package/dist/session-registry.js +40 -15
  40. package/dist/settings-service.js +35 -4
  41. package/dist/web-dashboard-ui.js +18 -0
  42. package/dist/web-dashboard.js +329 -47
  43. package/package.json +8 -3
  44. package/plugins/nordrelay/.codex-plugin/plugin.json +7 -4
  45. package/plugins/nordrelay/commands/remote.md +2 -2
  46. package/plugins/nordrelay/scripts/nordrelay.mjs +131 -3
  47. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +2 -2
  48. package/CHANGELOG.md +0 -26
package/dist/bot.js CHANGED
@@ -12,16 +12,20 @@ import { AuditLogStore } from "./audit-log.js";
12
12
  import { formatSessionLabel, renderHelpMessage, renderWelcomeFirstTime, renderWelcomeReturning, } from "./bot-ui.js";
13
13
  import { BotPreferencesStore, formatQuietHours, isQuietNow, parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
14
14
  import { listChannelDescriptors } from "./channel-adapter.js";
15
- import { CODEX_REASONING_EFFORTS, CODEX_AGENT_CAPABILITIES, PI_THINKING_LEVELS, agentLabel, agentReasoningLabel, } from "./agent.js";
15
+ import { CODEX_AGENT_CAPABILITIES, agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
16
+ import { getAgentActivityLog, getAgentDiagnostics, getExternalActivityForSession, getExternalSnapshotForSession, } from "./agent-activity.js";
16
17
  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";
18
+ import { checkAuthStatus, clearAuthCache, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
19
+ import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "./claude-code-auth.js";
20
+ import { formatLaunchProfileBehavior } from "./codex-launch.js";
20
21
  import { contextKeyFromCtx, isTelegramContextKey, isTopicContextKey, parseContextKey } from "./context-key.js";
21
22
  import { friendlyErrorText } from "./error-messages.js";
22
23
  import { escapeHTML, formatTelegramHTML } from "./format.js";
23
24
  import { getConnectorHealth, getUpdateLogPath, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate, } from "./operations.js";
24
25
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
26
+ import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
27
+ import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
28
+ import { checkPiAuthStatus } from "./pi-auth.js";
25
29
  import { configureRedaction, redactText } from "./redaction.js";
26
30
  import { canWriteWithLock, SessionLockStore } from "./session-locks.js";
27
31
  import { formatFileSize, renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
@@ -97,23 +101,23 @@ export function createBot(config, registry) {
97
101
  const syncInterval = config.codexSyncIntervalMs > 0
98
102
  ? setInterval(() => {
99
103
  try {
100
- registry.syncAllFromCodexState({ reattach: true });
104
+ registry.syncAllFromAgentState({ reattach: true });
101
105
  }
102
106
  catch (error) {
103
- console.error("Failed to sync sessions from Codex state:", error);
107
+ console.error("Failed to sync sessions from agent state:", error);
104
108
  }
105
109
  }, config.codexSyncIntervalMs)
106
110
  : undefined;
107
111
  syncInterval?.unref?.();
108
112
  const externalMonitorInterval = setInterval(() => {
109
113
  void monitorExternalContexts().catch((error) => {
110
- console.error("Failed to monitor external Codex activity:", error);
114
+ console.error("Failed to monitor external agent activity:", error);
111
115
  });
112
116
  }, config.codexExternalBusyCheckMs);
113
117
  externalMonitorInterval.unref?.();
114
118
  setTimeout(() => {
115
119
  void monitorExternalContexts().catch((error) => {
116
- console.error("Failed to run initial external Codex monitor:", error);
120
+ console.error("Failed to run initial external agent monitor:", error);
117
121
  });
118
122
  }, 0).unref?.();
119
123
  registry.onRemove((key) => {
@@ -152,19 +156,7 @@ export function createBot(config, registry) {
152
156
  }
153
157
  return state;
154
158
  };
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
- };
159
+ const getExternalActivity = (session) => getExternalActivityForSession(session, config);
168
160
  const getBusyReason = (contextKey) => {
169
161
  const state = contextBusy.get(contextKey);
170
162
  const session = registry.get(contextKey);
@@ -191,6 +183,83 @@ export function createBot(config, registry) {
191
183
  const updateSessionMetadata = (contextKey, session) => {
192
184
  registry.updateMetadata(contextKey, session);
193
185
  };
186
+ const checkAgentAuthStatus = async (info) => {
187
+ if (idOf(info) === "pi") {
188
+ return checkPiAuthStatus(info.model);
189
+ }
190
+ if (idOf(info) === "hermes") {
191
+ return checkHermesAuthStatus({
192
+ baseUrl: config.hermesApiBaseUrl,
193
+ apiKey: config.hermesApiKey,
194
+ });
195
+ }
196
+ if (idOf(info) === "openclaw") {
197
+ return checkOpenClawAuthStatus({
198
+ gatewayUrl: config.openClawGatewayUrl,
199
+ token: config.openClawGatewayToken,
200
+ password: config.openClawGatewayPassword,
201
+ });
202
+ }
203
+ if (idOf(info) === "claude-code") {
204
+ return checkClaudeCodeAuthStatus(config.claudeCodeCliPath);
205
+ }
206
+ return checkAuthStatus(config.codexApiKey);
207
+ };
208
+ const agentIdForAuth = (info) => info ? idOf(info) : "codex";
209
+ const labelForAuth = (info) => info ? labelOf(info) : "Codex";
210
+ const checkLoginAuthStatus = async (info) => {
211
+ const agentId = agentIdForAuth(info);
212
+ if (agentId === "hermes") {
213
+ return checkHermesAuthStatus({
214
+ baseUrl: config.hermesApiBaseUrl,
215
+ apiKey: config.hermesApiKey,
216
+ });
217
+ }
218
+ if (agentId === "claude-code") {
219
+ return checkClaudeCodeAuthStatus(config.claudeCodeCliPath);
220
+ }
221
+ return checkAuthStatus(config.codexApiKey);
222
+ };
223
+ const startAgentLogin = (info) => {
224
+ const agentId = agentIdForAuth(info);
225
+ if (agentId === "hermes") {
226
+ return startHermesLogin(config.hermesCliPath);
227
+ }
228
+ if (agentId === "claude-code") {
229
+ return startClaudeCodeLogin(config.claudeCodeCliPath);
230
+ }
231
+ return startCodexLogin();
232
+ };
233
+ const startAgentLogout = (info) => {
234
+ const agentId = agentIdForAuth(info);
235
+ if (agentId === "hermes") {
236
+ return startHermesLogout(config.hermesCliPath);
237
+ }
238
+ if (agentId === "claude-code") {
239
+ return startClaudeCodeLogout(config.claudeCodeCliPath);
240
+ }
241
+ return startCodexLogout();
242
+ };
243
+ const hostLoginCommand = (info) => {
244
+ const agentId = agentIdForAuth(info);
245
+ if (agentId === "hermes") {
246
+ return `${config.hermesCliPath ?? "hermes"} login --no-browser`;
247
+ }
248
+ if (agentId === "claude-code") {
249
+ return `${config.claudeCodeCliPath ?? "claude"} auth login`;
250
+ }
251
+ return "codex login --device-auth";
252
+ };
253
+ const hostLogoutCommand = (info) => {
254
+ const agentId = agentIdForAuth(info);
255
+ if (agentId === "hermes") {
256
+ return `${config.hermesCliPath ?? "hermes"} logout`;
257
+ }
258
+ if (agentId === "claude-code") {
259
+ return `${config.claudeCodeCliPath ?? "claude"} auth logout`;
260
+ }
261
+ return "codex logout";
262
+ };
194
263
  const isTopicContext = (contextKey) => isTopicContextKey(contextKey);
195
264
  const getPreferences = (contextKey) => preferencesStore.get(contextKey);
196
265
  const getEffectiveMirrorMode = (contextKey) => getPreferences(contextKey).mirrorMode ?? config.telegramMirrorMode;
@@ -364,11 +433,9 @@ export function createBot(config, registry) {
364
433
  return;
365
434
  }
366
435
  const previous = externalMirrors.get(contextKey);
367
- const snapshot = getThreadRolloutSnapshot(threadId, {
436
+ const snapshot = getExternalSnapshotForSession(session, config, {
368
437
  afterLine: previous?.lastLine ?? Number.MAX_SAFE_INTEGER,
369
- staleAfterMs: config.codexExternalBusyStaleMs,
370
- }) ?? getThreadRolloutSnapshot(threadId, {
371
- staleAfterMs: config.codexExternalBusyStaleMs,
438
+ }) ?? getExternalSnapshotForSession(session, config, {
372
439
  maxEvents: 0,
373
440
  });
374
441
  if (!snapshot) {
@@ -382,7 +449,7 @@ export function createBot(config, registry) {
382
449
  }
383
450
  const activity = snapshot.activity;
384
451
  if (activity.active && queueLength > 0) {
385
- await updateQueueStatusMessage(contextKey, `Waiting for Codex CLI task... ${queueLength} queued${paused ? " (paused)" : ""}.`);
452
+ await updateQueueStatusMessage(contextKey, `Waiting for ${info.agentLabel} CLI task... ${queueLength} queued${paused ? " (paused)" : ""}.`);
386
453
  return;
387
454
  }
388
455
  if (!activity.active && queueLength > 0 && !paused && !session.isProcessing()) {
@@ -394,10 +461,10 @@ export function createBot(config, registry) {
394
461
  const parsed = parseContextKey(contextKey);
395
462
  const previous = externalMirrors.get(contextKey);
396
463
  let state = previous;
397
- if (!state || state.threadId !== snapshot.threadId || state.rolloutPath !== snapshot.rolloutPath) {
464
+ if (!state || state.threadId !== snapshot.threadId || state.rolloutPath !== snapshot.sourcePath) {
398
465
  state = {
399
466
  threadId: snapshot.threadId,
400
- rolloutPath: snapshot.rolloutPath,
467
+ rolloutPath: snapshot.sourcePath,
401
468
  lastLine: snapshot.lineCount,
402
469
  turnId: snapshot.activity.turnId,
403
470
  startedAt: snapshot.activity.startedAt,
@@ -458,7 +525,7 @@ export function createBot(config, registry) {
458
525
  const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
459
526
  if (terminalEvent) {
460
527
  if (mirrorMode !== "off") {
461
- const doneText = `Codex CLI task ${terminalEvent.status}.`;
528
+ const doneText = `${snapshot.agentLabel} CLI task ${terminalEvent.status}.`;
462
529
  if (state.statusMessageId) {
463
530
  await safeEditMessage(bot, chatId, state.statusMessageId, escapeHTML(doneText), {
464
531
  fallbackText: doneText,
@@ -473,8 +540,8 @@ export function createBot(config, registry) {
473
540
  }
474
541
  const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
475
542
  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:",
543
+ await sendTextMessage(bot.api, chatId, `<b>${escapeHTML(snapshot.agentLabel)} CLI final answer:</b>`, {
544
+ fallbackText: `${snapshot.agentLabel} CLI final answer:`,
478
545
  messageThreadId: parsed.messageThreadId,
479
546
  });
480
547
  for (const chunk of splitMarkdownForTelegram(finalAgent.text)) {
@@ -544,7 +611,8 @@ export function createBot(config, registry) {
544
611
  }
545
612
  const busy = getBusyReason(contextKey);
546
613
  if (busy.kind === "external") {
547
- await updateQueueStatusMessage(contextKey, `Waiting for Codex CLI task... ${promptStore.list(contextKey).length} queued${promptStore.isPaused(contextKey) ? " (paused)" : ""}.`);
614
+ const label = busy.activity.agentLabel;
615
+ await updateQueueStatusMessage(contextKey, `Waiting for ${label} CLI task... ${promptStore.list(contextKey).length} queued${promptStore.isPaused(contextKey) ? " (paused)" : ""}.`);
548
616
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
549
617
  return;
550
618
  }
@@ -554,7 +622,7 @@ export function createBot(config, registry) {
554
622
  await updateQueueStatusMessage(contextKey, `CLI task finished, running queued prompt 1/${promptStore.list(contextKey).length}.`);
555
623
  await drainQueuedPrompts(ctx, contextKey, chatId, session);
556
624
  })().catch((error) => {
557
- console.error("Failed to drain queue after external Codex activity:", error);
625
+ console.error("Failed to drain queue after external CLI activity:", error);
558
626
  });
559
627
  }, config.codexExternalBusyCheckMs);
560
628
  timer.unref?.();
@@ -1109,21 +1177,21 @@ export function createBot(config, registry) {
1109
1177
  try {
1110
1178
  const sessionInfo = session.getInfo();
1111
1179
  if (capabilitiesOf(sessionInfo).auth) {
1112
- const authStatus = await checkAuthStatus(config.codexApiKey);
1180
+ const authStatus = await checkAgentAuthStatus(sessionInfo);
1113
1181
  if (!authStatus.authenticated) {
1114
1182
  await safeReply(ctx, [
1115
1183
  `<b>⚠️ ${escapeHTML(labelOf(sessionInfo))} is not authenticated.</b>`,
1116
1184
  "",
1117
1185
  `<code>${escapeHTML(authStatus.detail)}</code>`,
1118
1186
  "",
1119
- "Use /login to start authentication, or set CODEX_API_KEY on the host.",
1187
+ authHelpText(sessionInfo),
1120
1188
  ].join("\n"), {
1121
1189
  fallbackText: [
1122
1190
  `⚠️ ${labelOf(sessionInfo)} is not authenticated.`,
1123
1191
  "",
1124
1192
  authStatus.detail,
1125
1193
  "",
1126
- "Use /login to start authentication, or set CODEX_API_KEY on the host.",
1194
+ authHelpText(sessionInfo),
1127
1195
  ].join("\n"),
1128
1196
  });
1129
1197
  return;
@@ -1135,6 +1203,24 @@ export function createBot(config, registry) {
1135
1203
  });
1136
1204
  return;
1137
1205
  }
1206
+ if (idOf(sessionInfo) === "hermes" && !config.hermesEnabled) {
1207
+ await safeReply(ctx, "<b>⚠️ Hermes is disabled.</b>\nEnable it with <code>NORDRELAY_HERMES_ENABLED=true</code>.", {
1208
+ fallbackText: "⚠️ Hermes is disabled.\nEnable it with NORDRELAY_HERMES_ENABLED=true.",
1209
+ });
1210
+ return;
1211
+ }
1212
+ if (idOf(sessionInfo) === "openclaw" && !config.openClawEnabled) {
1213
+ await safeReply(ctx, "<b>⚠️ OpenClaw is disabled.</b>\nEnable it with <code>NORDRELAY_OPENCLAW_ENABLED=true</code>.", {
1214
+ fallbackText: "⚠️ OpenClaw is disabled.\nEnable it with NORDRELAY_OPENCLAW_ENABLED=true.",
1215
+ });
1216
+ return;
1217
+ }
1218
+ if (idOf(sessionInfo) === "claude-code" && !config.claudeCodeEnabled) {
1219
+ await safeReply(ctx, "<b>⚠️ Claude Code is disabled.</b>\nEnable it with <code>NORDRELAY_CLAUDE_CODE_ENABLED=true</code>.", {
1220
+ fallbackText: "⚠️ Claude Code is disabled.\nEnable it with NORDRELAY_CLAUDE_CODE_ENABLED=true.",
1221
+ });
1222
+ return;
1223
+ }
1138
1224
  if (!(await ensureActiveThread(ctx, contextKey, session))) {
1139
1225
  return;
1140
1226
  }
@@ -1148,12 +1234,13 @@ export function createBot(config, registry) {
1148
1234
  const finalExternalActivity = getExternalActivity(session);
1149
1235
  if (finalExternalActivity?.active) {
1150
1236
  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.`;
1237
+ const label = finalExternalActivity.agentLabel;
1238
+ const message = `Queued prompt ${item.id} at position 1. The ${label} session became active in ${label} CLI and is processing another task.`;
1152
1239
  await safeReply(ctx, escapeHTML(message), {
1153
1240
  fallbackText: message,
1154
1241
  replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
1155
1242
  });
1156
- await updateQueueStatusMessage(contextKey, `Waiting for Codex CLI task... ${promptStore.list(contextKey).length} queued.`);
1243
+ await updateQueueStatusMessage(contextKey, `Waiting for ${label} CLI task... ${promptStore.list(contextKey).length} queued.`);
1157
1244
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
1158
1245
  turnProgress.delete(contextKey);
1159
1246
  return;
@@ -1164,9 +1251,14 @@ export function createBot(config, registry) {
1164
1251
  status: "ok",
1165
1252
  description: envelope.description,
1166
1253
  });
1254
+ const artifactStartedAt = new Date();
1255
+ const artifactTurnId = envelope.artifactOutDir
1256
+ ? path.basename(path.dirname(envelope.artifactOutDir))
1257
+ : randomUUID().slice(0, 12);
1167
1258
  await session.prompt(envelope.input, callbacks);
1168
1259
  updateSessionMetadata(contextKey, session);
1169
1260
  await finalizeResponse();
1261
+ await deliverCliGeneratedArtifacts(contextKey, chatId, session, artifactStartedAt, artifactTurnId, messageThreadId);
1170
1262
  if (envelope.artifactOutDir) {
1171
1263
  if (config.telegramAutoSendArtifacts) {
1172
1264
  await deliverArtifacts(ctx, chatId, envelope.artifactOutDir, session.getInfo().workspace, messageThreadId);
@@ -1321,7 +1413,7 @@ export function createBot(config, registry) {
1321
1413
  const deliverArtifactReportZip = async (ctx, chatId, report, messageThreadId) => {
1322
1414
  const bundle = await createArtifactZipBundle(report.artifacts, report.outDir, {
1323
1415
  maxFileSize: config.maxFileSize,
1324
- bundleName: `codex-artifacts-${report.turnId}.zip`,
1416
+ bundleName: `nordrelay-artifacts-${report.turnId}.zip`,
1325
1417
  });
1326
1418
  if (!bundle) {
1327
1419
  await safeReply(ctx, escapeHTML("Could not create a ZIP bundle for this artifact turn."), {
@@ -1516,9 +1608,9 @@ export function createBot(config, registry) {
1516
1608
  }
1517
1609
  const { contextKey, session } = contextSession;
1518
1610
  const info = session.getInfo();
1519
- const authStatus = capabilitiesOf(info).auth ? await checkAuthStatus(config.codexApiKey) : null;
1611
+ const authStatus = capabilitiesOf(info).auth ? await checkAgentAuthStatus(info) : null;
1520
1612
  const authWarning = authStatus && !authStatus.authenticated
1521
- ? "Not authenticated. Use /login or set CODEX_API_KEY."
1613
+ ? [`${labelOf(info)} is not authenticated.`, authStatus.detail, authHelpText(info)].filter(Boolean).join(" ")
1522
1614
  : undefined;
1523
1615
  const isReturning = registry.hasMetadata(contextKey);
1524
1616
  if (isReturning) {
@@ -1561,7 +1653,13 @@ export function createBot(config, registry) {
1561
1653
  ? config.codexEnabled
1562
1654
  : descriptor.id === "pi"
1563
1655
  ? config.piEnabled
1564
- : false;
1656
+ : descriptor.id === "hermes"
1657
+ ? config.hermesEnabled
1658
+ : descriptor.id === "openclaw"
1659
+ ? config.openClawEnabled
1660
+ : descriptor.id === "claude-code"
1661
+ ? config.claudeCodeEnabled
1662
+ : false;
1565
1663
  return `${descriptor.label}: ${descriptor.status}${descriptor.status === "available" ? ` · ${enabled ? "enabled" : "disabled"}` : ""}`;
1566
1664
  }),
1567
1665
  ].join("\n");
@@ -1572,7 +1670,13 @@ export function createBot(config, registry) {
1572
1670
  ? config.codexEnabled
1573
1671
  : descriptor.id === "pi"
1574
1672
  ? config.piEnabled
1575
- : false;
1673
+ : descriptor.id === "hermes"
1674
+ ? config.hermesEnabled
1675
+ : descriptor.id === "openclaw"
1676
+ ? config.openClawEnabled
1677
+ : descriptor.id === "claude-code"
1678
+ ? config.claudeCodeEnabled
1679
+ : false;
1576
1680
  const status = descriptor.status === "available" ? `${enabled ? "enabled" : "disabled"}` : "planned";
1577
1681
  const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
1578
1682
  return `${descriptor.status === "available" ? "✅" : "🟡"} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(status)}</code>${notes}`;
@@ -1586,11 +1690,6 @@ export function createBot(config, registry) {
1586
1690
  return;
1587
1691
  }
1588
1692
  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
1693
  if (isBusy(contextKey)) {
1595
1694
  await safeReply(ctx, escapeHTML("Cannot switch agent while a prompt is running."), {
1596
1695
  fallbackText: "Cannot switch agent while a prompt is running.",
@@ -1627,7 +1726,7 @@ export function createBot(config, registry) {
1627
1726
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1628
1727
  return;
1629
1728
  }
1630
- const authStatus = await checkAuthStatus(config.codexApiKey);
1729
+ const authStatus = info ? await checkAgentAuthStatus(info) : await checkAuthStatus(config.codexApiKey);
1631
1730
  const icon = authStatus.authenticated ? "✅" : "❌";
1632
1731
  const html = [
1633
1732
  `<b>${icon} Auth status:</b> ${authStatus.authenticated ? "authenticated" : "not authenticated"}`,
@@ -1652,8 +1751,8 @@ export function createBot(config, registry) {
1652
1751
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1653
1752
  return;
1654
1753
  }
1655
- const authStatus = await checkAuthStatus(config.codexApiKey);
1656
- if (authStatus.authenticated) {
1754
+ const authStatus = await checkLoginAuthStatus(info);
1755
+ if (agentIdForAuth(info) !== "hermes" && authStatus.authenticated) {
1657
1756
  await safeReply(ctx, `<b>✅ Already authenticated</b> via <code>${escapeHTML(authStatus.method)}</code>.`, {
1658
1757
  fallbackText: `✅ Already authenticated via ${authStatus.method}.`,
1659
1758
  });
@@ -1663,17 +1762,17 @@ export function createBot(config, registry) {
1663
1762
  await safeReply(ctx, [
1664
1763
  "<b>Telegram-initiated login is disabled.</b>",
1665
1764
  "",
1666
- "Run <code>codex login</code> on the host, or set CODEX_API_KEY in .env.",
1765
+ `Run <code>${escapeHTML(hostLoginCommand(info))}</code> on the host.`,
1667
1766
  ].join("\n"), {
1668
1767
  fallbackText: [
1669
1768
  "Telegram-initiated login is disabled.",
1670
1769
  "",
1671
- "Run 'codex login' on the host, or set CODEX_API_KEY in .env.",
1770
+ `Run '${hostLoginCommand(info)}' on the host.`,
1672
1771
  ].join("\n"),
1673
1772
  });
1674
1773
  return;
1675
1774
  }
1676
- const result = await startLogin();
1775
+ const result = await startAgentLogin(info);
1677
1776
  if (result.success) {
1678
1777
  await safeReply(ctx, `<b>🔑 Login initiated.</b>\n\n<code>${escapeHTML(result.message)}</code>`, {
1679
1778
  fallbackText: `🔑 Login initiated.\n\n${result.message}`,
@@ -1695,17 +1794,17 @@ export function createBot(config, registry) {
1695
1794
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1696
1795
  return;
1697
1796
  }
1698
- const authStatus = await checkAuthStatus(config.codexApiKey);
1797
+ const authStatus = await checkLoginAuthStatus(info);
1699
1798
  if (authStatus.method === "api-key") {
1700
1799
  await safeReply(ctx, [
1701
- "<b>Cannot logout via Telegram when using CODEX_API_KEY.</b>",
1800
+ `<b>Cannot logout via Telegram when ${escapeHTML(labelForAuth(info))} uses API-key authentication.</b>`,
1702
1801
  "",
1703
- "Remove CODEX_API_KEY from .env to use CLI-based auth instead.",
1802
+ "Remove the API key from .env to use CLI-based auth instead.",
1704
1803
  ].join("\n"), {
1705
1804
  fallbackText: [
1706
- "Cannot logout via Telegram when using CODEX_API_KEY.",
1805
+ `Cannot logout via Telegram when ${labelForAuth(info)} uses API-key authentication.`,
1707
1806
  "",
1708
- "Remove CODEX_API_KEY from .env to use CLI-based auth instead.",
1807
+ "Remove the API key from .env to use CLI-based auth instead.",
1709
1808
  ].join("\n"),
1710
1809
  });
1711
1810
  return;
@@ -1714,23 +1813,23 @@ export function createBot(config, registry) {
1714
1813
  await safeReply(ctx, [
1715
1814
  "<b>Telegram-initiated auth management is disabled.</b>",
1716
1815
  "",
1717
- "Run <code>codex logout</code> on the host.",
1816
+ `Run <code>${escapeHTML(hostLogoutCommand(info))}</code> on the host.`,
1718
1817
  ].join("\n"), {
1719
1818
  fallbackText: [
1720
1819
  "Telegram-initiated auth management is disabled.",
1721
1820
  "",
1722
- "Run 'codex logout' on the host.",
1821
+ `Run '${hostLogoutCommand(info)}' on the host.`,
1723
1822
  ].join("\n"),
1724
1823
  });
1725
1824
  return;
1726
1825
  }
1727
- if (!authStatus.authenticated) {
1826
+ if (agentIdForAuth(info) !== "hermes" && !authStatus.authenticated) {
1728
1827
  await safeReply(ctx, escapeHTML("Not currently authenticated."), {
1729
1828
  fallbackText: "Not currently authenticated.",
1730
1829
  });
1731
1830
  return;
1732
1831
  }
1733
- const result = await startLogout();
1832
+ const result = await startAgentLogout(info);
1734
1833
  if (result.success) {
1735
1834
  await safeReply(ctx, `<b>🔓 Logged out.</b>\n\n${escapeHTML(result.message)}`, {
1736
1835
  fallbackText: `🔓 Logged out.\n\n${result.message}`,
@@ -1931,16 +2030,19 @@ export function createBot(config, registry) {
1931
2030
  });
1932
2031
  });
1933
2032
  bot.command(["status", "health"], async (ctx) => {
1934
- const health = await getConnectorHealth();
1935
- const authStatus = await checkAuthStatus(config.codexApiKey);
2033
+ const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
2034
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2035
+ const authStatus = contextSession
2036
+ ? await checkAgentAuthStatus(contextSession.session.getInfo())
2037
+ : await checkAuthStatus(config.codexApiKey);
1936
2038
  const html = renderHealthHTML(health, authStatus.authenticated, getUserRole(ctx));
1937
2039
  const plain = renderHealthPlain(health, authStatus.authenticated, getUserRole(ctx));
1938
2040
  await safeReply(ctx, html, { fallbackText: plain });
1939
2041
  });
1940
2042
  bot.command("version", async (ctx) => {
1941
- const health = await getConnectorHealth();
2043
+ const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
1942
2044
  const state = await readConnectorState();
1943
- const versions = await getVersionChecks({ piCliPath: config.piCliPath });
2045
+ const versions = await getVersionChecks({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
1944
2046
  const plain = [
1945
2047
  renderVersionCheckPlain(versions.nordrelay),
1946
2048
  `Runtime status: ${state.status ?? "unknown"}`,
@@ -1948,6 +2050,12 @@ export function createBot(config, registry) {
1948
2050
  renderVersionCheckPlain(versions.codex),
1949
2051
  formatCliPathPlain("Pi CLI", health.piCliPath, health.piCli),
1950
2052
  renderVersionCheckPlain(versions.pi),
2053
+ formatCliPathPlain("Hermes CLI", health.hermesCliPath, health.hermesCli),
2054
+ renderVersionCheckPlain(versions.hermes),
2055
+ formatCliPathPlain("OpenClaw CLI", health.openClawCliPath, health.openClawCli),
2056
+ renderVersionCheckPlain(versions.openclaw),
2057
+ formatCliPathPlain("Claude Code CLI", health.claudeCodeCliPath, health.claudeCodeCli),
2058
+ renderVersionCheckPlain(versions.claudeCode),
1951
2059
  ].join("\n");
1952
2060
  const html = [
1953
2061
  renderVersionCheckHTML(versions.nordrelay),
@@ -1956,6 +2064,12 @@ export function createBot(config, registry) {
1956
2064
  renderVersionCheckHTML(versions.codex),
1957
2065
  formatCliPathHTML("Pi CLI", health.piCliPath, health.piCli),
1958
2066
  renderVersionCheckHTML(versions.pi),
2067
+ formatCliPathHTML("Hermes CLI", health.hermesCliPath, health.hermesCli),
2068
+ renderVersionCheckHTML(versions.hermes),
2069
+ formatCliPathHTML("OpenClaw CLI", health.openClawCliPath, health.openClawCli),
2070
+ renderVersionCheckHTML(versions.openclaw),
2071
+ formatCliPathHTML("Claude Code CLI", health.claudeCodeCliPath, health.claudeCodeCli),
2072
+ renderVersionCheckHTML(versions.claudeCode),
1959
2073
  ].join("\n");
1960
2074
  await safeReply(ctx, html, { fallbackText: plain });
1961
2075
  });
@@ -1993,10 +2107,10 @@ export function createBot(config, registry) {
1993
2107
  return;
1994
2108
  }
1995
2109
  const options = parseActivityOptions((ctx.message?.text ?? "").replace(/^\/activity(?:@\w+)?\s*/i, "").trim());
1996
- const events = filterActivityEvents(getThreadActivityLog(threadId, options.exportFile ? 200 : options.limit), options);
2110
+ const events = filterActivityEvents(getAgentActivityLog(contextSession.session, config, options.exportFile ? 200 : options.limit), options);
1997
2111
  const rendered = renderActivityTimeline(threadId, events, options);
1998
2112
  if (options.exportFile && ctx.chat) {
1999
- const exportPath = path.join(tmpdir(), `codex-activity-${threadId}-${randomUUID().slice(0, 8)}.txt`);
2113
+ const exportPath = path.join(tmpdir(), `nordrelay-activity-${threadId}-${randomUUID().slice(0, 8)}.txt`);
2000
2114
  await writeFile(exportPath, rendered.plain, "utf8");
2001
2115
  try {
2002
2116
  await telegramRateLimiter.run(chatBucket(ctx.chat.id), "sendDocument", () => ctx.api.sendDocument(ctx.chat.id, new InputFile(exportPath, path.basename(exportPath)), {
@@ -2066,15 +2180,17 @@ export function createBot(config, registry) {
2066
2180
  await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2067
2181
  });
2068
2182
  bot.command("diagnostics", async (ctx) => {
2069
- const health = await getConnectorHealth();
2070
- const authStatus = await checkAuthStatus(config.codexApiKey);
2183
+ const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
2071
2184
  const contextKey = contextKeyFromCtx(ctx);
2072
2185
  const queueLength = contextKey ? promptStore.list(contextKey).length : 0;
2073
2186
  const progress = contextKey ? turnProgress.get(contextKey) : undefined;
2074
2187
  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>" };
2188
+ const authStatus = contextSession
2189
+ ? await checkAgentAuthStatus(contextSession.session.getInfo())
2190
+ : await checkAuthStatus(config.codexApiKey);
2191
+ const agentDiagnostics = contextSession
2192
+ ? renderAgentDiagnostics(getAgentDiagnostics(contextSession.session, config))
2193
+ : { plain: "Agent state: no context", html: "<b>Agent state:</b> <code>no context</code>" };
2078
2194
  const runtime = {
2079
2195
  rateLimit: getTelegramRateLimitMetrics(),
2080
2196
  externalMirrors: externalMirrors.size,
@@ -2087,8 +2203,8 @@ export function createBot(config, registry) {
2087
2203
  voiceLanguage: contextKey ? getEffectiveVoiceLanguage(contextKey) ?? "auto" : config.voiceDefaultLanguage ?? "auto",
2088
2204
  voiceTranscribeOnly: contextKey ? isVoiceTranscribeOnly(contextKey) : config.voiceTranscribeOnly,
2089
2205
  };
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}`;
2206
+ const plain = `${renderDiagnosticsPlain(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${agentDiagnostics.plain}`;
2207
+ const html = `${renderDiagnosticsHTML(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${agentDiagnostics.html}`;
2092
2208
  await safeReply(ctx, html, { fallbackText: plain });
2093
2209
  });
2094
2210
  bot.command("sync", async (ctx) => {
@@ -2103,7 +2219,7 @@ export function createBot(config, registry) {
2103
2219
  await safeReply(ctx, html, { fallbackText: plain });
2104
2220
  return;
2105
2221
  }
2106
- const result = contextSession.session.syncFromCodexState({ reattach: true });
2222
+ const result = contextSession.session.syncFromAgentState({ reattach: true });
2107
2223
  if (result.changed) {
2108
2224
  updateSessionMetadata(contextSession.contextKey, contextSession.session);
2109
2225
  }
@@ -2230,8 +2346,14 @@ export function createBot(config, registry) {
2230
2346
  if (!contextSession) {
2231
2347
  return;
2232
2348
  }
2233
- const { session } = contextSession;
2349
+ const { contextKey, session } = contextSession;
2234
2350
  try {
2351
+ const busy = getBusyReason(contextKey);
2352
+ if (busy.kind === "external") {
2353
+ 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.`;
2354
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2355
+ return;
2356
+ }
2235
2357
  await session.abort();
2236
2358
  await safeReply(ctx, escapeHTML("Aborted current operation"), {
2237
2359
  fallbackText: "Aborted current operation",
@@ -2536,28 +2658,29 @@ export function createBot(config, registry) {
2536
2658
  });
2537
2659
  return;
2538
2660
  }
2539
- const selectedLaunchProfile = session.getSelectedLaunchProfile();
2540
- const launchButtons = config.launchProfiles.map((profile, index) => ({
2541
- label: formatLaunchProfileLabel(profile, profile.id === selectedLaunchProfile.id),
2661
+ const profiles = session.listLaunchProfiles();
2662
+ const selectedLaunchProfile = session.getInfo();
2663
+ const launchButtons = profiles.map((profile, index) => ({
2664
+ label: formatAgentLaunchProfileLabel(profile, profile.id === selectedLaunchProfile.launchProfileId),
2542
2665
  callbackData: `launch_${index}`,
2543
2666
  }));
2544
- pendingLaunchPicks.set(contextKey, config.launchProfiles.map((profile) => profile.id));
2667
+ pendingLaunchPicks.set(contextKey, profiles.map((profile) => profile.id));
2545
2668
  pendingLaunchButtons.set(contextKey, launchButtons);
2546
2669
  pendingUnsafeLaunchConfirmations.delete(contextKey);
2547
2670
  const keyboard = paginateKeyboard(launchButtons, 0, "launch");
2548
2671
  const htmlLines = [
2549
- `<b>Selected launch profile:</b> <code>${escapeHTML(selectedLaunchProfile.label)}</code>`,
2550
- `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(selectedLaunchProfile))}</code>`,
2672
+ `<b>Selected launch profile:</b> <code>${escapeHTML(selectedLaunchProfile.launchProfileLabel)}</code>`,
2673
+ `<b>Behavior:</b> <code>${escapeHTML(selectedLaunchProfile.launchProfileBehavior)}</code>`,
2551
2674
  "",
2552
2675
  "Select a profile for new or reattached threads:",
2553
2676
  ];
2554
2677
  const plainLines = [
2555
- `Selected launch profile: ${selectedLaunchProfile.label}`,
2556
- `Behavior: ${formatLaunchProfileBehavior(selectedLaunchProfile)}`,
2678
+ `Selected launch profile: ${selectedLaunchProfile.launchProfileLabel}`,
2679
+ `Behavior: ${selectedLaunchProfile.launchProfileBehavior}`,
2557
2680
  "",
2558
2681
  "Select a profile for new or reattached threads:",
2559
2682
  ];
2560
- if (selectedLaunchProfile.unsafe) {
2683
+ if (selectedLaunchProfile.unsafeLaunch) {
2561
2684
  htmlLines.splice(2, 0, "⚠️ <i>Selected profile uses danger-full-access.</i>");
2562
2685
  plainLines.splice(2, 0, "⚠️ Selected profile uses danger-full-access.");
2563
2686
  }
@@ -2897,6 +3020,10 @@ export function createBot(config, registry) {
2897
3020
  });
2898
3021
  return;
2899
3022
  }
3023
+ const info = session.getInfo();
3024
+ await session.refreshModels({ force: true }).catch((error) => {
3025
+ console.warn(`Failed to refresh ${labelOf(info)} models: ${error instanceof Error ? error.message : String(error)}`);
3026
+ });
2900
3027
  const models = session.listModels();
2901
3028
  if (models.length === 0) {
2902
3029
  await safeReply(ctx, escapeHTML("No models available."), {
@@ -2906,7 +3033,7 @@ export function createBot(config, registry) {
2906
3033
  }
2907
3034
  const currentModel = session.getInfo().model ?? "(default)";
2908
3035
  const modelButtons = models.map((model) => ({
2909
- label: `${model.displayName}${model.slug === currentModel ? " ✓" : ""}`,
3036
+ label: formatModelButtonLabel(model, model.slug === currentModel),
2910
3037
  callbackData: `model_${model.slug}`,
2911
3038
  }));
2912
3039
  pendingModelButtons.set(contextKey, modelButtons);
@@ -2993,7 +3120,7 @@ export function createBot(config, registry) {
2993
3120
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2994
3121
  return;
2995
3122
  }
2996
- const efforts = idOf(info) === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
3123
+ const efforts = agentReasoningOptions(idOf(info));
2997
3124
  const current = info.reasoningEffort;
2998
3125
  const effortButtons = efforts.map((effort) => ({
2999
3126
  label: effort === current ? `${effort} ✓` : effort,
@@ -3011,7 +3138,7 @@ export function createBot(config, registry) {
3011
3138
  });
3012
3139
  };
3013
3140
  bot.command(["effort", "reasoning"], openReasoningPicker);
3014
- bot.callbackQuery(/^agent_(codex|pi)$/, async (ctx) => {
3141
+ bot.callbackQuery(/^agent_(codex|pi|hermes|openclaw|claude-code)$/, async (ctx) => {
3015
3142
  const chatId = ctx.chat?.id;
3016
3143
  const messageId = ctx.callbackQuery.message?.message_id;
3017
3144
  const selectedAgent = ctx.match?.[1];
@@ -3365,7 +3492,7 @@ export function createBot(config, registry) {
3365
3492
  await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3366
3493
  return;
3367
3494
  }
3368
- const profile = findLaunchProfile(config.launchProfiles, profileId);
3495
+ const profile = session.listLaunchProfiles().find((candidate) => candidate.id === profileId);
3369
3496
  if (!profile) {
3370
3497
  clearLaunchSelectionState(contextKey);
3371
3498
  await ctx.answerCallbackQuery({ text: "Launch profile no longer exists" });
@@ -3382,14 +3509,14 @@ export function createBot(config, registry) {
3382
3509
  .text("Cancel", `launchconfirm_no:${profile.id}`);
3383
3510
  const html = [
3384
3511
  `<b>Confirm launch profile:</b> <code>${escapeHTML(profile.label)}</code>`,
3385
- `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(profile))}</code>`,
3512
+ `<b>Behavior:</b> <code>${escapeHTML(profile.behavior)}</code>`,
3386
3513
  "",
3387
3514
  "⚠️ <b>This profile uses danger-full-access.</b>",
3388
3515
  "It will apply to new or reattached threads in this Telegram context.",
3389
3516
  ].join("\n");
3390
3517
  const plain = [
3391
3518
  `Confirm launch profile: ${profile.label}`,
3392
- `Behavior: ${formatLaunchProfileBehavior(profile)}`,
3519
+ `Behavior: ${profile.behavior}`,
3393
3520
  "",
3394
3521
  "WARNING: This profile uses danger-full-access.",
3395
3522
  "It will apply to new or reattached threads in this Telegram context.",
@@ -3410,17 +3537,18 @@ export function createBot(config, registry) {
3410
3537
  }
3411
3538
  await ctx.answerCallbackQuery({ text: `Launch set to ${profile.label}` });
3412
3539
  clearLaunchSelectionState(contextKey);
3413
- const selectedProfile = session.setLaunchProfile(profile.id);
3540
+ session.setLaunchProfile(profile.id);
3414
3541
  updateSessionMetadata(contextKey, session);
3542
+ const info = session.getInfo();
3415
3543
  const html = [
3416
- `<b>Launch profile set to</b> <code>${escapeHTML(selectedProfile.label)}</code>`,
3417
- `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(selectedProfile))}</code>`,
3544
+ `<b>Launch profile set to</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`,
3545
+ `<b>Behavior:</b> <code>${escapeHTML(info.launchProfileBehavior)}</code>`,
3418
3546
  "",
3419
3547
  "Applies to new or reattached threads.",
3420
3548
  ].join("\n");
3421
3549
  const plain = [
3422
- `Launch profile set to ${selectedProfile.label}`,
3423
- `Behavior: ${formatLaunchProfileBehavior(selectedProfile)}`,
3550
+ `Launch profile set to ${info.launchProfileLabel}`,
3551
+ `Behavior: ${info.launchProfileBehavior}`,
3424
3552
  "",
3425
3553
  "Applies to new or reattached threads.",
3426
3554
  ].join("\n");
@@ -3461,7 +3589,7 @@ export function createBot(config, registry) {
3461
3589
  await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3462
3590
  return;
3463
3591
  }
3464
- const profile = findLaunchProfile(config.launchProfiles, profileId);
3592
+ const profile = session.listLaunchProfiles().find((candidate) => candidate.id === profileId);
3465
3593
  if (!profile) {
3466
3594
  clearLaunchSelectionState(contextKey);
3467
3595
  await ctx.answerCallbackQuery({ text: "Launch profile no longer exists" });
@@ -3471,18 +3599,19 @@ export function createBot(config, registry) {
3471
3599
  return;
3472
3600
  }
3473
3601
  clearLaunchSelectionState(contextKey);
3474
- const selectedProfile = session.setLaunchProfile(profile.id);
3602
+ session.setLaunchProfile(profile.id);
3475
3603
  updateSessionMetadata(contextKey, session);
3476
- await ctx.answerCallbackQuery({ text: `Launch set to ${selectedProfile.label}` });
3604
+ const info = session.getInfo();
3605
+ await ctx.answerCallbackQuery({ text: `Launch set to ${info.launchProfileLabel}` });
3477
3606
  const html = [
3478
- `<b>Launch profile set to</b> <code>${escapeHTML(selectedProfile.label)}</code>`,
3479
- `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(selectedProfile))}</code>`,
3607
+ `<b>Launch profile set to</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`,
3608
+ `<b>Behavior:</b> <code>${escapeHTML(info.launchProfileBehavior)}</code>`,
3480
3609
  "",
3481
3610
  "⚠️ <i>danger-full-access confirmed for new or reattached threads.</i>",
3482
3611
  ].join("\n");
3483
3612
  const plain = [
3484
- `Launch profile set to ${selectedProfile.label}`,
3485
- `Behavior: ${formatLaunchProfileBehavior(selectedProfile)}`,
3613
+ `Launch profile set to ${info.launchProfileLabel}`,
3614
+ `Behavior: ${info.launchProfileBehavior}`,
3486
3615
  "",
3487
3616
  "danger-full-access confirmed for new or reattached threads.",
3488
3617
  ].join("\n");
@@ -3519,9 +3648,7 @@ export function createBot(config, registry) {
3519
3648
  try {
3520
3649
  const result = await session.setModelForCurrentSession(slug);
3521
3650
  updateSessionMetadata(contextKey, session);
3522
- const scope = result.appliedToActiveThread
3523
- ? "applied to the current idle thread and future threads"
3524
- : "applies to new threads";
3651
+ const scope = formatAgentSettingScope(session.getInfo(), result.appliedToActiveThread);
3525
3652
  const html = `<b>Model set to</b> <code>${escapeHTML(result.value)}</code> — ${escapeHTML(scope)}.`;
3526
3653
  const plainText = `Model set to ${result.value} — ${scope}.`;
3527
3654
  if (messageId) {
@@ -3542,7 +3669,7 @@ export function createBot(config, registry) {
3542
3669
  }
3543
3670
  }
3544
3671
  });
3545
- bot.callbackQuery(/^effort_(off|minimal|low|medium|high|xhigh)$/, async (ctx) => {
3672
+ bot.callbackQuery(/^effort_(off|none|minimal|low|medium|high|xhigh)$/, async (ctx) => {
3546
3673
  const chatId = ctx.chat?.id;
3547
3674
  const messageId = ctx.callbackQuery.message?.message_id;
3548
3675
  const effort = ctx.match?.[1];
@@ -3568,9 +3695,7 @@ export function createBot(config, registry) {
3568
3695
  const result = await session.setReasoningEffortForCurrentSession(effort);
3569
3696
  updateSessionMetadata(contextKey, session);
3570
3697
  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";
3698
+ const scope = formatAgentSettingScope(session.getInfo(), result.appliedToActiveThread);
3574
3699
  const html = `⚡ ${escapeHTML(label)} set to <code>${escapeHTML(effort)}</code> — ${escapeHTML(scope)}.`;
3575
3700
  await safeEditMessage(bot, chatId, messageId, html, {
3576
3701
  fallbackText: `⚡ ${label} set to ${effort} — ${scope}.`,
@@ -3901,7 +4026,7 @@ export async function registerCommands(bot) {
3901
4026
  { command: "help", description: "Command reference" },
3902
4027
  { command: "channels", description: "Messaging adapter status" },
3903
4028
  { command: "agents", description: "Agent adapter status" },
3904
- { command: "agent", description: "Select Codex or Pi" },
4029
+ { command: "agent", description: "Select agent" },
3905
4030
  { command: "new", description: "Start a new thread" },
3906
4031
  { command: "session", description: "Current thread details" },
3907
4032
  { command: "sessions", description: "Browse & switch threads" },
@@ -4259,7 +4384,7 @@ function renderExternalMirrorStatus(snapshot, queueLength) {
4259
4384
  ? formatDurationSeconds((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
4260
4385
  : "-";
4261
4386
  const lines = [
4262
- "Codex CLI task running.",
4387
+ `${snapshot.agentLabel} CLI task running.`,
4263
4388
  `Thread: ${snapshot.threadId}`,
4264
4389
  `Elapsed: ${elapsed}`,
4265
4390
  `Prompt: ${prompt}`,
@@ -4269,7 +4394,7 @@ function renderExternalMirrorStatus(snapshot, queueLength) {
4269
4394
  return {
4270
4395
  plain: lines.join("\n"),
4271
4396
  html: [
4272
- "<b>Codex CLI task running.</b>",
4397
+ `<b>${escapeHTML(snapshot.agentLabel)} CLI task running.</b>`,
4273
4398
  `<b>Thread:</b> <code>${escapeHTML(snapshot.threadId)}</code>`,
4274
4399
  `<b>Elapsed:</b> <code>${escapeHTML(elapsed)}</code>`,
4275
4400
  `<b>Prompt:</b> <code>${escapeHTML(prompt)}</code>`,
@@ -4302,8 +4427,8 @@ function renderExternalMirrorEvent(event) {
4302
4427
  function renderActivityTimeline(threadId, events, options = { limit: 16, filter: "all", exportFile: false }) {
4303
4428
  if (events.length === 0) {
4304
4429
  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>`,
4430
+ plain: `Activity:\nThread: ${threadId}\nFilter: ${options.filter}\nNo activity events found.`,
4431
+ 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
4432
  };
4308
4433
  }
4309
4434
  const lines = events.map((event) => {
@@ -4379,50 +4504,36 @@ function filterActivityEvents(events, options) {
4379
4504
  function isActivityFilter(value) {
4380
4505
  return value === "all" || value === "tools" || value === "errors" || value === "user" || value === "agent" || value === "tasks";
4381
4506
  }
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
- ];
4507
+ function formatAgentLaunchProfileLabel(profile, selected) {
4508
+ const prefix = selected ? "✅" : profile.unsafe ? "⚠️" : "🚀";
4509
+ return `${prefix} ${profile.label} · ${trimLine(profile.behavior, 24)}`;
4510
+ }
4511
+ function formatModelButtonLabel(model, selected) {
4512
+ const meta = [
4513
+ model.contextWindow ? formatCompactNumber(model.contextWindow) : undefined,
4514
+ model.supportsImages === true ? "img" : model.supportsImages === false ? "text" : undefined,
4515
+ model.supportsThinking === true ? "think" : undefined,
4516
+ ].filter(Boolean).join(" ");
4517
+ return trimLine(`${selected ? "✅ " : ""}${model.displayName}${meta ? ` · ${meta}` : ""}`, 58);
4518
+ }
4519
+ function formatCompactNumber(value) {
4520
+ if (value >= 1_000_000_000)
4521
+ return `${Math.round(value / 100_000_000) / 10}B`;
4522
+ if (value >= 1_000_000)
4523
+ return `${Math.round(value / 100_000) / 10}M`;
4524
+ if (value >= 1_000)
4525
+ return `${Math.round(value / 100) / 10}K`;
4526
+ return String(value);
4527
+ }
4528
+ function renderAgentDiagnostics(diagnostics) {
4416
4529
  return {
4417
- plain: lines.join("\n"),
4530
+ plain: [
4531
+ `${diagnostics.agentLabel} state:`,
4532
+ ...diagnostics.lines.map((line) => `${line.label}: ${line.value}`),
4533
+ ].join("\n"),
4418
4534
  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>`,
4535
+ `<b>${escapeHTML(diagnostics.agentLabel)} state:</b>`,
4536
+ ...diagnostics.lines.map((line) => `<b>${escapeHTML(line.label)}:</b> <code>${escapeHTML(line.value)}</code>`),
4426
4537
  ].join("\n"),
4427
4538
  };
4428
4539
  }
@@ -4462,6 +4573,11 @@ function renderDiagnosticsPlain(config, registry, health, authenticated, role, q
4462
4573
  `Telegram transport: ${config.telegramTransport}`,
4463
4574
  `Codex CLI: ${health.codexCli}`,
4464
4575
  `Pi CLI: ${health.piCli}`,
4576
+ `Hermes CLI: ${health.hermesCli}`,
4577
+ `OpenClaw CLI: ${health.openClawCli}`,
4578
+ `Claude Code CLI: ${health.claudeCodeCli}`,
4579
+ `Hermes API: ${config.hermesApiBaseUrl}`,
4580
+ `OpenClaw Gateway: ${config.openClawGatewayUrl}`,
4465
4581
  `Enabled agents/default: ${enabledAgents(config).join(", ")} / ${config.defaultAgent}`,
4466
4582
  `State DB: ${health.databasePath ?? "-"}`,
4467
4583
  `Log file: ${health.logFile}`,
@@ -4502,6 +4618,11 @@ function renderDiagnosticsHTML(config, registry, health, authenticated, role, qu
4502
4618
  `<b>Telegram transport:</b> <code>${escapeHTML(config.telegramTransport)}</code>`,
4503
4619
  `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4504
4620
  `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
4621
+ `<b>Hermes CLI:</b> <code>${escapeHTML(health.hermesCli)}</code>`,
4622
+ `<b>OpenClaw CLI:</b> <code>${escapeHTML(health.openClawCli)}</code>`,
4623
+ `<b>Claude Code CLI:</b> <code>${escapeHTML(health.claudeCodeCli)}</code>`,
4624
+ `<b>Hermes API:</b> <code>${escapeHTML(config.hermesApiBaseUrl)}</code>`,
4625
+ `<b>OpenClaw Gateway:</b> <code>${escapeHTML(config.openClawGatewayUrl)}</code>`,
4505
4626
  `<b>Enabled agents/default:</b> <code>${escapeHTML(`${enabledAgents(config).join(", ")} / ${config.defaultAgent}`)}</code>`,
4506
4627
  `<b>State DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
4507
4628
  `<b>Log file:</b> <code>${escapeHTML(health.logFile)}</code>`,
@@ -4539,6 +4660,9 @@ function renderHealthPlain(health, authenticated, role) {
4539
4660
  `Workspace: ${health.state.workspace ?? "-"}`,
4540
4661
  `Codex CLI: ${health.codexCli}`,
4541
4662
  `Pi CLI: ${health.piCli}`,
4663
+ `Hermes CLI: ${health.hermesCli}`,
4664
+ `OpenClaw CLI: ${health.openClawCli}`,
4665
+ `Claude Code CLI: ${health.claudeCodeCli}`,
4542
4666
  `Codex state DB: ${health.databasePath ?? "-"}`,
4543
4667
  `Log: ${health.logFile}`,
4544
4668
  ].join("\n");
@@ -4555,6 +4679,9 @@ function renderHealthHTML(health, authenticated, role) {
4555
4679
  `<b>Workspace:</b> <code>${escapeHTML(health.state.workspace ?? "-")}</code>`,
4556
4680
  `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4557
4681
  `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
4682
+ `<b>Hermes CLI:</b> <code>${escapeHTML(health.hermesCli)}</code>`,
4683
+ `<b>OpenClaw CLI:</b> <code>${escapeHTML(health.openClawCli)}</code>`,
4684
+ `<b>Claude Code CLI:</b> <code>${escapeHTML(health.claudeCodeCli)}</code>`,
4558
4685
  `<b>Codex state DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
4559
4686
  `<b>Log:</b> <code>${escapeHTML(health.logFile)}</code>`,
4560
4687
  ].join("\n");
@@ -4622,6 +4749,48 @@ function labelOf(info) {
4622
4749
  function idOf(info) {
4623
4750
  return info.agentId ?? "codex";
4624
4751
  }
4752
+ function authHelpText(info) {
4753
+ const agentId = idOf(info);
4754
+ if (agentId === "pi") {
4755
+ return "Configure the required Pi provider environment variable on the host.";
4756
+ }
4757
+ if (agentId === "hermes") {
4758
+ return "Start the Hermes API Server, configure HERMES_API_KEY when required, or use /login to start Hermes CLI auth.";
4759
+ }
4760
+ if (agentId === "openclaw") {
4761
+ return "Start the OpenClaw Gateway and configure OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD when the gateway requires one.";
4762
+ }
4763
+ if (agentId === "claude-code") {
4764
+ return "Use /login to start Claude Code CLI auth, or run 'claude auth login' on the host.";
4765
+ }
4766
+ return "Use /login to start authentication, or set CODEX_API_KEY on the host.";
4767
+ }
4768
+ function formatAgentSettingScope(info, appliedToActiveThread) {
4769
+ const agentId = idOf(info);
4770
+ if (agentId === "hermes") {
4771
+ return appliedToActiveThread
4772
+ ? "applies to the next Hermes run in this session"
4773
+ : "applies to new Hermes sessions";
4774
+ }
4775
+ if (agentId === "pi") {
4776
+ return appliedToActiveThread
4777
+ ? "applied to the current idle Pi session and future turns"
4778
+ : "applies to new Pi sessions";
4779
+ }
4780
+ if (agentId === "openclaw") {
4781
+ return appliedToActiveThread
4782
+ ? "applies to the next OpenClaw run in this session"
4783
+ : "applies to new OpenClaw sessions";
4784
+ }
4785
+ if (agentId === "claude-code") {
4786
+ return appliedToActiveThread
4787
+ ? "applies to the next Claude Code run in this session"
4788
+ : "applies to new Claude Code sessions";
4789
+ }
4790
+ return appliedToActiveThread
4791
+ ? "applied to the current idle thread and future threads"
4792
+ : "applies to new threads";
4793
+ }
4625
4794
  function requiresTurnApproval(info) {
4626
4795
  return info.unsafeLaunch || info.approvalPolicy !== "never";
4627
4796
  }