@nordbyte/nordrelay 0.5.2 → 0.6.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 +63 -11
  2. package/README.md +90 -19
  3. package/dist/access-control.js +1 -0
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-rendering.js +10 -7
  7. package/dist/bot.js +458 -5
  8. package/dist/channel-actions.js +7 -2
  9. package/dist/channel-adapter.js +34 -7
  10. package/dist/channel-command-service.js +156 -0
  11. package/dist/channel-turn-service.js +237 -0
  12. package/dist/config-metadata.js +78 -13
  13. package/dist/config.js +77 -7
  14. package/dist/context-key.js +77 -5
  15. package/dist/discord-artifacts.js +165 -0
  16. package/dist/discord-bot.js +2014 -0
  17. package/dist/discord-channel-runtime.js +133 -0
  18. package/dist/discord-command-surface.js +119 -0
  19. package/dist/discord-rate-limit.js +141 -0
  20. package/dist/index.js +16 -5
  21. package/dist/job-store.js +127 -0
  22. package/dist/metrics.js +41 -0
  23. package/dist/relay-external-activity-monitor.js +47 -6
  24. package/dist/relay-runtime.js +986 -281
  25. package/dist/runtime-cache.js +57 -0
  26. package/dist/session-locks.js +10 -7
  27. package/dist/support-bundle.js +1 -0
  28. package/dist/telegram-access-commands.js +15 -2
  29. package/dist/telegram-access-middleware.js +16 -3
  30. package/dist/telegram-agent-commands.js +25 -0
  31. package/dist/telegram-artifact-commands.js +46 -0
  32. package/dist/telegram-diagnostics-command.js +5 -50
  33. package/dist/telegram-general-commands.js +2 -6
  34. package/dist/telegram-operational-commands.js +14 -6
  35. package/dist/telegram-queue-commands.js +74 -4
  36. package/dist/telegram-support-command.js +7 -0
  37. package/dist/telegram-update-commands.js +27 -0
  38. package/dist/user-management.js +208 -0
  39. package/dist/web-api-contract.js +9 -0
  40. package/dist/web-dashboard-access-routes.js +74 -1
  41. package/dist/web-dashboard-artifact-routes.js +3 -3
  42. package/dist/web-dashboard-assets.js +2 -0
  43. package/dist/web-dashboard-pages.js +97 -13
  44. package/dist/web-dashboard-runtime-routes.js +53 -8
  45. package/dist/web-dashboard-session-routes.js +27 -20
  46. package/dist/web-dashboard-ui.js +1 -0
  47. package/dist/web-dashboard.js +148 -6
  48. package/dist/web-state.js +33 -2
  49. package/dist/webui-assets/dashboard.css +75 -1
  50. package/dist/webui-assets/dashboard.js +358 -47
  51. package/package.json +3 -1
  52. package/plugins/nordrelay/scripts/nordrelay.mjs +210 -17
package/dist/bot.js CHANGED
@@ -11,6 +11,7 @@ import { AuditLogStore } from "./audit-log.js";
11
11
  import { formatSessionLabel } from "./bot-ui.js";
12
12
  import { BotPreferencesStore, isQuietNow, } from "./bot-preferences.js";
13
13
  import { renderAgentUpdateJobAction } from "./channel-actions.js";
14
+ import { ChannelCommandService } from "./channel-command-service.js";
14
15
  import { deliverChannelAction } from "./channel-runtime.js";
