@nordbyte/nordrelay 0.5.1 → 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 (57) hide show
  1. package/.env.example +65 -11
  2. package/README.md +97 -23
  3. package/dist/access-control.js +1 -0
  4. package/dist/activity-events.js +44 -0
  5. package/dist/agent-updates.js +18 -2
  6. package/dist/audit-log.js +40 -2
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +492 -7
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +34 -7
  11. package/dist/channel-command-service.js +156 -0
  12. package/dist/channel-turn-service.js +237 -0
  13. package/dist/codex-cli.js +1 -1
  14. package/dist/config-metadata.js +80 -13
  15. package/dist/config.js +77 -7
  16. package/dist/context-key.js +77 -5
  17. package/dist/discord-artifacts.js +165 -0
  18. package/dist/discord-bot.js +2014 -0
  19. package/dist/discord-channel-runtime.js +133 -0
  20. package/dist/discord-command-surface.js +119 -0
  21. package/dist/discord-rate-limit.js +141 -0
  22. package/dist/index.js +16 -5
  23. package/dist/job-store.js +127 -0
  24. package/dist/metrics.js +41 -0
  25. package/dist/operations.js +176 -119
  26. package/dist/relay-external-activity-monitor.js +47 -6
  27. package/dist/relay-runtime.js +1003 -268
  28. package/dist/runtime-cache.js +57 -0
  29. package/dist/session-locks.js +10 -7
  30. package/dist/state-backend.js +3 -0
  31. package/dist/support-bundle.js +18 -1
  32. package/dist/telegram-access-commands.js +15 -2
  33. package/dist/telegram-access-middleware.js +16 -3
  34. package/dist/telegram-agent-commands.js +25 -0
  35. package/dist/telegram-artifact-commands.js +46 -0
  36. package/dist/telegram-diagnostics-command.js +5 -50
  37. package/dist/telegram-general-commands.js +2 -6
  38. package/dist/telegram-operational-commands.js +14 -6
  39. package/dist/telegram-queue-commands.js +74 -4
  40. package/dist/telegram-support-command.js +7 -0
  41. package/dist/telegram-update-commands.js +27 -0
  42. package/dist/user-management.js +208 -0
  43. package/dist/web-api-contract.js +9 -0
  44. package/dist/web-dashboard-access-routes.js +74 -1
  45. package/dist/web-dashboard-artifact-routes.js +3 -3
  46. package/dist/web-dashboard-assets.js +2 -0
  47. package/dist/web-dashboard-pages.js +97 -13
  48. package/dist/web-dashboard-runtime-routes.js +53 -8
  49. package/dist/web-dashboard-session-routes.js +27 -20
  50. package/dist/web-dashboard-ui.js +1 -0
  51. package/dist/web-dashboard.js +149 -6
  52. package/dist/web-state.js +33 -2
  53. package/dist/webui-assets/dashboard.css +75 -1
  54. package/dist/webui-assets/dashboard.js +358 -47
  55. package/package.json +3 -1
  56. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  57. package/plugins/nordrelay/scripts/nordrelay.mjs +468 -22
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
  });
@@ -453,6 +481,30 @@ export function createBot(config, registry) {
453
481
  await drainQueuedPrompts(createSystemContext(contextKey), contextKey, parsed.chatId, session);
454
482
  }
455
483
  };
