@nordbyte/nordrelay 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.env.example +45 -2
  2. package/README.md +227 -47
  3. package/dist/agent-activity.js +300 -0
  4. package/dist/agent-adapter.js +17 -30
  5. package/dist/agent-factory.js +27 -0
  6. package/dist/agent.js +123 -9
  7. package/dist/artifacts.js +1 -1
  8. package/dist/audit-log.js +1 -1
  9. package/dist/bot-ui.js +1 -1
  10. package/dist/bot.js +333 -161
  11. package/dist/claude-code-auth.js +121 -0
  12. package/dist/claude-code-cli.js +19 -0
  13. package/dist/claude-code-launch.js +73 -0
  14. package/dist/claude-code-session.js +660 -0
  15. package/dist/claude-code-state.js +590 -0
  16. package/dist/codex-session.js +15 -2
  17. package/dist/config.js +113 -9
  18. package/dist/context-key.js +23 -0
  19. package/dist/hermes-api.js +150 -0
  20. package/dist/hermes-auth.js +96 -0
  21. package/dist/hermes-cli.js +19 -0
  22. package/dist/hermes-launch.js +57 -0
  23. package/dist/hermes-session.js +477 -0
  24. package/dist/hermes-state.js +609 -0
  25. package/dist/index.js +51 -8
  26. package/dist/openclaw-auth.js +27 -0
  27. package/dist/openclaw-cli.js +19 -0
  28. package/dist/openclaw-gateway.js +285 -0
  29. package/dist/openclaw-launch.js +65 -0
  30. package/dist/openclaw-session.js +549 -0
  31. package/dist/openclaw-state.js +409 -0
  32. package/dist/operations.js +84 -3
  33. package/dist/pi-auth.js +59 -0
  34. package/dist/pi-launch.js +61 -0
  35. package/dist/pi-rpc.js +18 -0
  36. package/dist/pi-session.js +103 -15
  37. package/dist/pi-state.js +253 -0
  38. package/dist/relay-runtime.js +1073 -22
  39. package/dist/session-format.js +28 -18
  40. package/dist/session-registry.js +43 -18
  41. package/dist/settings-service.js +80 -26
  42. package/dist/state-backend.js +17 -8
  43. package/dist/web-dashboard-ui.js +18 -0
  44. package/dist/web-dashboard.js +463 -55
  45. package/dist/web-state.js +131 -0
  46. package/docker-compose.yml +1 -1
  47. package/package.json +8 -3
  48. package/plugins/nordrelay/.codex-plugin/plugin.json +7 -4
  49. package/plugins/nordrelay/commands/remote.md +2 -2
  50. package/plugins/nordrelay/scripts/nordrelay.mjs +173 -20
  51. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +2 -2
  52. package/CHANGELOG.md +0 -17
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";
20
- import { contextKeyFromCtx, isTopicContextKey, parseContextKey } from "./context-key.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";
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;
@@ -331,12 +400,15 @@ export function createBot(config, registry) {
331
400
  const contextKeys = new Set([
332
401
  ...registry.listContexts().map((context) => context.contextKey),
333
402
  ...promptStore.listContextKeys(),
334
- ]);
403
+ ].filter(isTelegramContextKey));
335
404
  for (const contextKey of contextKeys) {
336
405
  await monitorExternalContext(contextKey);
337
406
  }
338
407
  };