15
16
  import { agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
16
17
  import { getExternalActivityForSession, getExternalSnapshotForSession, } from "./agent-activity.js";
@@ -45,6 +46,7 @@ import { registerTelegramSupportCommands } from "./telegram-support-command.js";
45
46
  import { registerTelegramUpdateCommands } from "./telegram-update-commands.js";
46
47
  import { appendWithCap, authHelpText, buildStreamingPreview, capabilitiesOf, filterSessions, formatAgentLaunchProfileLabel, formatAgentSettingScope, formatDurationSeconds, formatError, formatLocalDateTime, formatLockOwner, formatModelButtonLabel, formatRelativeTime, formatTelegramName, formatToolSummaryLine, formatTurnUsageLine, getWorkspaceShortName, idOf, isEmptyArtifactReport, isPromptEnvelopeLike, isQueuedPromptLike, labelOf, orderPinnedSessions, parseFastModeArgument, renderExternalMirrorEvent, renderExternalMirrorStatus, renderPromptFailure, renderTodoList, renderToolEndMessage, renderToolStartMessage, requiresTurnApproval, trimLine, } from "./bot-rendering.js";
47
48
  import { UserStore } from "./user-management.js";
49
+ import { WebActivityStore } from "./web-state.js";
48
50
  import { evaluateWorkspacePolicy, filterAllowedWorkspaces, renderWorkspacePolicyLine, } from "./workspace-policy.js";
49
51
  export { formatToolSummaryLine, formatTurnUsageLine, summarizeToolName } from "./bot-rendering.js";
50
52
  export { registerCommands } from "./telegram-command-menu.js";
@@ -54,6 +56,10 @@ const TOOL_OUTPUT_PREVIEW_LIMIT = 500;
54
56
  const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
55
57
  const MEDIA_GROUP_FLUSH_MS = 1200;
56
58
  const LAUNCH_PROFILES_COMMAND = "/launch_profiles";
59
+ const CLI_ACTIVITY_ACTOR = {
60
+ channel: "cli",
61
+ label: "CLI",
62
+ };
57
63
  export function createBot(config, registry) {
58
64
  configureRedaction(config.telegramRedactPatterns);
59
65
  telegramRateLimiter.configure({
@@ -80,11 +86,17 @@ export function createBot(config, registry) {
80
86
  const turnProgress = new Map();
81
87
  const promptStore = new PromptStore(config.workspace, config.stateBackend);
82
88
  const preferencesStore = new BotPreferencesStore(config.workspace, config.stateBackend);
89
+ const activityStore = new WebActivityStore(config.workspace, config.stateBackend, config.auditMaxEvents);
83
90
  const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
84
91
  const lockStore = new SessionLockStore(config.workspace, config.stateBackend);
85
92
  const userStore = new UserStore();
86
93
  const contextUsers = new WeakMap();
87
- const agentUpdates = new AgentUpdateManager();
94
+ const agentUpdateActors = new Map();
95
+ const agentUpdateStates = new Map();
96
+ const commandService = new ChannelCommandService(config);
97
+ const agentUpdates = new AgentUpdateManager({
98
+ onUpdate: (job) => recordTelegramAgentUpdateLifecycle(job),
99
+ });
88
100
  const linkAttempts = new Map();
89
101
  const drainingQueues = new Set();
90
102
  const externalQueueTimers = new Map();
@@ -228,6 +240,19 @@ export function createBot(config, registry) {
228
240
  const startTelegramAgentUpdate = async (ctx, agentId, operation = "update") => {
229
241
  try {
230
242
  const job = agentUpdates.start(agentId, agentUpdateContext(), operation);
243
+ const actor = telegramActivityActor(ctx);
244
+ agentUpdateActors.set(job.id, actor);
245
+ agentUpdateStates.set(job.id, { status: job.status, needsInput: job.needsInput });
246
+ appendActivity({
247
+ source: "telegram",
248
+ status: "info",
249
+ type: operation === "install" ? "agent_install_started" : "agent_update_started",
250
+ threadId: null,
251
+ workspace: config.workspace,
252
+ agentId,
253
+ actor,
254
+ detail: `${job.method}: ${job.summary}`,
255
+ });
231
256
  const contextKey = contextKeyFromCtx(ctx);
232
257
  if (contextKey) {
233
258
  audit({
@@ -235,6 +260,9 @@ export function createBot(config, registry) {
235
260
  status: "ok",
236
261
  contextKey,
237
262
  agentId,
263
+ actor,
264
+ actorId: getAuthenticatedUser(ctx)?.user.id ?? ctx.from?.id,
265
+ actorRole: getUserRole(ctx),
238
266
  description: `${operation} ${agentId}`,
239
267
  detail: job.summary,
240
268
  });
@@ -495,6 +523,26 @@ export function createBot(config, registry) {
495
523
  if (snapshot.activity.active) {
496
524
  state.turnId = snapshot.activity.turnId;
497
525
  state.startedAt = snapshot.activity.startedAt;
526
+ const turnKey = snapshot.activity.turnId ?? snapshot.activity.startedAt?.toISOString() ?? "unknown";
527
+ if (state.activityStartedTurnKey !== turnKey) {
528
+ const info = session.getInfo();
529
+ appendActivity({
530
+ source: "cli",
531
+ status: "running",
532
+ type: "cli_turn_started",
533
+ contextKey,
534
+ threadId: snapshot.threadId,
535
+ workspace: info.workspace,
536
+ agentId: info.agentId,
537
+ actor: CLI_ACTIVITY_ACTOR,
538
+ prompt: snapshot.latestUserMessage ?? `${snapshot.agentLabel} CLI task`,
539
+ detail: `${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
540
+ });
541
+ state.activityStartedTurnKey = turnKey;
542
+ state.activityFinishedTurnKey = undefined;
543
+ state.activityToolStartLines = [];
544
+ state.activityToolEndLines = [];
545
+ }
498
546
  if (mirrorMode !== "off") {
499
547
  await sendExternalMirrorTyping(chatId, parsed.messageThreadId, state);
500
548
  }
@@ -542,6 +590,43 @@ export function createBot(config, registry) {
542
590
  state.latestMirroredEventLine = event.lineNumber;
543
591
  }
544
592
  }
593
+ const info = session.getInfo();
594
+ const loggedStartLines = new Set(state.activityToolStartLines ?? []);
595
+ const loggedEndLines = new Set(state.activityToolEndLines ?? []);
596
+ for (const event of snapshot.events.filter((event) => event.lineNumber > state.lastLine && event.kind === "tool")) {
597
+ if (event.status === "started" && !loggedStartLines.has(event.lineNumber)) {
598
+ appendActivity({
599
+ source: "cli",
600
+ status: "running",
601
+ type: "cli_tool_started",
602
+ contextKey,
603
+ threadId: snapshot.threadId,
604
+ workspace: info.workspace,
605
+ agentId: info.agentId,
606
+ actor: CLI_ACTIVITY_ACTOR,
607
+ prompt: snapshot.latestUserMessage ?? undefined,
608
+ detail: event.toolName ?? "tool",
609
+ });
610
+ loggedStartLines.add(event.lineNumber);
611
+ }
612
+ if ((event.status === "finished" || event.status === "failed") && !loggedEndLines.has(event.lineNumber)) {
613
+ appendActivity({
614
+ source: "cli",
615
+ status: event.status === "failed" ? "failed" : "completed",
616
+ type: event.status === "failed" ? "cli_tool_failed" : "cli_tool_completed",
617
+ contextKey,
618
+ threadId: snapshot.threadId,
619
+ workspace: info.workspace,
620
+ agentId: info.agentId,
621
+ actor: CLI_ACTIVITY_ACTOR,
622
+ prompt: snapshot.latestUserMessage ?? undefined,
623
+ detail: event.toolName ?? "tool",
624
+ });
625
+ loggedEndLines.add(event.lineNumber);
626
+ }
627
+ }
628
+ state.activityToolStartLines = [...loggedStartLines].slice(-200);
629
+ state.activityToolEndLines = [...loggedEndLines].slice(-200);
545
630
  state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
546
631
  return;
547
632
  }
@@ -551,6 +636,25 @@ export function createBot(config, registry) {
551
636
  }
552
637
  const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
553
638
  if (terminalEvent) {
639
+ const turnKey = terminalEvent.turnId ?? snapshot.activity.turnId ?? state.startedAt?.toString() ?? "unknown";
640
+ if (state.activityFinishedTurnKey !== turnKey) {
641
+ const info = session.getInfo();
642
+ const startedAt = state.startedAt instanceof Date ? state.startedAt : state.startedAt ? new Date(state.startedAt) : snapshot.activity.startedAt;
643
+ appendActivity({
644
+ source: "cli",
645
+ status: terminalEvent.status === "aborted" ? "aborted" : terminalEvent.status === "failed" ? "failed" : "completed",
646
+ type: "cli_turn_finished",
647
+ contextKey,
648
+ threadId: snapshot.threadId,
649
+ workspace: info.workspace,
650
+ agentId: info.agentId,
651
+ actor: CLI_ACTIVITY_ACTOR,
652
+ prompt: snapshot.latestUserMessage ?? undefined,
653
+ detail: `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`,
654
+ durationMs: startedAt && terminalEvent.timestamp ? Math.max(0, terminalEvent.timestamp.getTime() - startedAt.getTime()) : undefined,
655
+ });
656
+ state.activityFinishedTurnKey = turnKey;
657
+ }
554
658
  if (mirrorMode !== "off") {
555
659
  const doneText = `${snapshot.agentLabel} CLI task ${terminalEvent.status}.`;
556
660
  if (state.statusMessageId) {
@@ -637,6 +741,18 @@ export function createBot(config, registry) {
637
741
  for (const artifact of (persistedReport?.artifacts ?? report.artifacts)) {
638
742
  await sendArtifactFileByApi(bot.api, chatId, artifact, messageThreadId);
639
743
  }
744
+ const info = session.getInfo();
745
+ appendActivity({
746
+ source: "cli",
747
+ status: "info",
748
+ type: "artifacts_sent",
749
+ contextKey,
750
+ threadId: info.threadId,
751
+ workspace: info.workspace,
752
+ agentId: info.agentId,
753
+ actor: CLI_ACTIVITY_ACTOR,
754
+ detail: summary,
755
+ });
640
756
  if (state)
641
757
  state.artifactsDeliveredForTurnId = turnId;
642
758
  };
@@ -688,9 +804,11 @@ export function createBot(config, registry) {
688
804
  };
689
805
  const auditContext = (ctx, contextKey, session, patch) => {
690
806
  const info = session.getInfo();
807
+ const authUser = getAuthenticatedUser(ctx);
691
808
  audit({
692
809
  contextKey,
693
- actorId: ctx.from?.id,
810
+ actor: telegramActivityActor(ctx),
811
+ actorId: authUser?.user.id ?? ctx.from?.id,
694
812
  actorRole: getUserRole(ctx),
695
813
  agentId: idOf(info),
696
814
  threadId: info.threadId,
@@ -698,10 +816,68 @@ export function createBot(config, registry) {
698
816
  ...patch,
699
817
  });
700
818
  };
819
+ function telegramActivityActor(ctx) {
820
+ const user = ctx.from;
821
+ const authUser = getAuthenticatedUser(ctx);
822
+ const label = authUser?.user.displayName || formatTelegramName(ctx) || user?.username || (user?.id ? String(user.id) : "Telegram user");
823
+ return {
824
+ channel: "telegram",
825
+ id: authUser?.user.id ?? (user?.id !== undefined ? `telegram:${user.id}` : undefined),
826
+ label,
827
+ username: authUser?.user.email ?? user?.username,
828
+ channelUserId: user?.id !== undefined ? String(user.id) : undefined,
829
+ };
830
+ }
831
+ function appendActivity(input) {
832
+ return activityStore.append(input);
833
+ }
834
+ function appendTelegramActivity(ctx, contextKey, session, input) {
835
+ const info = session.getInfo();
836
+ return appendActivity({
837
+ source: "telegram",
838
+ contextKey,
839
+ ...input,
840
+ threadId: input.threadId ?? info.threadId,
841
+ workspace: input.workspace ?? info.workspace,
842
+ agentId: input.agentId ?? idOf(info),
843
+ actor: input.actor ?? telegramActivityActor(ctx),
844
+ });
845
+ }
846
+ function recordTelegramAgentUpdateLifecycle(job) {
847
+ const previous = agentUpdateStates.get(job.id);
848
+ const actor = agentUpdateActors.get(job.id);
849
+ if (job.needsInput && !previous?.needsInput) {
850
+ appendActivity({
851
+ source: "telegram",
852
+ status: "info",
853
+ type: "agent_update_input_required",
854
+ threadId: null,
855
+ workspace: config.workspace,
856
+ agentId: job.agentId,
857
+ actor,
858
+ detail: `${job.agentLabel} ${job.operation} may require input.`,
859
+ });
860
+ }
861
+ if (job.status !== "running" && previous?.status === "running") {
862
+ appendActivity({
863
+ source: "telegram",
864
+ status: job.status === "completed" ? "completed" : job.status === "cancelled" ? "aborted" : "failed",
865
+ type: job.operation === "install" ? `agent_install_${job.status}` : `agent_update_${job.status}`,
866
+ threadId: null,
867
+ workspace: config.workspace,
868
+ agentId: job.agentId,
869
+ actor,
870
+ detail: job.error ?? `${job.agentLabel} ${job.operation} ${job.status}.`,
871
+ durationMs: Math.max(0, Date.parse(job.finishedAt ?? job.updatedAt) - Date.parse(job.startedAt)),
872
+ });
873
+ agentUpdateActors.delete(job.id);
874
+ }
875
+ agentUpdateStates.set(job.id, { status: job.status, needsInput: job.needsInput });
876
+ }
701
877
  const denyIfLocked = async (ctx, contextKey, session) => {
702
878
  const lock = lockStore.get(contextKey);
703
879
  const isAdmin = isAdminUser(ctx);
704
- if (canWriteWithLock(lock, ctx.from?.id, isAdmin)) {
880
+ if (canWriteWithLock(lock, getAuthenticatedUser(ctx)?.user.id, isAdmin)) {
705
881
  return false;
706
882
  }
707
883
  const owner = formatLockOwner(lock);
@@ -711,6 +887,11 @@ export function createBot(config, registry) {
711
887
  status: "denied",
712
888
  detail: text,
713
889
  });
890
+ appendTelegramActivity(ctx, contextKey, session, {
891
+ status: "failed",
892
+ type: "lock_denied",
893
+ detail: text,
894
+ });
714
895
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
715
896
  return true;
716
897
  };
@@ -809,7 +990,11 @@ export function createBot(config, registry) {
809
990
  }
810
991
  const parsed = parseContextKey(contextKey);
811
992
  const messageThreadId = parsed.messageThreadId;
812
- const envelope = isPromptEnvelopeLike(prompt) ? prompt : toPromptEnvelope(prompt);
993
+ const rawEnvelope = isPromptEnvelopeLike(prompt) ? prompt : toPromptEnvelope(prompt);
994
+ const envelope = {
995
+ ...rawEnvelope,
996
+ activityActor: rawEnvelope.activityActor ?? telegramActivityActor(ctx),
997
+ };
813
998
  if (!options.fromQueue && await denyIfLocked(ctx, contextKey, session)) {
814
999
  return;
815
1000
  }
@@ -840,6 +1025,13 @@ export function createBot(config, registry) {
840
1025
  description: item.description,
841
1026
  detail: busy.kind,
842
1027
  });
1028
+ appendTelegramActivity(ctx, contextKey, session, {
1029
+ status: "queued",
1030
+ type: "prompt_queued",
1031
+ prompt: item.description,
1032
+ detail: `Queued prompt ${item.id} at position ${position}; busy=${busy.kind}`,
1033
+ actor: envelope.activityActor,
1034
+ });
843
1035
  if (busy.kind === "external") {
844
1036
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
845
1037
  }
@@ -877,6 +1069,9 @@ export function createBot(config, registry) {
877
1069
  let lastRenderedPlan = "";
878
1070
  let planMessageSending = false;
879
1071
  let lastTurnUsage;
1072
+ let promptStartedAt;
1073
+ const toolActivityNames = new Map();
1074
+ const toolActivityStartedAt = new Map();
880
1075
  const typingInterval = setInterval(() => {
881
1076
  void sendChatActionSafe(bot.api, chatId, "typing", messageThreadId).catch(() => { });
882
1077
  }, TYPING_INTERVAL_MS);
@@ -1080,6 +1275,15 @@ export function createBot(config, registry) {
1080
1275
  progress.lastTool = toolName;
1081
1276
  progress.updatedAt = Date.now();
1082
1277
  progress.toolCounts.set(toolName, (progress.toolCounts.get(toolName) ?? 0) + 1);
1278
+ toolActivityNames.set(toolCallId, toolName);
1279
+ toolActivityStartedAt.set(toolCallId, Date.now());
1280
+ appendTelegramActivity(ctx, contextKey, session, {
1281
+ status: "running",
1282
+ type: "tool_started",
1283
+ prompt: envelope.description,
1284
+ detail: toolName,
1285
+ actor: envelope.activityActor,
1286
+ });
1083
1287
  if (toolVerbosity === "summary") {
1084
1288
  toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1);
1085
1289
  return;
@@ -1127,6 +1331,18 @@ export function createBot(config, registry) {
1127
1331
  onToolEnd: (toolCallId, isError) => {
1128
1332
  progress.currentTool = undefined;
1129
1333
  progress.updatedAt = Date.now();
1334
+ const activityToolName = toolActivityNames.get(toolCallId) ?? "tool";
1335
+ const activityStartedAt = toolActivityStartedAt.get(toolCallId);
1336
+ appendTelegramActivity(ctx, contextKey, session, {
1337
+ status: isError ? "failed" : "completed",
1338
+ type: isError ? "tool_failed" : "tool_completed",
1339
+ prompt: envelope.description,
1340
+ detail: activityToolName,
1341
+ actor: envelope.activityActor,
1342
+ durationMs: activityStartedAt ? Date.now() - activityStartedAt : undefined,
1343
+ });
1344
+ toolActivityNames.delete(toolCallId);
1345
+ toolActivityStartedAt.delete(toolCallId);
1130
1346
  if (toolVerbosity === "none" || toolVerbosity === "summary") {
1131
1347
  return;
1132
1348
  }
@@ -1266,11 +1482,25 @@ export function createBot(config, registry) {
1266
1482
  replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
1267
1483
  });
1268
1484
  await updateQueueStatusMessage(contextKey, `Waiting for ${label} CLI task... ${promptStore.list(contextKey).length} queued.`);
1485
+ appendTelegramActivity(ctx, contextKey, session, {
1486
+ status: "queued",
1487
+ type: "prompt_queued",
1488
+ prompt: item.description,
1489
+ detail: `Queued prompt ${item.id} at position 1; external ${label} CLI task active`,
1490
+ actor: envelope.activityActor,
1491
+ });
1269
1492
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
1270
1493
  turnProgress.delete(contextKey);
1271
1494
  return;
1272
1495
  }
1273
1496
  promptStore.setLastPrompt(contextKey, envelope);
1497
+ promptStartedAt = Date.now();
1498
+ appendTelegramActivity(ctx, contextKey, session, {
1499
+ status: "running",
1500
+ type: "prompt_started",
1501
+ prompt: envelope.description,
1502
+ actor: envelope.activityActor,
1503
+ });
1274
1504
  auditContext(ctx, contextKey, session, {
1275
1505
  action: "prompt_started",
1276
1506
  status: "ok",
@@ -1300,6 +1530,13 @@ export function createBot(config, registry) {
1300
1530
  status: "ok",
1301
1531
  description: envelope.description,
1302
1532
  });
1533
+ appendTelegramActivity(ctx, contextKey, session, {
1534
+ status: "completed",
1535
+ type: "prompt_completed",
1536
+ prompt: envelope.description,
1537
+ actor: envelope.activityActor,
1538
+ durationMs: promptStartedAt ? Date.now() - promptStartedAt : undefined,
1539
+ });
1303
1540
  }
1304
1541
  catch (error) {
1305
1542
  progress.status = "failed";
@@ -1310,6 +1547,16 @@ export function createBot(config, registry) {
1310
1547
  description: envelope.description,
1311
1548
  detail: progress.error,
1312
1549
  });
1550
+ if (promptStartedAt) {
1551
+ appendTelegramActivity(ctx, contextKey, session, {
1552
+ status: "failed",
1553
+ type: "prompt_failed",
1554
+ prompt: envelope.description,
1555
+ detail: progress.error,
1556
+ actor: envelope.activityActor,
1557
+ durationMs: Date.now() - promptStartedAt,
1558
+ });
1559
+ }
1313
1560
  progress.completedAt = Date.now();
1314
1561
  progress.updatedAt = progress.completedAt;
1315
1562
  stopTyping();
@@ -1400,6 +1647,15 @@ export function createBot(config, registry) {
1400
1647
  source: "turn",
1401
1648
  };
1402
1649
  await deliverArtifactReport(ctx, chatId, report, messageThreadId);
1650
+ const contextKey = contextKeyFromCtx(ctx);
1651
+ const session = contextKey ? registry.get(contextKey) : undefined;
1652
+ if (contextKey && session) {
1653
+ appendTelegramActivity(ctx, contextKey, session, {
1654
+ status: "info",
1655
+ type: "artifacts_sent",
1656
+ detail: formatArtifactSummary(report.artifacts, report.skippedCount, report.omittedCount),
1657
+ });
1658
+ }
1403
1659
  await pruneArtifacts(workspace);
1404
1660
  };
1405
1661
  const deliverArtifactReport = async (ctx, chatId, report, messageThreadId) => {
@@ -1596,6 +1852,11 @@ export function createBot(config, registry) {
1596
1852
  }
1597
1853
  const receivedText = `Received ${stagedFiles.length} media group file${stagedFiles.length === 1 ? "" : "s"}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}.`;
1598
1854
  await safeReply(pending.ctx, escapeHTML(receivedText), { fallbackText: receivedText });
1855
+ appendTelegramActivity(pending.ctx, pending.contextKey, pending.session, {
1856
+ status: "info",
1857
+ type: "attachment_staged",
1858
+ detail: receivedText,
1859
+ });
1599
1860
  await sendChatActionSafe(pending.ctx.api, pending.chatId, "typing", pending.messageThreadId).catch(() => { });
1600
1861
  const promptInput = {
1601
1862
  stagedFileInstructions: buildFileInstructions(stagedFiles, outDir),
@@ -1625,6 +1886,7 @@ export function createBot(config, registry) {
1625
1886
  checkAgentAuthStatus,
1626
1887
  isTopicContext,
1627
1888
  replyChannelAction,
1889
+ commandService,
1628
1890
  });
1629
1891
  registerTelegramAgentCommands({
1630
1892
  bot,
@@ -1641,6 +1903,13 @@ export function createBot(config, registry) {
1641
1903
  startAgentLogout,
1642
1904
  hostLoginCommand,
1643
1905
  hostLogoutCommand,
1906
+ appendActivity: (ctx, input) => appendActivity({
1907
+ source: "telegram",
1908
+ ...input,
1909
+ threadId: input.threadId ?? null,
1910
+ workspace: input.workspace ?? config.workspace,
1911
+ actor: input.actor ?? telegramActivityActor(ctx),
1912
+ }),
1644
1913
  });
1645
1914
  registerTelegramPreferenceCommands({
1646
1915
  bot,
@@ -1673,6 +1942,7 @@ export function createBot(config, registry) {
1673
1942
  getEffectiveVoiceLanguage,
1674
1943
  isVoiceTranscribeOnly,
1675
1944
  replyChannelAction,
1945
+ commandService,
1676
1946
  });
1677
1947
  registerTelegramOperationalCommands({
1678
1948
  bot,
@@ -1686,10 +1956,34 @@ export function createBot(config, registry) {
1686
1956
  getExternalActivity,
1687
1957
  isAdminUser,
1688
1958
  auditContext,
1959
+ getLockOwner: (ctx) => {
1960
+ const authUser = getAuthenticatedUser(ctx);
1961
+ if (!authUser) {
1962
+ return null;
1963
+ }
1964
+ return {
1965
+ userId: authUser.user.id,
1966
+ label: authUser.user.displayName || authUser.user.email,
1967
+ channel: "telegram",
1968
+ channelUserId: ctx.from?.id !== undefined ? String(ctx.from.id) : undefined,
1969
+ };
1970
+ },
1689
1971
  updateSessionMetadata,
1690
1972
  });
1691
1973
  registerTelegramSupportCommands({ bot, config, auditLog, agentUpdates, getUserRole, audit });
1692
- registerTelegramUpdateCommands({ bot, agentUpdates, replyChannelAction, startTelegramAgentUpdate });
1974
+ registerTelegramUpdateCommands({
1975
+ bot,
1976
+ agentUpdates,
1977
+ replyChannelAction,
1978
+ startTelegramAgentUpdate,
1979
+ appendActivity: (ctx, input) => appendActivity({
1980
+ source: "telegram",
1981
+ ...input,
1982
+ threadId: input.threadId ?? null,
1983
+ workspace: input.workspace ?? config.workspace,
1984
+ actor: input.actor ?? telegramActivityActor(ctx),
1985
+ }),
1986
+ });
1693
1987
  bot.command("new", async (ctx) => {
1694
1988
  const chatId = ctx.chat?.id;
1695
1989
  if (!chatId) {
@@ -1718,6 +2012,14 @@ export function createBot(config, registry) {
1718
2012
  try {
1719
2013
  const info = await session.newThread();
1720
2014
  updateSessionMetadata(contextKey, session);
2015
+ appendTelegramActivity(ctx, contextKey, session, {
2016
+ status: "info",
2017
+ type: "session_new",
2018
+ threadId: info.threadId,
2019
+ workspace: info.workspace,
2020
+ agentId: info.agentId,
2021
+ detail: info.workspace,
2022
+ });
1721
2023
  const label = isTopicContext(contextKey) ? "New thread created for this topic." : "New thread created.";
1722
2024
  const policyLine = renderWorkspacePolicyLine(info.workspace, config);
1723
2025
  const plainText = [label, policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
@@ -1755,12 +2057,22 @@ export function createBot(config, registry) {
1755
2057
  if (busy.kind === "external") {
1756
2058
  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.`;
1757
2059
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2060
+ appendTelegramActivity(ctx, contextKey, session, {
2061
+ status: "failed",
2062
+ type: "prompt_abort_rejected",
2063
+ detail: text,
2064
+ });
1758
2065
  return;
1759
2066
  }
1760
2067
  await session.abort();
1761
2068
  await safeReply(ctx, escapeHTML("Aborted current operation"), {
1762
2069
  fallbackText: "Aborted current operation",
1763
2070
  });
2071
+ appendTelegramActivity(ctx, contextKey, session, {
2072
+ status: "aborted",
2073
+ type: "prompt_aborted",
2074
+ detail: "Abort requested from Telegram.",
2075
+ });
1764
2076
  }
1765
2077
  catch (error) {
1766
2078
  await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
@@ -1809,6 +2121,8 @@ export function createBot(config, registry) {
1809
2121
  drainQueuedPrompts,
1810
2122
  handleUserPrompt,
1811
2123
  auditContext,
2124
+ activityActor: telegramActivityActor,
2125
+ appendActivity: appendTelegramActivity,
1812
2126
  });
1813
2127
  registerTelegramArtifactCommands({
1814
2128
  bot,
@@ -1816,6 +2130,13 @@ export function createBot(config, registry) {
1816
2130
  getContextSession,
1817
2131
  deliverArtifactReport,
1818
2132
  deliverArtifactReportZip,
2133
+ appendActivity: (ctx, input) => appendActivity({
2134
+ source: "telegram",
2135
+ ...input,
2136
+ threadId: input.threadId ?? null,
2137
+ workspace: input.workspace ?? config.workspace,
2138
+ actor: input.actor ?? telegramActivityActor(ctx),
2139
+ }),
1819
2140
  });
1820
2141
  bot.command("session", async (ctx) => {
1821
2142
  const contextSession = await getContextSession(ctx, { deferThreadStart: true });
@@ -1910,6 +2231,14 @@ export function createBot(config, registry) {
1910
2231
  try {
1911
2232
  const info = session.handback();
1912
2233
  updateSessionMetadata(contextKey, session);
2234
+ appendTelegramActivity(ctx, contextKey, session, {
2235
+ status: "info",
2236
+ type: "handback",
2237
+ threadId: info.threadId,
2238
+ workspace: info.workspace,
2239
+ agentId: idOf(session.getInfo()),
2240
+ detail: info.command ?? info.threadId ?? "handback",
2241
+ });
1913
2242
  if (!info.threadId) {
1914
2243
  await safeReply(ctx, escapeHTML("This thread has not started yet, so there is no resumable thread ID. Send a message to create one, or use /new to start fresh."), {
1915
2244
  fallbackText: "This thread has not started yet, so there is no resumable thread ID. Send a message to create one, or use /new to start fresh.",
@@ -2005,6 +2334,14 @@ export function createBot(config, registry) {
2005
2334
  try {
2006
2335
  const info = await session.switchSession(threadId);
2007
2336
  updateSessionMetadata(contextKey, session);
2337
+ appendTelegramActivity(ctx, contextKey, session, {
2338
+ status: "info",
2339
+ type: "session_attach",
2340
+ threadId: info.threadId,
2341
+ workspace: info.workspace,
2342
+ agentId: info.agentId,
2343
+ detail: threadId,
2344
+ });
2008
2345
  const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2009
2346
  const html = ["<b>Attached to thread.</b>", policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
2010
2347
  const plain = ["Attached to thread.", policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
@@ -2051,6 +2388,14 @@ export function createBot(config, registry) {
2051
2388
  try {
2052
2389
  const info = await session.switchSession(threadId);
2053
2390
  updateSessionMetadata(contextKey, session);
2391
+ appendTelegramActivity(ctx, contextKey, session, {
2392
+ status: "info",
2393
+ type: "session_switch",
2394
+ threadId: info.threadId,
2395
+ workspace: info.workspace,
2396
+ agentId: info.agentId,
2397
+ detail: threadId,
2398
+ });
2054
2399
  const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2055
2400
  const html = ["<b>Switched thread.</b>", policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
2056
2401
  const plain = ["Switched thread.", policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
@@ -2137,6 +2482,12 @@ export function createBot(config, registry) {
2137
2482
  return;
2138
2483
  }
2139
2484
  const pinned = registry.pinThread(contextKey, threadId);
2485
+ appendTelegramActivity(ctx, contextKey, session, {
2486
+ status: "info",
2487
+ type: "session_pinned",
2488
+ threadId,
2489
+ detail: threadId,
2490
+ });
2140
2491
  await safeReply(ctx, `<b>Pinned thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Total pinned:</b> <code>${pinned.length}</code>`, {
2141
2492
  fallbackText: `Pinned thread: ${threadId}\nTotal pinned: ${pinned.length}`,
2142
2493
  });
@@ -2157,6 +2508,12 @@ export function createBot(config, registry) {
2157
2508
  return;
2158
2509
  }
2159
2510
  const pinned = registry.unpinThread(contextKey, threadId);
2511
+ appendTelegramActivity(ctx, contextKey, session, {
2512
+ status: "info",
2513
+ type: "session_unpinned",
2514
+ threadId,
2515
+ detail: threadId,
2516
+ });
2160
2517
  await safeReply(ctx, `<b>Unpinned thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Total pinned:</b> <code>${pinned.length}</code>`, {
2161
2518
  fallbackText: `Unpinned thread: ${threadId}\nTotal pinned: ${pinned.length}`,
2162
2519
  });
@@ -2272,6 +2629,14 @@ export function createBot(config, registry) {
2272
2629
  const result = session.setFastMode(nextFastMode);
2273
2630
  updateSessionMetadata(contextKey, session);
2274
2631
  const info = session.getInfo();
2632
+ appendTelegramActivity(ctx, contextKey, session, {
2633
+ status: "info",
2634
+ type: "fast_mode_changed",
2635
+ threadId: info.threadId,
2636
+ workspace: info.workspace,
2637
+ agentId: info.agentId,
2638
+ detail: result.enabled ? "on" : "off",
2639
+ });
2275
2640
  const plain = [
2276
2641
  `Fast mode: ${result.enabled ? "on" : "off"}`,
2277
2642
  `Launch profile: ${result.profile.label} (${formatLaunchProfileBehavior(result.profile)})`,
@@ -2384,6 +2749,15 @@ export function createBot(config, registry) {
2384
2749
  });
2385
2750
  }
2386
2751
  const session = registry.get(pending.contextKey);
2752
+ if (session) {
2753
+ appendTelegramActivity(ctx, pending.contextKey, session, {
2754
+ status: "aborted",
2755
+ type: "prompt_approval_denied",
2756
+ prompt: pending.prompt.description,
2757
+ detail: approvalId,
2758
+ actor: pending.prompt.activityActor,
2759
+ });
2760
+ }
2387
2761
  if (chatId && session) {
2388
2762
  void drainQueuedPrompts(ctx, pending.contextKey, chatId, session).catch((error) => {
2389
2763
  console.error("Failed to drain queue after approval denial:", error);
@@ -2402,6 +2776,13 @@ export function createBot(config, registry) {
2402
2776
  fallbackText: `Approved prompt ${approvalId}.`,
2403
2777
  });
2404
2778
  }
2779
+ appendTelegramActivity(ctx, pending.contextKey, contextSession.session, {
2780
+ status: "info",
2781
+ type: "prompt_approval_approved",
2782
+ prompt: pending.prompt.description,
2783
+ detail: approvalId,
2784
+ actor: pending.prompt.activityActor,
2785
+ });
2405
2786
  await handleUserPrompt(ctx, pending.contextKey, chatId ?? parseContextKey(pending.contextKey).chatId, contextSession.session, pending.prompt, {
2406
2787
  approved: true,
2407
2788
  });
@@ -2445,6 +2826,14 @@ export function createBot(config, registry) {
2445
2826
  try {
2446
2827
  const info = await session.switchSession(threadId);
2447
2828
  updateSessionMetadata(contextKey, session);
2829
+ appendTelegramActivity(ctx, contextKey, session, {
2830
+ status: "info",
2831
+ type: "session_switch",
2832
+ threadId: info.threadId,
2833
+ workspace: info.workspace,
2834
+ agentId: info.agentId,
2835
+ detail: threadId,
2836
+ });
2448
2837
  const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2449
2838
  const plainText = ["Switched session.", policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
2450
2839
  const html = ["<b>Switched session.</b>", policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
@@ -2507,6 +2896,14 @@ export function createBot(config, registry) {
2507
2896
  try {
2508
2897
  const info = await session.newThread(workspace);
2509
2898
  updateSessionMetadata(contextKey, session);
2899
+ appendTelegramActivity(ctx, contextKey, session, {
2900
+ status: "info",
2901
+ type: "session_new",
2902
+ threadId: info.threadId,
2903
+ workspace: info.workspace,
2904
+ agentId: info.agentId,
2905
+ detail: workspace,
2906
+ });
2510
2907
  const label = isTopicContext(contextKey) ? "New thread created for this topic." : "New thread created.";
2511
2908
  const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2512
2909
  const plainText = [label, policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
@@ -2602,6 +2999,14 @@ export function createBot(config, registry) {
2602
2999
  session.setLaunchProfile(profile.id);
2603
3000
  updateSessionMetadata(contextKey, session);
2604
3001
  const info = session.getInfo();
3002
+ appendTelegramActivity(ctx, contextKey, session, {
3003
+ status: "info",
3004
+ type: "launch_profile_changed",
3005
+ threadId: info.threadId,
3006
+ workspace: info.workspace,
3007
+ agentId: info.agentId,
3008
+ detail: info.launchProfileLabel,
3009
+ });
2605
3010
  const html = [
2606
3011
  `<b>Launch profile set to</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`,
2607
3012
  `<b>Behavior:</b> <code>${escapeHTML(info.launchProfileBehavior)}</code>`,
@@ -2664,6 +3069,14 @@ export function createBot(config, registry) {
2664
3069
  session.setLaunchProfile(profile.id);
2665
3070
  updateSessionMetadata(contextKey, session);
2666
3071
  const info = session.getInfo();
3072
+ appendTelegramActivity(ctx, contextKey, session, {
3073
+ status: "info",
3074
+ type: "launch_profile_changed",
3075
+ threadId: info.threadId,
3076
+ workspace: info.workspace,
3077
+ agentId: info.agentId,
3078
+ detail: info.launchProfileLabel,
3079
+ });
2667
3080
  await ctx.answerCallbackQuery({ text: `Launch set to ${info.launchProfileLabel}` });
2668
3081
  const html = [
2669
3082
  `<b>Launch profile set to</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`,
@@ -2710,6 +3123,15 @@ export function createBot(config, registry) {
2710
3123
  try {
2711
3124
  const result = await session.setModelForCurrentSession(slug);
2712
3125
  updateSessionMetadata(contextKey, session);
3126
+ const info = session.getInfo();
3127
+ appendTelegramActivity(ctx, contextKey, session, {
3128
+ status: "info",
3129
+ type: "model_changed",
3130
+ threadId: info.threadId,
3131
+ workspace: info.workspace,
3132
+ agentId: info.agentId,
3133
+ detail: result.value,
3134
+ });
2713
3135
  const scope = formatAgentSettingScope(session.getInfo(), result.appliedToActiveThread);
2714
3136
  const html = `<b>Model set to</b> <code>${escapeHTML(result.value)}</code> — ${escapeHTML(scope)}.`;
2715
3137
  const plainText = `Model set to ${result.value} — ${scope}.`;
@@ -2756,6 +3178,15 @@ export function createBot(config, registry) {
2756
3178
  pendingEffortButtons.delete(contextKey);
2757
3179
  const result = await session.setReasoningEffortForCurrentSession(effort);
2758
3180
  updateSessionMetadata(contextKey, session);
3181
+ const info = session.getInfo();
3182
+ appendTelegramActivity(ctx, contextKey, session, {
3183
+ status: "info",
3184
+ type: "reasoning_changed",
3185
+ threadId: info.threadId,
3186
+ workspace: info.workspace,
3187
+ agentId: info.agentId,
3188
+ detail: result.value,
3189
+ });
2759
3190
  const label = agentReasoningLabel(idOf(session.getInfo()));
2760
3191
  const scope = formatAgentSettingScope(session.getInfo(), result.appliedToActiveThread);
2761
3192
  const html = `⚡ ${escapeHTML(label)} set to <code>${escapeHTML(effort)}</code> — ${escapeHTML(scope)}.`;
@@ -2815,12 +3246,24 @@ export function createBot(config, registry) {
2815
3246
  }
2816
3247
  const preview = trimLine(transcript.replace(/\s+/g, " "), 100);
2817
3248
  await safeReply(ctx, `🎙️ <b>Transcribed:</b> ${escapeHTML(preview)} <i>(via ${escapeHTML(result.backend)}, ${formatDurationSeconds(result.durationMs / 1000)})</i>`, { fallbackText: `🎙️ Transcribed: ${preview} (via ${result.backend}, ${formatDurationSeconds(result.durationMs / 1000)})` });
3249
+ appendTelegramActivity(ctx, contextKey, session, {
3250
+ status: "info",
3251
+ type: "voice_transcribed",
3252
+ prompt: preview,
3253
+ detail: result.backend,
3254
+ durationMs: result.durationMs,
3255
+ });
2818
3256
  }
2819
3257
  catch (error) {
2820
3258
  const note = "Voice uses faster-whisper/parakeet locally or OPENAI_API_KEY for cloud transcription, not CODEX_API_KEY.";
2821
3259
  await safeReply(ctx, `<b>Transcription failed:</b>\n${escapeHTML(friendlyErrorText(error))}\n\n<i>${escapeHTML(note)}</i>`, {
2822
3260
  fallbackText: `Transcription failed:\n${friendlyErrorText(error)}\n\n${note}`,
2823
3261
  });
3262
+ appendTelegramActivity(ctx, contextKey, session, {
3263
+ status: "failed",
3264
+ type: "voice_transcription_failed",
3265
+ detail: friendlyErrorText(error),
3266
+ });
2824
3267
  return;
2825
3268
  }
2826
3269
  finally {
@@ -2915,6 +3358,11 @@ export function createBot(config, registry) {
2915
3358
  if (caption) {
2916
3359
  promptInput.text = caption;
2917
3360
  }
3361
+ appendTelegramActivity(ctx, contextKey, session, {
3362
+ status: "info",
3363
+ type: "attachment_staged",
3364
+ detail: stagedPhoto.safeName,
3365
+ });
2918
3366
  await setReaction(ctx, "👀");
2919
3367
  try {
2920
3368
  await handleUserPrompt(ctx, contextKey, chatId, session, toPromptEnvelope(promptInput, outDir));
@@ -2997,6 +3445,11 @@ export function createBot(config, registry) {
2997
3445
  await safeReply(ctx, `📎 <b>Received:</b> <code>${escapeHTML(stagedFile.safeName)}</code>`, {
2998
3446
  fallbackText: `📎 Received: ${stagedFile.safeName}`,
2999
3447
  });
3448
+ appendTelegramActivity(ctx, contextKey, session, {
3449
+ status: "info",
3450
+ type: "attachment_staged",
3451
+ detail: stagedFile.safeName,
3452
+ });
3000
3453
  // Keep typing visible during the gap between staging and prompt execution
3001
3454
  await sendChatActionSafe(ctx.api, chatId, "typing", ctx.message?.message_thread_id).catch(() => { });
3002
3455
  const outDir = outboxPath(workspace, turnId);