484
+ const sendExternalMirrorTyping = async (chatId, messageThreadId, state) => {
485
+ const now = Date.now();
486
+ if (state.lastTypingAt && now - state.lastTypingAt < TYPING_INTERVAL_MS) {
487
+ return;
488
+ }
489
+ state.lastTypingAt = now;
490
+ await sendChatActionSafe(bot.api, chatId, "typing", messageThreadId).catch(() => { });
491
+ };
492
+ const sendExternalWorkingNotice = async (chatId, messageThreadId, state, snapshot) => {
493
+ const turnKey = snapshot.activity.turnId ?? snapshot.activity.startedAt?.toISOString() ?? "unknown";
494
+ if (state.workingNoticeTurnKey === turnKey) {
495
+ return;
496
+ }
497
+ const prompt = trimLine(snapshot.latestUserMessage ?? "", 250);
498
+ const fallbackText = prompt ? `Working on ${prompt}` : `Working on external ${snapshot.agentLabel} task...`;
499
+ const html = prompt
500
+ ? `<b>Working on</b> ${escapeHTML(prompt)}`
501
+ : `<b>Working on</b> external ${escapeHTML(snapshot.agentLabel)} task...`;
502
+ await sendTextMessage(bot.api, chatId, html, {
503
+ fallbackText,
504
+ messageThreadId,
505
+ });
506
+ state.workingNoticeTurnKey = turnKey;
507
+ };
456
508
  const mirrorExternalSnapshot = async (contextKey, chatId, session, snapshot) => {
457
509
  const parsed = parseContextKey(contextKey);
458
510
  const previous = externalMirrors.get(contextKey);
@@ -471,7 +523,35 @@ export function createBot(config, registry) {
471
523
  if (snapshot.activity.active) {
472
524
  state.turnId = snapshot.activity.turnId;
473
525
  state.startedAt = snapshot.activity.startedAt;
474
- if (mirrorMode === "off" || mirrorMode === "final") {
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
+ }
546
+ if (mirrorMode !== "off") {
547
+ await sendExternalMirrorTyping(chatId, parsed.messageThreadId, state);
548
+ }
549
+ if (mirrorMode === "final") {
550
+ await sendExternalWorkingNotice(chatId, parsed.messageThreadId, state, snapshot);
551
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
552
+ return;
553
+ }
554
+ if (mirrorMode === "off") {
475
555
  state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
476
556
  return;
477
557
  }
@@ -510,7 +590,43 @@ export function createBot(config, registry) {
510
590
  state.latestMirroredEventLine = event.lineNumber;
511
591
  }
512
592
  }
513
- await sendChatActionSafe(bot.api, chatId, "typing", parsed.messageThreadId).catch(() => { });
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);
514
630
  state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
515
631
  return;
516
632
  }
@@ -520,6 +636,25 @@ export function createBot(config, registry) {
520
636
  }
521
637
  const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
522
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
+ }
523
658
  if (mirrorMode !== "off") {
524
659
  const doneText = `${snapshot.agentLabel} CLI task ${terminalEvent.status}.`;
525
660
  if (state.statusMessageId) {
@@ -551,6 +686,7 @@ export function createBot(config, registry) {
551
686
  }
552
687
  await deliverCliGeneratedArtifacts(contextKey, chatId, session, state.startedAt, terminalEvent.turnId, parsed.messageThreadId);
553
688
  }
689
+ state.workingNoticeTurnKey = undefined;
554
690
  state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
555
691
  };
556
692
  const canSendSystemMessagesToContext = (contextKey) => {
@@ -605,6 +741,18 @@ export function createBot(config, registry) {
605
741
  for (const artifact of (persistedReport?.artifacts ?? report.artifacts)) {
606
742
  await sendArtifactFileByApi(bot.api, chatId, artifact, messageThreadId);
607
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
+ });
608
756
  if (state)
609
757
  state.artifactsDeliveredForTurnId = turnId;
610
758
  };
@@ -656,9 +804,11 @@ export function createBot(config, registry) {
656
804
  };