339
408
  const monitorExternalContext = async (contextKey) => {
409
+ if (!isTelegramContextKey(contextKey)) {
410
+ return;
411
+ }
340
412
  const session = await registry.getOrCreate(contextKey, { deferThreadStart: true }).catch(() => null);
341
413
  if (!session) {
342
414
  return;
@@ -361,11 +433,9 @@ export function createBot(config, registry) {
361
433
  return;
362
434
  }
363
435
  const previous = externalMirrors.get(contextKey);
364
- const snapshot = getThreadRolloutSnapshot(threadId, {
436
+ const snapshot = getExternalSnapshotForSession(session, config, {
365
437
  afterLine: previous?.lastLine ?? Number.MAX_SAFE_INTEGER,
366
- staleAfterMs: config.codexExternalBusyStaleMs,
367
- }) ?? getThreadRolloutSnapshot(threadId, {
368
- staleAfterMs: config.codexExternalBusyStaleMs,
438
+ }) ?? getExternalSnapshotForSession(session, config, {
369
439
  maxEvents: 0,
370
440
  });
371
441
  if (!snapshot) {
@@ -379,7 +449,7 @@ export function createBot(config, registry) {
379
449
  }
380
450
  const activity = snapshot.activity;
381
451
  if (activity.active && queueLength > 0) {
382
- 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)" : ""}.`);
383
453
  return;
384
454
  }
385
455
  if (!activity.active && queueLength > 0 && !paused && !session.isProcessing()) {
@@ -391,10 +461,10 @@ export function createBot(config, registry) {
391
461
  const parsed = parseContextKey(contextKey);
392
462
  const previous = externalMirrors.get(contextKey);
393
463
  let state = previous;
394
- if (!state || state.threadId !== snapshot.threadId || state.rolloutPath !== snapshot.rolloutPath) {
464
+ if (!state || state.threadId !== snapshot.threadId || state.rolloutPath !== snapshot.sourcePath) {
395
465
  state = {
396
466
  threadId: snapshot.threadId,
397
- rolloutPath: snapshot.rolloutPath,
467
+ rolloutPath: snapshot.sourcePath,
398
468
  lastLine: snapshot.lineCount,
399
469
  turnId: snapshot.activity.turnId,
400
470
  startedAt: snapshot.activity.startedAt,
@@ -455,7 +525,7 @@ export function createBot(config, registry) {
455
525
  const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
456
526
  if (terminalEvent) {
457
527
  if (mirrorMode !== "off") {
458
- const doneText = `Codex CLI task ${terminalEvent.status}.`;
528
+ const doneText = `${snapshot.agentLabel} CLI task ${terminalEvent.status}.`;
459
529
  if (state.statusMessageId) {
460
530
  await safeEditMessage(bot, chatId, state.statusMessageId, escapeHTML(doneText), {
461
531
  fallbackText: doneText,
@@ -470,8 +540,8 @@ export function createBot(config, registry) {
470
540
  }
471
541
  const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
472
542
  if (mirrorMode !== "off" && mirrorMode !== "status" && finalAgent?.text && finalAgent.lineNumber !== state.latestAgentLine) {
473
- await sendTextMessage(bot.api, chatId, "<b>Codex CLI final answer:</b>", {
474
- 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:`,
475
545
  messageThreadId: parsed.messageThreadId,
476
546
  });
477
547
  for (const chunk of splitMarkdownForTelegram(finalAgent.text)) {
@@ -541,7 +611,8 @@ export function createBot(config, registry) {
541
611
  }
542
612
  const busy = getBusyReason(contextKey);
543
613
  if (busy.kind === "external") {
544
- 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)" : ""}.`);
545
616
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
546
617
  return;
547
618
  }
@@ -551,7 +622,7 @@ export function createBot(config, registry) {
551
622
  await updateQueueStatusMessage(contextKey, `CLI task finished, running queued prompt 1/${promptStore.list(contextKey).length}.`);
552
623
  await drainQueuedPrompts(ctx, contextKey, chatId, session);
553
624
  })().catch((error) => {
554
- console.error("Failed to drain queue after external Codex activity:", error);
625
+ console.error("Failed to drain queue after external CLI activity:", error);
555
626
  });
556
627
  }, config.codexExternalBusyCheckMs);
557
628
  timer.unref?.();
@@ -1106,21 +1177,21 @@ export function createBot(config, registry) {
1106
1177
  try {
1107
1178
  const sessionInfo = session.getInfo();
1108
1179
  if (capabilitiesOf(sessionInfo).auth) {
1109
- const authStatus = await checkAuthStatus(config.codexApiKey);
1180
+ const authStatus = await checkAgentAuthStatus(sessionInfo);
1110
1181
  if (!authStatus.authenticated) {
1111
1182
  await safeReply(ctx, [
1112
1183
  `<b>⚠️ ${escapeHTML(labelOf(sessionInfo))} is not authenticated.</b>`,
1113
1184
  "",
1114
1185
  `<code>${escapeHTML(authStatus.detail)}</code>`,
1115
1186
  "",
1116
- "Use /login to start authentication, or set CODEX_API_KEY on the host.",
1187
+ authHelpText(sessionInfo),
1117
1188
  ].join("\n"), {
1118
1189
  fallbackText: [
1119
1190
  `⚠️ ${labelOf(sessionInfo)} is not authenticated.`,
1120
1191
  "",
1121
1192
  authStatus.detail,
1122
1193
  "",
1123
- "Use /login to start authentication, or set CODEX_API_KEY on the host.",
1194
+ authHelpText(sessionInfo),
1124
1195
  ].join("\n"),
1125
1196
  });
1126
1197
  return;
@@ -1132,6 +1203,24 @@ export function createBot(config, registry) {
1132
1203
  });
1133
1204
  return;
1134
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
+ }
1135
1224
  if (!(await ensureActiveThread(ctx, contextKey, session))) {
1136
1225
  return;
1137
1226
  }
@@ -1145,12 +1234,13 @@ export function createBot(config, registry) {
1145
1234
  const finalExternalActivity = getExternalActivity(session);
1146
1235
  if (finalExternalActivity?.active) {
1147
1236
  const item = maybeRequeuePromptAtFront(contextKey, envelope);
1148
- 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.`;
1149
1239
  await safeReply(ctx, escapeHTML(message), {
1150
1240
  fallbackText: message,
1151
1241
  replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
1152
1242
  });
1153
- 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.`);
1154
1244
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
1155
1245
  turnProgress.delete(contextKey);
1156
1246
  return;
@@ -1161,9 +1251,14 @@ export function createBot(config, registry) {
1161
1251
  status: "ok",
1162
1252
  description: envelope.description,
1163
1253
  });
1254
+ const artifactStartedAt = new Date();
1255
+ const artifactTurnId = envelope.artifactOutDir
1256
+ ? path.basename(path.dirname(envelope.artifactOutDir))
1257
+ : randomUUID().slice(0, 12);
1164
1258
  await session.prompt(envelope.input, callbacks);
1165
1259
  updateSessionMetadata(contextKey, session);
1166
1260
  await finalizeResponse();
1261
+ await deliverCliGeneratedArtifacts(contextKey, chatId, session, artifactStartedAt, artifactTurnId, messageThreadId);
1167
1262
  if (envelope.artifactOutDir) {
1168
1263
  if (config.telegramAutoSendArtifacts) {
1169
1264
  await deliverArtifacts(ctx, chatId, envelope.artifactOutDir, session.getInfo().workspace, messageThreadId);
@@ -1318,7 +1413,7 @@ export function createBot(config, registry) {
1318
1413
  const deliverArtifactReportZip = async (ctx, chatId, report, messageThreadId) => {
1319
1414
  const bundle = await createArtifactZipBundle(report.artifacts, report.outDir, {
1320
1415
  maxFileSize: config.maxFileSize,
1321
- bundleName: `codex-artifacts-${report.turnId}.zip`,
1416
+ bundleName: `nordrelay-artifacts-${report.turnId}.zip`,
1322
1417
  });
1323
1418
  if (!bundle) {
1324
1419
  await safeReply(ctx, escapeHTML("Could not create a ZIP bundle for this artifact turn."), {
@@ -1513,9 +1608,9 @@ export function createBot(config, registry) {
1513
1608
  }
1514
1609
  const { contextKey, session } = contextSession;
1515
1610
  const info = session.getInfo();
1516
- const authStatus = capabilitiesOf(info).auth ? await checkAuthStatus(config.codexApiKey) : null;
1611
+ const authStatus = capabilitiesOf(info).auth ? await checkAgentAuthStatus(info) : null;
1517
1612
  const authWarning = authStatus && !authStatus.authenticated
1518
- ? "Not authenticated. Use /login or set CODEX_API_KEY."
1613
+ ? [`${labelOf(info)} is not authenticated.`, authStatus.detail, authHelpText(info)].filter(Boolean).join(" ")
1519
1614
  : undefined;
1520
1615
  const isReturning = registry.hasMetadata(contextKey);
1521
1616
  if (isReturning) {
@@ -1558,7 +1653,13 @@ export function createBot(config, registry) {
1558
1653
  ? config.codexEnabled
1559
1654
  : descriptor.id === "pi"
1560
1655
  ? config.piEnabled
1561
- : 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;
1562
1663
  return `${descriptor.label}: ${descriptor.status}${descriptor.status === "available" ? ` · ${enabled ? "enabled" : "disabled"}` : ""}`;
1563
1664
  }),
1564
1665
  ].join("\n");
@@ -1569,7 +1670,13 @@ export function createBot(config, registry) {
1569
1670
  ? config.codexEnabled
1570
1671
  : descriptor.id === "pi"
1571
1672
  ? config.piEnabled
1572
- : 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;
1573
1680
  const status = descriptor.status === "available" ? `${enabled ? "enabled" : "disabled"}` : "planned";
1574
1681
  const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
1575
1682
  return `${descriptor.status === "available" ? "✅" : "🟡"} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(status)}</code>${notes}`;
@@ -1583,11 +1690,6 @@ export function createBot(config, registry) {
1583
1690
  return;
1584
1691
  }
1585
1692
  const { contextKey, session } = contextSession;
1586
- if (!capabilitiesOf(session.getInfo()).modelSelection) {
1587
- const text = `Model selection is not supported for ${labelOf(session.getInfo())}.`;
1588
- await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1589
- return;
1590
- }
1591
1693
  if (isBusy(contextKey)) {
1592
1694
  await safeReply(ctx, escapeHTML("Cannot switch agent while a prompt is running."), {
1593
1695
  fallbackText: "Cannot switch agent while a prompt is running.",
@@ -1624,7 +1726,7 @@ export function createBot(config, registry) {
1624
1726
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1625
1727
  return;
1626
1728
  }
1627
- const authStatus = await checkAuthStatus(config.codexApiKey);
1729
+ const authStatus = info ? await checkAgentAuthStatus(info) : await checkAuthStatus(config.codexApiKey);
1628
1730
  const icon = authStatus.authenticated ? "✅" : "❌";
1629
1731
  const html = [
1630
1732
  `<b>${icon} Auth status:</b> ${authStatus.authenticated ? "authenticated" : "not authenticated"}`,
@@ -1649,8 +1751,8 @@ export function createBot(config, registry) {
1649
1751
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1650
1752
  return;
1651
1753
  }
1652
- const authStatus = await checkAuthStatus(config.codexApiKey);
1653
- if (authStatus.authenticated) {
1754
+ const authStatus = await checkLoginAuthStatus(info);
1755
+ if (agentIdForAuth(info) !== "hermes" && authStatus.authenticated) {
1654
1756
  await safeReply(ctx, `<b>✅ Already authenticated</b> via <code>${escapeHTML(authStatus.method)}</code>.`, {
1655
1757
  fallbackText: `✅ Already authenticated via ${authStatus.method}.`,
1656
1758
  });
@@ -1660,17 +1762,17 @@ export function createBot(config, registry) {
1660
1762
  await safeReply(ctx, [
1661
1763
  "<b>Telegram-initiated login is disabled.</b>",
1662
1764
  "",
1663
- "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.`,
1664
1766
  ].join("\n"), {
1665
1767
  fallbackText: [
1666
1768
  "Telegram-initiated login is disabled.",
1667
1769
  "",
1668
- "Run 'codex login' on the host, or set CODEX_API_KEY in .env.",
1770
+ `Run '${hostLoginCommand(info)}' on the host.`,
1669
1771
  ].join("\n"),
1670
1772
  });
1671
1773
  return;
1672
1774
  }
1673
- const result = await startLogin();
1775
+ const result = await startAgentLogin(info);
1674
1776
  if (result.success) {
1675
1777
  await safeReply(ctx, `<b>🔑 Login initiated.</b>\n\n<code>${escapeHTML(result.message)}</code>`, {
1676
1778
  fallbackText: `🔑 Login initiated.\n\n${result.message}`,
@@ -1692,17 +1794,17 @@ export function createBot(config, registry) {
1692
1794
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
1693
1795
  return;
1694
1796
  }
1695
- const authStatus = await checkAuthStatus(config.codexApiKey);
1797
+ const authStatus = await checkLoginAuthStatus(info);
1696
1798
  if (authStatus.method === "api-key") {
1697
1799
  await safeReply(ctx, [
1698
- "<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>`,
1699
1801
  "",
1700
- "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.",
1701
1803
  ].join("\n"), {
1702
1804
  fallbackText: [
1703
- "Cannot logout via Telegram when using CODEX_API_KEY.",
1805
+ `Cannot logout via Telegram when ${labelForAuth(info)} uses API-key authentication.`,
1704
1806
  "",
1705
- "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.",
1706
1808
  ].join("\n"),
1707
1809
  });
1708
1810
  return;
@@ -1711,23 +1813,23 @@ export function createBot(config, registry) {
1711
1813
  await safeReply(ctx, [
1712
1814
  "<b>Telegram-initiated auth management is disabled.</b>",
1713
1815
  "",
1714
- "Run <code>codex logout</code> on the host.",
1816
+ `Run <code>${escapeHTML(hostLogoutCommand(info))}</code> on the host.`,
1715
1817
  ].join("\n"), {
1716
1818
  fallbackText: [
1717
1819
  "Telegram-initiated auth management is disabled.",
1718
1820
  "",
1719
- "Run 'codex logout' on the host.",
1821
+ `Run '${hostLogoutCommand(info)}' on the host.`,
1720
1822
  ].join("\n"),
1721
1823
  });
1722
1824
  return;
1723
1825
  }
1724
- if (!authStatus.authenticated) {
1826
+ if (agentIdForAuth(info) !== "hermes" && !authStatus.authenticated) {
1725
1827
  await safeReply(ctx, escapeHTML("Not currently authenticated."), {
1726
1828
  fallbackText: "Not currently authenticated.",
1727
1829
  });
1728
1830
  return;
1729
1831
  }
1730
- const result = await startLogout();
1832
+ const result = await startAgentLogout(info);
1731
1833
  if (result.success) {
1732
1834
  await safeReply(ctx, `<b>🔓 Logged out.</b>\n\n${escapeHTML(result.message)}`, {
1733
1835
  fallbackText: `🔓 Logged out.\n\n${result.message}`,
@@ -1928,16 +2030,19 @@ export function createBot(config, registry) {
1928
2030
  });
1929
2031
  });
1930
2032
  bot.command(["status", "health"], async (ctx) => {
1931
- const health = await getConnectorHealth();
1932
- 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);
1933
2038
  const html = renderHealthHTML(health, authStatus.authenticated, getUserRole(ctx));
1934
2039
  const plain = renderHealthPlain(health, authStatus.authenticated, getUserRole(ctx));
1935
2040
  await safeReply(ctx, html, { fallbackText: plain });
1936
2041
  });
1937
2042
  bot.command("version", async (ctx) => {
1938
- const health = await getConnectorHealth();
2043
+ const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
1939
2044
  const state = await readConnectorState();
1940
- const versions = await getVersionChecks({ piCliPath: config.piCliPath });
2045
+ const versions = await getVersionChecks({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
1941
2046
  const plain = [
1942
2047
  renderVersionCheckPlain(versions.nordrelay),
1943
2048
  `Runtime status: ${state.status ?? "unknown"}`,
@@ -1945,6 +2050,12 @@ export function createBot(config, registry) {
1945
2050
  renderVersionCheckPlain(versions.codex),
1946
2051
  formatCliPathPlain("Pi CLI", health.piCliPath, health.piCli),
1947
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),
1948
2059
  ].join("\n");
1949
2060
  const html = [
1950
2061
  renderVersionCheckHTML(versions.nordrelay),
@@ -1953,6 +2064,12 @@ export function createBot(config, registry) {
1953
2064
  renderVersionCheckHTML(versions.codex),
1954
2065
  formatCliPathHTML("Pi CLI", health.piCliPath, health.piCli),
1955
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),
1956
2073
  ].join("\n");
1957
2074
  await safeReply(ctx, html, { fallbackText: plain });
1958
2075
  });
@@ -1990,10 +2107,10 @@ export function createBot(config, registry) {
1990
2107
  return;
1991
2108
  }
1992
2109
  const options = parseActivityOptions((ctx.message?.text ?? "").replace(/^\/activity(?:@\w+)?\s*/i, "").trim());
1993
- 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);
1994
2111
  const rendered = renderActivityTimeline(threadId, events, options);
1995
2112
  if (options.exportFile && ctx.chat) {
1996
- 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`);
1997
2114
  await writeFile(exportPath, rendered.plain, "utf8");
1998
2115
  try {
1999
2116
  await telegramRateLimiter.run(chatBucket(ctx.chat.id), "sendDocument", () => ctx.api.sendDocument(ctx.chat.id, new InputFile(exportPath, path.basename(exportPath)), {
@@ -2063,15 +2180,17 @@ export function createBot(config, registry) {
2063
2180
  await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2064
2181
  });
2065
2182
  bot.command("diagnostics", async (ctx) => {
2066
- const health = await getConnectorHealth();
2067
- const authStatus = await checkAuthStatus(config.codexApiKey);
2183
+ const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
2068
2184
  const contextKey = contextKeyFromCtx(ctx);
2069
2185
  const queueLength = contextKey ? promptStore.list(contextKey).length : 0;
2070
2186
  const progress = contextKey ? turnProgress.get(contextKey) : undefined;
2071
2187
  const contextSession = contextKey ? await getContextSession(ctx, { deferThreadStart: true }) : null;
2072
- const rolloutDiagnostics = contextSession && capabilitiesOf(contextSession.session.getInfo()).externalActivity
2073
- ? renderRolloutDiagnostics(contextSession.session.getActiveThreadId(), config.codexExternalBusyStaleMs)
2074
- : { 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>" };
2075
2194
  const runtime = {
2076
2195
  rateLimit: getTelegramRateLimitMetrics(),
2077
2196
  externalMirrors: externalMirrors.size,
@@ -2084,8 +2203,8 @@ export function createBot(config, registry) {
2084
2203
  voiceLanguage: contextKey ? getEffectiveVoiceLanguage(contextKey) ?? "auto" : config.voiceDefaultLanguage ?? "auto",
2085
2204
  voiceTranscribeOnly: contextKey ? isVoiceTranscribeOnly(contextKey) : config.voiceTranscribeOnly,
2086
2205
  };
2087
- const plain = `${renderDiagnosticsPlain(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${rolloutDiagnostics.plain}`;
2088
- 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}`;
2089
2208
  await safeReply(ctx, html, { fallbackText: plain });
2090
2209
  });
2091
2210
  bot.command("sync", async (ctx) => {
@@ -2100,7 +2219,7 @@ export function createBot(config, registry) {
2100
2219
  await safeReply(ctx, html, { fallbackText: plain });
2101
2220
  return;
2102
2221
  }
2103
- const result = contextSession.session.syncFromCodexState({ reattach: true });
2222
+ const result = contextSession.session.syncFromAgentState({ reattach: true });
2104
2223
  if (result.changed) {
2105
2224
  updateSessionMetadata(contextSession.contextKey, contextSession.session);
2106
2225
  }
@@ -2227,8 +2346,14 @@ export function createBot(config, registry) {
2227
2346
  if (!contextSession) {
2228
2347
  return;
2229
2348
  }
2230
- const { session } = contextSession;
2349
+ const { contextKey, session } = contextSession;
2231
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
+ }
2232
2357
  await session.abort();
2233
2358
  await safeReply(ctx, escapeHTML("Aborted current operation"), {
2234
2359
  fallbackText: "Aborted current operation",
@@ -2533,28 +2658,29 @@ export function createBot(config, registry) {
2533
2658
  });
2534
2659
  return;
2535
2660
  }
2536
- const selectedLaunchProfile = session.getSelectedLaunchProfile();
2537
- const launchButtons = config.launchProfiles.map((profile, index) => ({
2538
- 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),
2539
2665
  callbackData: `launch_${index}`,
2540
2666
  }));
2541
- pendingLaunchPicks.set(contextKey, config.launchProfiles.map((profile) => profile.id));
2667
+ pendingLaunchPicks.set(contextKey, profiles.map((profile) => profile.id));
2542
2668
  pendingLaunchButtons.set(contextKey, launchButtons);
2543
2669
  pendingUnsafeLaunchConfirmations.delete(contextKey);
2544
2670
  const keyboard = paginateKeyboard(launchButtons, 0, "launch");
2545
2671
  const htmlLines = [
2546
- `<b>Selected launch profile:</b> <code>${escapeHTML(selectedLaunchProfile.label)}</code>`,
2547
- `<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>`,
2548
2674
  "",
2549
2675
  "Select a profile for new or reattached threads:",
2550
2676
  ];
2551
2677
  const plainLines = [
2552
- `Selected launch profile: ${selectedLaunchProfile.label}`,
2553
- `Behavior: ${formatLaunchProfileBehavior(selectedLaunchProfile)}`,
2678
+ `Selected launch profile: ${selectedLaunchProfile.launchProfileLabel}`,
2679
+ `Behavior: ${selectedLaunchProfile.launchProfileBehavior}`,
2554
2680
  "",
2555
2681
  "Select a profile for new or reattached threads:",
2556
2682
  ];
2557
- if (selectedLaunchProfile.unsafe) {
2683
+ if (selectedLaunchProfile.unsafeLaunch) {
2558
2684
  htmlLines.splice(2, 0, "⚠️ <i>Selected profile uses danger-full-access.</i>");
2559
2685
  plainLines.splice(2, 0, "⚠️ Selected profile uses danger-full-access.");
2560
2686
  }
@@ -2894,6 +3020,10 @@ export function createBot(config, registry) {
2894
3020
  });
2895
3021
  return;
2896
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
+ });
2897
3027
  const models = session.listModels();
2898
3028
  if (models.length === 0) {
2899
3029
  await safeReply(ctx, escapeHTML("No models available."), {
@@ -2903,7 +3033,7 @@ export function createBot(config, registry) {
2903
3033
  }
2904
3034
  const currentModel = session.getInfo().model ?? "(default)";
2905
3035
  const modelButtons = models.map((model) => ({
2906
- label: `${model.displayName}${model.slug === currentModel ? " ✓" : ""}`,
3036
+ label: formatModelButtonLabel(model, model.slug === currentModel),
2907
3037
  callbackData: `model_${model.slug}`,
2908
3038
  }));
2909
3039
  pendingModelButtons.set(contextKey, modelButtons);
@@ -2990,7 +3120,7 @@ export function createBot(config, registry) {
2990
3120
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2991
3121
  return;
2992
3122
  }
2993
- const efforts = idOf(info) === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
3123
+ const efforts = agentReasoningOptions(idOf(info));
2994
3124
  const current = info.reasoningEffort;
2995
3125
  const effortButtons = efforts.map((effort) => ({
2996
3126
  label: effort === current ? `${effort} ✓` : effort,
@@ -3008,7 +3138,7 @@ export function createBot(config, registry) {
3008
3138
  });
3009
3139
  };
3010
3140
  bot.command(["effort", "reasoning"], openReasoningPicker);
3011
- bot.callbackQuery(/^agent_(codex|pi)$/, async (ctx) => {
3141
+ bot.callbackQuery(/^agent_(codex|pi|hermes|openclaw|claude-code)$/, async (ctx) => {
3012
3142
  const chatId = ctx.chat?.id;
3013
3143
  const messageId = ctx.callbackQuery.message?.message_id;
3014
3144
  const selectedAgent = ctx.match?.[1];
@@ -3362,7 +3492,7 @@ export function createBot(config, registry) {
3362
3492
  await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3363
3493
  return;
3364
3494
  }
3365
- const profile = findLaunchProfile(config.launchProfiles, profileId);
3495
+ const profile = session.listLaunchProfiles().find((candidate) => candidate.id === profileId);
3366
3496
  if (!profile) {
3367
3497
  clearLaunchSelectionState(contextKey);
3368
3498
  await ctx.answerCallbackQuery({ text: "Launch profile no longer exists" });
@@ -3379,14 +3509,14 @@ export function createBot(config, registry) {
3379
3509
  .text("Cancel", `launchconfirm_no:${profile.id}`);
3380
3510
  const html = [
3381
3511
  `<b>Confirm launch profile:</b> <code>${escapeHTML(profile.label)}</code>`,
3382
- `<b>Behavior:</b> <code>${escapeHTML(formatLaunchProfileBehavior(profile))}</code>`,
3512
+ `<b>Behavior:</b> <code>${escapeHTML(profile.behavior)}</code>`,
3383
3513
  "",
3384
3514
  "⚠️ <b>This profile uses danger-full-access.</b>",
3385
3515
  "It will apply to new or reattached threads in this Telegram context.",
3386
3516
  ].join("\n");
3387
3517
  const plain = [
3388
3518
  `Confirm launch profile: ${profile.label}`,
3389
- `Behavior: ${formatLaunchProfileBehavior(profile)}`,
3519
+ `Behavior: ${profile.behavior}`,
3390
3520
  "",
3391
3521
  "WARNING: This profile uses danger-full-access.",
3392
3522
  "It will apply to new or reattached threads in this Telegram context.",
@@ -3407,17 +3537,18 @@ export function createBot(config, registry) {
3407
3537
  }
3408
3538
  await ctx.answerCallbackQuery({ text: `Launch set to ${profile.label}` });
3409
3539
  clearLaunchSelectionState(contextKey);
3410
- const selectedProfile = session.setLaunchProfile(profile.id);
3540
+ session.setLaunchProfile(profile.id);
3411
3541
  updateSessionMetadata(contextKey, session);
3542
+ const info = session.getInfo();
3412
3543
  const html = [
3413
- `<b>Launch profile set to</b> <code>${escapeHTML(selectedProfile.label)}</code>`,
3414
- `<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>`,
3415
3546
  "",
3416
3547
  "Applies to new or reattached threads.",
3417
3548
  ].join("\n");
3418
3549
  const plain = [
3419
- `Launch profile set to ${selectedProfile.label}`,
3420
- `Behavior: ${formatLaunchProfileBehavior(selectedProfile)}`,
3550
+ `Launch profile set to ${info.launchProfileLabel}`,
3551
+ `Behavior: ${info.launchProfileBehavior}`,
3421
3552
  "",
3422
3553
  "Applies to new or reattached threads.",
3423
3554
  ].join("\n");
@@ -3458,7 +3589,7 @@ export function createBot(config, registry) {
3458
3589
  await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
3459
3590
  return;
3460
3591
  }
3461
- const profile = findLaunchProfile(config.launchProfiles, profileId);
3592
+ const profile = session.listLaunchProfiles().find((candidate) => candidate.id === profileId);
3462
3593
  if (!profile) {
3463
3594
  clearLaunchSelectionState(contextKey);
3464
3595
  await ctx.answerCallbackQuery({ text: "Launch profile no longer exists" });
@@ -3468,18 +3599,19 @@ export function createBot(config, registry) {
3468
3599
  return;
3469
3600
  }
3470
3601
  clearLaunchSelectionState(contextKey);
3471
- const selectedProfile = session.setLaunchProfile(profile.id);
3602
+ session.setLaunchProfile(profile.id);
3472
3603
  updateSessionMetadata(contextKey, session);
3473
- await ctx.answerCallbackQuery({ text: `Launch set to ${selectedProfile.label}` });
3604
+ const info = session.getInfo();
3605
+ await ctx.answerCallbackQuery({ text: `Launch set to ${info.launchProfileLabel}` });
3474
3606
  const html = [
3475
- `<b>Launch profile set to</b> <code>${escapeHTML(selectedProfile.label)}</code>`,
3476
- `<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>`,
3477
3609
  "",
3478
3610
  "⚠️ <i>danger-full-access confirmed for new or reattached threads.</i>",
3479
3611
  ].join("\n");
3480
3612
  const plain = [
3481
- `Launch profile set to ${selectedProfile.label}`,
3482
- `Behavior: ${formatLaunchProfileBehavior(selectedProfile)}`,
3613
+ `Launch profile set to ${info.launchProfileLabel}`,
3614
+ `Behavior: ${info.launchProfileBehavior}`,
3483
3615
  "",
3484
3616
  "danger-full-access confirmed for new or reattached threads.",
3485
3617
  ].join("\n");
@@ -3516,9 +3648,7 @@ export function createBot(config, registry) {
3516
3648
  try {
3517
3649
  const result = await session.setModelForCurrentSession(slug);
3518
3650
  updateSessionMetadata(contextKey, session);
3519
- const scope = result.appliedToActiveThread
3520
- ? "applied to the current idle thread and future threads"
3521
- : "applies to new threads";
3651
+ const scope = formatAgentSettingScope(session.getInfo(), result.appliedToActiveThread);
3522
3652
  const html = `<b>Model set to</b> <code>${escapeHTML(result.value)}</code> — ${escapeHTML(scope)}.`;
3523
3653
  const plainText = `Model set to ${result.value} — ${scope}.`;
3524
3654
  if (messageId) {
@@ -3539,7 +3669,7 @@ export function createBot(config, registry) {
3539
3669
  }
3540
3670
  }
3541
3671
  });
3542
- bot.callbackQuery(/^effort_(off|minimal|low|medium|high|xhigh)$/, async (ctx) => {
3672
+ bot.callbackQuery(/^effort_(off|none|minimal|low|medium|high|xhigh)$/, async (ctx) => {
3543
3673
  const chatId = ctx.chat?.id;
3544
3674
  const messageId = ctx.callbackQuery.message?.message_id;
3545
3675
  const effort = ctx.match?.[1];
@@ -3565,9 +3695,7 @@ export function createBot(config, registry) {
3565
3695
  const result = await session.setReasoningEffortForCurrentSession(effort);
3566
3696
  updateSessionMetadata(contextKey, session);
3567
3697
  const label = agentReasoningLabel(idOf(session.getInfo()));
3568
- const scope = result.appliedToActiveThread
3569
- ? "applied to the current idle thread and future threads"
3570
- : "applies to new threads";
3698
+ const scope = formatAgentSettingScope(session.getInfo(), result.appliedToActiveThread);
3571
3699
  const html = `⚡ ${escapeHTML(label)} set to <code>${escapeHTML(effort)}</code> — ${escapeHTML(scope)}.`;
3572
3700
  await safeEditMessage(bot, chatId, messageId, html, {
3573
3701
  fallbackText: `⚡ ${label} set to ${effort} — ${scope}.`,
@@ -3898,7 +4026,7 @@ export async function registerCommands(bot) {
3898
4026
  { command: "help", description: "Command reference" },
3899
4027
  { command: "channels", description: "Messaging adapter status" },
3900
4028
  { command: "agents", description: "Agent adapter status" },
3901
- { command: "agent", description: "Select Codex or Pi" },
4029
+ { command: "agent", description: "Select agent" },
3902
4030
  { command: "new", description: "Start a new thread" },
3903
4031
  { command: "session", description: "Current thread details" },
3904
4032
  { command: "sessions", description: "Browse & switch threads" },
@@ -4256,7 +4384,7 @@ function renderExternalMirrorStatus(snapshot, queueLength) {
4256
4384
  ? formatDurationSeconds((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
4257
4385
  : "-";
4258
4386
  const lines = [
4259
- "Codex CLI task running.",
4387
+ `${snapshot.agentLabel} CLI task running.`,
4260
4388
  `Thread: ${snapshot.threadId}`,
4261
4389
  `Elapsed: ${elapsed}`,
4262
4390
  `Prompt: ${prompt}`,
@@ -4266,7 +4394,7 @@ function renderExternalMirrorStatus(snapshot, queueLength) {
4266
4394
  return {
4267
4395
  plain: lines.join("\n"),
4268
4396
  html: [
4269
- "<b>Codex CLI task running.</b>",
4397
+ `<b>${escapeHTML(snapshot.agentLabel)} CLI task running.</b>`,
4270
4398
  `<b>Thread:</b> <code>${escapeHTML(snapshot.threadId)}</code>`,
4271
4399
  `<b>Elapsed:</b> <code>${escapeHTML(elapsed)}</code>`,
4272
4400
  `<b>Prompt:</b> <code>${escapeHTML(prompt)}</code>`,
@@ -4299,8 +4427,8 @@ function renderExternalMirrorEvent(event) {
4299
4427
  function renderActivityTimeline(threadId, events, options = { limit: 16, filter: "all", exportFile: false }) {
4300
4428
  if (events.length === 0) {
4301
4429
  return {
4302
- plain: `Activity:\nThread: ${threadId}\nFilter: ${options.filter}\nNo rollout events found.`,
4303
- 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>`,
4304
4432
  };
4305
4433
  }
4306
4434
  const lines = events.map((event) => {
@@ -4376,50 +4504,36 @@ function filterActivityEvents(events, options) {
4376
4504
  function isActivityFilter(value) {
4377
4505
  return value === "all" || value === "tools" || value === "errors" || value === "user" || value === "agent" || value === "tasks";
4378
4506
  }
4379
- function renderRolloutDiagnostics(threadId, staleAfterMs) {
4380
- if (!threadId) {
4381
- return {
4382
- plain: "Rollout: no active thread",
4383
- html: "<b>Rollout:</b> <code>no active thread</code>",
4384
- };
4385
- }
4386
- const snapshot = getThreadRolloutSnapshot(threadId, { staleAfterMs, maxEvents: 0 });
4387
- if (!snapshot) {
4388
- return {
4389
- plain: `Rollout:\nThread: ${threadId}\nStatus: unavailable`,
4390
- html: [
4391
- "<b>Rollout:</b>",
4392
- `<b>Thread:</b> <code>${escapeHTML(threadId)}</code>`,
4393
- "<b>Status:</b> <code>unavailable</code>",
4394
- ].join("\n"),
4395
- };
4396
- }
4397
- const activity = snapshot.activity;
4398
- const status = activity.active ? "active" : activity.stale ? "stale" : "idle";
4399
- const reason = activity.active
4400
- ? "open task without terminal event"
4401
- : activity.stale
4402
- ? "open task exceeded stale timeout"
4403
- : "no open task";
4404
- const lines = [
4405
- "Rollout:",
4406
- `Path: ${snapshot.rolloutPath}`,
4407
- `Status: ${status}`,
4408
- `Reason: ${reason}`,
4409
- `Turn: ${activity.turnId ?? "-"}`,
4410
- `Lines: ${snapshot.lineCount}`,
4411
- `Updated: ${activity.updatedAt?.toISOString() ?? "-"}`,
4412
- ];
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) {
4413
4529
  return {
4414
- plain: lines.join("\n"),
4530
+ plain: [
4531
+ `${diagnostics.agentLabel} state:`,
4532
+ ...diagnostics.lines.map((line) => `${line.label}: ${line.value}`),
4533
+ ].join("\n"),
4415
4534
  html: [
4416
- "<b>Rollout:</b>",
4417
- `<b>Path:</b> <code>${escapeHTML(snapshot.rolloutPath)}</code>`,
4418
- `<b>Status:</b> <code>${escapeHTML(status)}</code>`,
4419
- `<b>Reason:</b> <code>${escapeHTML(reason)}</code>`,
4420
- `<b>Turn:</b> <code>${escapeHTML(activity.turnId ?? "-")}</code>`,
4421
- `<b>Lines:</b> <code>${snapshot.lineCount}</code>`,
4422
- `<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>`),
4423
4537
  ].join("\n"),
4424
4538
  };
4425
4539
  }
@@ -4459,6 +4573,11 @@ function renderDiagnosticsPlain(config, registry, health, authenticated, role, q
4459
4573
  `Telegram transport: ${config.telegramTransport}`,
4460
4574
  `Codex CLI: ${health.codexCli}`,
4461
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}`,
4462
4581
  `Enabled agents/default: ${enabledAgents(config).join(", ")} / ${config.defaultAgent}`,
4463
4582
  `State DB: ${health.databasePath ?? "-"}`,
4464
4583
  `Log file: ${health.logFile}`,
@@ -4499,6 +4618,11 @@ function renderDiagnosticsHTML(config, registry, health, authenticated, role, qu
4499
4618
  `<b>Telegram transport:</b> <code>${escapeHTML(config.telegramTransport)}</code>`,
4500
4619
  `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4501
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>`,
4502
4626
  `<b>Enabled agents/default:</b> <code>${escapeHTML(`${enabledAgents(config).join(", ")} / ${config.defaultAgent}`)}</code>`,
4503
4627
  `<b>State DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
4504
4628
  `<b>Log file:</b> <code>${escapeHTML(health.logFile)}</code>`,
@@ -4536,6 +4660,9 @@ function renderHealthPlain(health, authenticated, role) {
4536
4660
  `Workspace: ${health.state.workspace ?? "-"}`,
4537
4661
  `Codex CLI: ${health.codexCli}`,
4538
4662
  `Pi CLI: ${health.piCli}`,
4663
+ `Hermes CLI: ${health.hermesCli}`,
4664
+ `OpenClaw CLI: ${health.openClawCli}`,
4665
+ `Claude Code CLI: ${health.claudeCodeCli}`,
4539
4666
  `Codex state DB: ${health.databasePath ?? "-"}`,
4540
4667
  `Log: ${health.logFile}`,
4541
4668
  ].join("\n");
@@ -4552,6 +4679,9 @@ function renderHealthHTML(health, authenticated, role) {
4552
4679
  `<b>Workspace:</b> <code>${escapeHTML(health.state.workspace ?? "-")}</code>`,
4553
4680
  `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4554
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>`,
4555
4685
  `<b>Codex state DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
4556
4686
  `<b>Log:</b> <code>${escapeHTML(health.logFile)}</code>`,
4557
4687
  ].join("\n");
@@ -4619,6 +4749,48 @@ function labelOf(info) {
4619
4749
  function idOf(info) {
4620
4750
  return info.agentId ?? "codex";
4621
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
+ }
4622
4794
  function requiresTurnApproval(info) {
4623
4795
  return info.unsafeLaunch || info.approvalPolicy !== "never";
4624
4796
  }