657
805
  const auditContext = (ctx, contextKey, session, patch) => {
658
806
  const info = session.getInfo();
807
+ const authUser = getAuthenticatedUser(ctx);
659
808
  audit({
660
809
  contextKey,
661
- actorId: ctx.from?.id,
810
+ actor: telegramActivityActor(ctx),
811
+ actorId: authUser?.user.id ?? ctx.from?.id,
662
812
  actorRole: getUserRole(ctx),
663
813
  agentId: idOf(info),
664
814
  threadId: info.threadId,
@@ -666,10 +816,68 @@ export function createBot(config, registry) {
666
816
  ...patch,
667
817
  });
668
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
+ }
669
877
  const denyIfLocked = async (ctx, contextKey, session) => {
670
878
  const lock = lockStore.get(contextKey);
671
879
  const isAdmin = isAdminUser(ctx);
672
- if (canWriteWithLock(lock, ctx.from?.id, isAdmin)) {
880
+ if (canWriteWithLock(lock, getAuthenticatedUser(ctx)?.user.id, isAdmin)) {
673
881
  return false;
674
882
  }
675
883
  const owner = formatLockOwner(lock);
@@ -679,6 +887,11 @@ export function createBot(config, registry) {
679
887
  status: "denied",
680
888
  detail: text,
681
889
  });
890
+ appendTelegramActivity(ctx, contextKey, session, {
891
+ status: "failed",
892
+ type: "lock_denied",
893
+ detail: text,
894
+ });
682
895
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
683
896
  return true;
684
897
  };
@@ -777,7 +990,11 @@ export function createBot(config, registry) {
777
990
  }
778
991
  const parsed = parseContextKey(contextKey);
779
992
  const messageThreadId = parsed.messageThreadId;
780
- 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
+ };
781
998
  if (!options.fromQueue && await denyIfLocked(ctx, contextKey, session)) {
782
999
  return;
783
1000
  }
@@ -808,6 +1025,13 @@ export function createBot(config, registry) {
808
1025
  description: item.description,
809
1026
  detail: busy.kind,
810
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
+ });
811
1035
  if (busy.kind === "external") {
812
1036
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
813
1037
  }
@@ -845,6 +1069,9 @@ export function createBot(config, registry) {
845
1069
  let lastRenderedPlan = "";
846
1070
  let planMessageSending = false;
847
1071
  let lastTurnUsage;
1072
+ let promptStartedAt;
1073
+ const toolActivityNames = new Map();
1074
+ const toolActivityStartedAt = new Map();
848
1075
  const typingInterval = setInterval(() => {
849
1076
  void sendChatActionSafe(bot.api, chatId, "typing", messageThreadId).catch(() => { });
850
1077
  }, TYPING_INTERVAL_MS);
@@ -1048,6 +1275,15 @@ export function createBot(config, registry) {
1048
1275
  progress.lastTool = toolName;
1049
1276
  progress.updatedAt = Date.now();
1050
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
+ });
1051
1287
  if (toolVerbosity === "summary") {
1052
1288
  toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1);
1053
1289
  return;
@@ -1095,6 +1331,18 @@ export function createBot(config, registry) {
1095
1331
  onToolEnd: (toolCallId, isError) => {
1096
1332
  progress.currentTool = undefined;
1097
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);
1098
1346
  if (toolVerbosity === "none" || toolVerbosity === "summary") {
1099
1347
  return;
1100
1348
  }
@@ -1234,11 +1482,25 @@ export function createBot(config, registry) {
1234
1482
  replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
1235
1483
  });
1236
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
+ });
1237
1492
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
1238
1493
  turnProgress.delete(contextKey);
1239
1494
  return;
1240
1495
  }
1241
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
+ });
1242
1504
  auditContext(ctx, contextKey, session, {
1243
1505
  action: "prompt_started",
1244
1506
  status: "ok",
@@ -1268,6 +1530,13 @@ export function createBot(config, registry) {
1268
1530
  status: "ok",
1269
1531
  description: envelope.description,
1270
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
+ });
1271
1540
  }
1272
1541
  catch (error) {
1273
1542
  progress.status = "failed";
@@ -1278,6 +1547,16 @@ export function createBot(config, registry) {
1278
1547
  description: envelope.description,
1279
1548
  detail: progress.error,
1280
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
+ }
1281
1560
  progress.completedAt = Date.now();
1282
1561
  progress.updatedAt = progress.completedAt;
1283
1562
  stopTyping();
@@ -1368,6 +1647,15 @@ export function createBot(config, registry) {
1368
1647
  source: "turn",
1369
1648
  };
1370
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
+ }
1371
1659
  await pruneArtifacts(workspace);
1372
1660
  };
1373
1661
  const deliverArtifactReport = async (ctx, chatId, report, messageThreadId) => {
@@ -1564,6 +1852,11 @@ export function createBot(config, registry) {
1564
1852
  }
1565
1853
  const receivedText = `Received ${stagedFiles.length} media group file${stagedFiles.length === 1 ? "" : "s"}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}.`;
1566
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
+ });
1567
1860
  await sendChatActionSafe(pending.ctx.api, pending.chatId, "typing", pending.messageThreadId).catch(() => { });
1568
1861
  const promptInput = {
1569
1862
  stagedFileInstructions: buildFileInstructions(stagedFiles, outDir),
@@ -1593,6 +1886,7 @@ export function createBot(config, registry) {
1593
1886
  checkAgentAuthStatus,
1594
1887
  isTopicContext,
1595
1888
  replyChannelAction,
1889
+ commandService,
1596
1890
  });
1597
1891
  registerTelegramAgentCommands({
1598
1892
  bot,
@@ -1609,6 +1903,13 @@ export function createBot(config, registry) {
1609
1903
  startAgentLogout,
1610
1904
  hostLoginCommand,
1611
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
+ }),
1612
1913
  });
1613
1914
  registerTelegramPreferenceCommands({
1614
1915
  bot,
@@ -1641,6 +1942,7 @@ export function createBot(config, registry) {
1641
1942
  getEffectiveVoiceLanguage,
1642
1943
  isVoiceTranscribeOnly,
1643
1944
  replyChannelAction,
1945
+ commandService,
1644
1946
  });
1645
1947
  registerTelegramOperationalCommands({
1646
1948
  bot,
@@ -1654,10 +1956,34 @@ export function createBot(config, registry) {
1654
1956
  getExternalActivity,
1655
1957
  isAdminUser,
1656
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
+ },
1657
1971
  updateSessionMetadata,
1658
1972
  });
1659
1973
  registerTelegramSupportCommands({ bot, config, auditLog, agentUpdates, getUserRole, audit });
1660
- 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
+ });
1661
1987
  bot.command("new", async (ctx) => {
1662
1988
  const chatId = ctx.chat?.id;
1663
1989
  if (!chatId) {
@@ -1686,6 +2012,14 @@ export function createBot(config, registry) {
1686
2012
  try {
1687
2013
  const info = await session.newThread();
1688
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
+ });
1689
2023
  const label = isTopicContext(contextKey) ? "New thread created for this topic." : "New thread created.";
1690
2024
  const policyLine = renderWorkspacePolicyLine(info.workspace, config);
1691
2025
  const plainText = [label, policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
@@ -1723,12 +2057,22 @@ export function createBot(config, registry) {
1723
2057
  if (busy.kind === "external") {
1724
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.`;
1725
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
+ });
1726
2065
  return;
1727
2066
  }
1728
2067
  await session.abort();
1729
2068
  await safeReply(ctx, escapeHTML("Aborted current operation"), {
1730
2069
  fallbackText: "Aborted current operation",
1731
2070
  });
2071
+ appendTelegramActivity(ctx, contextKey, session, {
2072
+ status: "aborted",
2073
+ type: "prompt_aborted",
2074
+ detail: "Abort requested from Telegram.",
2075
+ });
1732
2076
  }
1733
2077
  catch (error) {
1734
2078
  await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
@@ -1777,6 +2121,8 @@ export function createBot(config, registry) {
1777
2121
  drainQueuedPrompts,
1778
2122
  handleUserPrompt,
1779
2123
  auditContext,
2124
+ activityActor: telegramActivityActor,
2125
+ appendActivity: appendTelegramActivity,
1780
2126
  });
1781
2127
  registerTelegramArtifactCommands({
1782
2128
  bot,
@@ -1784,6 +2130,13 @@ export function createBot(config, registry) {
1784
2130
  getContextSession,
1785
2131
  deliverArtifactReport,
1786
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
+ }),
1787
2140
  });
1788
2141
  bot.command("session", async (ctx) => {
1789
2142
  const contextSession = await getContextSession(ctx, { deferThreadStart: true });
@@ -1878,6 +2231,14 @@ export function createBot(config, registry) {
1878
2231
  try {
1879
2232
  const info = session.handback();
1880
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
+ });
1881
2242
  if (!info.threadId) {
1882
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."), {
1883
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.",
@@ -1973,6 +2334,14 @@ export function createBot(config, registry) {
1973
2334
  try {
1974
2335
  const info = await session.switchSession(threadId);
1975
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
+ });
1976
2345
  const policyLine = renderWorkspacePolicyLine(info.workspace, config);
1977
2346
  const html = ["<b>Attached to thread.</b>", policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
1978
2347
  const plain = ["Attached to thread.", policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
@@ -2019,6 +2388,14 @@ export function createBot(config, registry) {
2019
2388
  try {
2020
2389
  const info = await session.switchSession(threadId);
2021
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
+ });
2022
2399
  const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2023
2400
  const html = ["<b>Switched thread.</b>", policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
2024
2401
  const plain = ["Switched thread.", policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
@@ -2105,6 +2482,12 @@ export function createBot(config, registry) {
2105
2482
  return;
2106
2483
  }
2107
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
+ });
2108
2491
  await safeReply(ctx, `<b>Pinned thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Total pinned:</b> <code>${pinned.length}</code>`, {
2109
2492
  fallbackText: `Pinned thread: ${threadId}\nTotal pinned: ${pinned.length}`,
2110
2493
  });
@@ -2125,6 +2508,12 @@ export function createBot(config, registry) {
2125
2508
  return;
2126
2509
  }
2127
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
+ });
2128
2517
  await safeReply(ctx, `<b>Unpinned thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Total pinned:</b> <code>${pinned.length}</code>`, {
2129
2518
  fallbackText: `Unpinned thread: ${threadId}\nTotal pinned: ${pinned.length}`,
2130
2519
  });
@@ -2240,6 +2629,14 @@ export function createBot(config, registry) {
2240
2629
  const result = session.setFastMode(nextFastMode);
2241
2630
  updateSessionMetadata(contextKey, session);
2242
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
+ });
2243
2640
  const plain = [
2244
2641
  `Fast mode: ${result.enabled ? "on" : "off"}`,
2245
2642
  `Launch profile: ${result.profile.label} (${formatLaunchProfileBehavior(result.profile)})`,
@@ -2352,6 +2749,15 @@ export function createBot(config, registry) {
2352
2749
  });
2353
2750
  }
2354
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
+ }
2355
2761
  if (chatId && session) {
2356
2762
  void drainQueuedPrompts(ctx, pending.contextKey, chatId, session).catch((error) => {
2357
2763
  console.error("Failed to drain queue after approval denial:", error);
@@ -2370,6 +2776,13 @@ export function createBot(config, registry) {
2370
2776
  fallbackText: `Approved prompt ${approvalId}.`,
2371
2777
  });
2372
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
+ });
2373
2786
  await handleUserPrompt(ctx, pending.contextKey, chatId ?? parseContextKey(pending.contextKey).chatId, contextSession.session, pending.prompt, {
2374
2787
  approved: true,
2375
2788
  });
@@ -2413,6 +2826,14 @@ export function createBot(config, registry) {
2413
2826
  try {
2414
2827
  const info = await session.switchSession(threadId);
2415
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
+ });
2416
2837
  const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2417
2838
  const plainText = ["Switched session.", policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
2418
2839
  const html = ["<b>Switched session.</b>", policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
@@ -2475,6 +2896,14 @@ export function createBot(config, registry) {
2475
2896
  try {
2476
2897
  const info = await session.newThread(workspace);
2477
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
+ });
2478
2907
  const label = isTopicContext(contextKey) ? "New thread created for this topic." : "New thread created.";
2479
2908
  const policyLine = renderWorkspacePolicyLine(info.workspace, config);
2480
2909
  const plainText = [label, policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
@@ -2570,6 +2999,14 @@ export function createBot(config, registry) {
2570
2999
  session.setLaunchProfile(profile.id);
2571
3000
  updateSessionMetadata(contextKey, session);
2572
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
+ });
2573
3010
  const html = [
2574
3011
  `<b>Launch profile set to</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`,
2575
3012
  `<b>Behavior:</b> <code>${escapeHTML(info.launchProfileBehavior)}</code>`,
@@ -2632,6 +3069,14 @@ export function createBot(config, registry) {
2632
3069
  session.setLaunchProfile(profile.id);
2633
3070
  updateSessionMetadata(contextKey, session);
2634
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
+ });
2635
3080
  await ctx.answerCallbackQuery({ text: `Launch set to ${info.launchProfileLabel}` });
2636
3081
  const html = [
2637
3082
  `<b>Launch profile set to</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`,
@@ -2678,6 +3123,15 @@ export function createBot(config, registry) {
2678
3123
  try {
2679
3124
  const result = await session.setModelForCurrentSession(slug);
2680
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
+ });
2681
3135
  const scope = formatAgentSettingScope(session.getInfo(), result.appliedToActiveThread);
2682
3136
  const html = `<b>Model set to</b> <code>${escapeHTML(result.value)}</code> — ${escapeHTML(scope)}.`;
2683
3137
  const plainText = `Model set to ${result.value} — ${scope}.`;
@@ -2724,6 +3178,15 @@ export function createBot(config, registry) {
2724
3178
  pendingEffortButtons.delete(contextKey);
2725
3179
  const result = await session.setReasoningEffortForCurrentSession(effort);
2726
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
+ });
2727
3190
  const label = agentReasoningLabel(idOf(session.getInfo()));
2728
3191
  const scope = formatAgentSettingScope(session.getInfo(), result.appliedToActiveThread);
2729
3192
  const html = `⚡ ${escapeHTML(label)} set to <code>${escapeHTML(effort)}</code> — ${escapeHTML(scope)}.`;
@@ -2783,12 +3246,24 @@ export function createBot(config, registry) {
2783
3246
  }
2784
3247
  const preview = trimLine(transcript.replace(/\s+/g, " "), 100);
2785
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
+ });
2786
3256
  }
2787
3257
  catch (error) {
2788
3258
  const note = "Voice uses faster-whisper/parakeet locally or OPENAI_API_KEY for cloud transcription, not CODEX_API_KEY.";
2789
3259
  await safeReply(ctx, `<b>Transcription failed:</b>\n${escapeHTML(friendlyErrorText(error))}\n\n<i>${escapeHTML(note)}</i>`, {
2790
3260
  fallbackText: `Transcription failed:\n${friendlyErrorText(error)}\n\n${note}`,
2791
3261
  });
3262
+ appendTelegramActivity(ctx, contextKey, session, {
3263
+ status: "failed",
3264
+ type: "voice_transcription_failed",
3265
+ detail: friendlyErrorText(error),
3266
+ });
2792
3267
  return;
2793
3268
  }
2794
3269
  finally {
@@ -2883,6 +3358,11 @@ export function createBot(config, registry) {
2883
3358
  if (caption) {
2884
3359
  promptInput.text = caption;
2885
3360
  }
3361
+ appendTelegramActivity(ctx, contextKey, session, {
3362
+ status: "info",
3363
+ type: "attachment_staged",
3364
+ detail: stagedPhoto.safeName,
3365
+ });
2886
3366
  await setReaction(ctx, "👀");
2887
3367
  try {
2888
3368
  await handleUserPrompt(ctx, contextKey, chatId, session, toPromptEnvelope(promptInput, outDir));
@@ -2965,6 +3445,11 @@ export function createBot(config, registry) {
2965
3445
  await safeReply(ctx, `📎 <b>Received:</b> <code>${escapeHTML(stagedFile.safeName)}</code>`, {
2966
3446
  fallbackText: `📎 Received: ${stagedFile.safeName}`,
2967
3447
  });
3448
+ appendTelegramActivity(ctx, contextKey, session, {
3449
+ status: "info",
3450
+ type: "attachment_staged",
3451
+ detail: stagedFile.safeName,
3452
+ });
2968
3453
  // Keep typing visible during the gap between staging and prompt execution
2969
3454
  await sendChatActionSafe(ctx.api, chatId, "typing", ctx.message?.message_thread_id).catch(() => { });
2970
3455
  const outDir = outboxPath(workspace, turnId);