appback-remoteagent 0.17.0 → 0.18.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.
package/dist/bot.js CHANGED
@@ -104,6 +104,41 @@ const workLoopTails = new Map();
104
104
  const workLoopGenerations = new Map();
105
105
  const queuedWorkLoops = new Map();
106
106
  let nextQueuedWorkSequence = 1;
107
+ class InlineActionRegistry {
108
+ records = new Map();
109
+ ttlMs = 6 * 60 * 60 * 1000;
110
+ maxRecords = 1_000;
111
+ register(action, chatId, userId) {
112
+ this.prune();
113
+ while (this.records.size >= this.maxRecords) {
114
+ const oldest = this.records.keys().next().value;
115
+ if (!oldest) {
116
+ break;
117
+ }
118
+ this.records.delete(oldest);
119
+ }
120
+ const id = randomUUID().replace(/-/g, "").slice(0, 20);
121
+ this.records.set(id, { action, chatId, userId, createdAt: Date.now() });
122
+ return `remoteagent:action:${id}`;
123
+ }
124
+ resolve(data, chatId, userId) {
125
+ this.prune();
126
+ const match = /^remoteagent:action:([a-f0-9]{20})$/i.exec(data);
127
+ const record = match ? this.records.get(match[1]) : undefined;
128
+ if (!record || record.chatId !== chatId || record.userId !== userId) {
129
+ return undefined;
130
+ }
131
+ return record.action;
132
+ }
133
+ prune() {
134
+ const expiresBefore = Date.now() - this.ttlMs;
135
+ for (const [id, record] of this.records) {
136
+ if (record.createdAt < expiresBefore) {
137
+ this.records.delete(id);
138
+ }
139
+ }
140
+ }
141
+ }
107
142
  const REPORT_CONTINUE_PROMPT = [
108
143
  "Continue the same task now.",
109
144
  "Do more concrete work before replying again.",
@@ -185,6 +220,7 @@ class AutoContinueController {
185
220
  }
186
221
  export function createBot(token, bridge, botManagement, botInfo) {
187
222
  const bot = new Bot(token, { botInfo });
223
+ const inlineActions = new InlineActionRegistry();
188
224
  const autoContinue = new AutoContinueController(path.join(config.dataDir, "stop-gates.json"));
189
225
  const shellService = new RemoteShellService(config.commandTimeoutMs);
190
226
  const memoryService = new AgentMemoryService(config.dataDir);
@@ -229,6 +265,32 @@ export function createBot(token, bridge, botManagement, botInfo) {
229
265
  throw error;
230
266
  }
231
267
  };
268
+ const actionButton = (ctx, text, action) => {
269
+ if (!ctx.chat || !ctx.from) {
270
+ throw new Error("Telegram action button requires chat and user context.");
271
+ }
272
+ return {
273
+ text,
274
+ callback_data: inlineActions.register(action, String(ctx.chat.id), String(ctx.from.id)),
275
+ };
276
+ };
277
+ const keyboardOptions = (rows) => {
278
+ return rows.length > 0
279
+ ? { reply_markup: JSON.stringify({ inline_keyboard: rows }) }
280
+ : undefined;
281
+ };
282
+ const removeQueuedInstruction = async (botId, chatId, selector) => {
283
+ const mapping = await bridge.status(botId, chatId);
284
+ const activeKey = workLoopKey(botId, chatId, mapping?.session.sessionId);
285
+ const removed = removeQueuedWorkLoop(activeKey, selector);
286
+ if (!removed) {
287
+ const target = selector ? normalizeQueueId(selector) : "the latest queued instruction";
288
+ return `Queued instruction was not found: ${target}`;
289
+ }
290
+ await bridge.logSystem(botId, chatId, `Removed queued instruction ${removed.id} for ${activeKey}.`);
291
+ return `Removed queued instruction ${removed.id} from ${removed.publicSessionId ?? "this session"}.\n`
292
+ + `Remaining queued instructions: ${listQueuedWorkLoops(activeKey).length}`;
293
+ };
232
294
  const runPlanDocumentReinforcement = async (ctx, count) => {
233
295
  if (!ctx.chat) {
234
296
  throw new Error("Telegram chat context is missing.");
@@ -317,14 +379,14 @@ ${bridge.formatStatus(mapping)}`);
317
379
  bot.command("help", async (ctx) => {
318
380
  await reply(ctx, HELP_TEXT);
319
381
  });
320
- const replySessionList = async (ctx) => {
382
+ const replySessionList = async (ctx, requestedShowAll) => {
321
383
  if (!ctx.chat) {
322
384
  throw new Error("Telegram chat context is missing.");
323
385
  }
324
386
  const botId = getBotId();
325
387
  const chatId = String(ctx.chat.id);
326
388
  const { args } = parseCommand(ctx.message?.text, 1);
327
- const showAll = args[0] === "-a" || args[0] === "--all";
389
+ const showAll = requestedShowAll ?? (args[0] === "-a" || args[0] === "--all");
328
390
  const [mapping, sessions] = await Promise.all([
329
391
  bridge.status(botId, chatId),
330
392
  bridge.listSessions(),
@@ -333,9 +395,33 @@ ${bridge.formatStatus(mapping)}`);
333
395
  const sessionList = showAll
334
396
  ? await bridge.formatSessionListDetailed(sessions, mapping?.session.sessionId, await bridge.listActiveSessionIds())
335
397
  : bridge.formatSessionList(sessions, mapping?.session.sessionId);
336
- for (const chunk of flattenChunks([`${sessionList}\n\n${botSummary}`], 3900)) {
337
- await reply(ctx, chunk);
398
+ const sessionButtons = sessions.slice(0, showAll ? 20 : 10).map((session) => [
399
+ actionButton(ctx, truncateButtonLabel(`${session.sessionId === mapping?.session.sessionId ? "✓ " : ""}${session.publicId} · ${workspaceLeaf(session.workspace)}`), { kind: "session.switch", selector: session.publicId }),
400
+ ]);
401
+ if (!showAll && sessions.length > 10) {
402
+ sessionButtons.push([actionButton(ctx, `Show all ${sessions.length} sessions`, { kind: "session.list", showAll: true })]);
403
+ }
404
+ const chunks = flattenChunks([`${sessionList}\n\n${botSummary}`], 3900);
405
+ for (const [index, chunk] of chunks.entries()) {
406
+ await reply(ctx, chunk, index === chunks.length - 1 ? keyboardOptions(sessionButtons) : undefined);
407
+ }
408
+ };
409
+ const switchChatSession = async (ctx, selector) => {
410
+ if (!ctx.chat) {
411
+ throw new Error("Telegram chat context is missing.");
412
+ }
413
+ const botId = getBotId();
414
+ const chatId = String(ctx.chat.id);
415
+ const previous = await bridge.status(botId, chatId).catch(() => undefined);
416
+ const mapping = await bridge.switchSession(botId, chatId, selector);
417
+ if (previous && previous.session.sessionId !== mapping.session.sessionId) {
418
+ autoContinue.requestSessionStop(previous.session.sessionId);
419
+ cancelQueuedWorkLoops(botId, chatId, previous.session.sessionId);
420
+ messageBatcher.cancelPending(botId, chatId);
421
+ messageBatcher.cancelManual(botId, chatId);
422
+ await bridge.stopSessionRun(previous.session.sessionId, botId, chatId, "Chat switched to another session; previous session execution was stopped.");
338
423
  }
424
+ return `Switched this chat to session ${mapping.session.publicId}.\n\n${bridge.formatCurrentSession(mapping)}`;
339
425
  };
340
426
  bot.command("list", async (ctx) => {
341
427
  await replySessionList(ctx);
@@ -349,8 +435,6 @@ ${bridge.formatStatus(mapping)}`);
349
435
  await reply(ctx, `Created and bound a new ${mapping.session.mode} session.\n\n${bridge.formatCurrentSession(mapping)}`);
350
436
  });
351
437
  bot.command("switch", async (ctx) => {
352
- const botId = getBotId();
353
- const chatId = String(ctx.chat.id);
354
438
  const { args } = parseCommand(ctx.message?.text, 1);
355
439
  const sessionId = args[0];
356
440
  if (!sessionId) {
@@ -359,16 +443,7 @@ ${bridge.formatStatus(mapping)}`);
359
443
  });
360
444
  return;
361
445
  }
362
- const previous = await bridge.status(botId, chatId).catch(() => undefined);
363
- const mapping = await bridge.switchSession(botId, chatId, sessionId);
364
- if (previous && previous.session.sessionId !== mapping.session.sessionId) {
365
- autoContinue.requestSessionStop(previous.session.sessionId);
366
- cancelQueuedWorkLoops(botId, chatId, previous.session.sessionId);
367
- messageBatcher.cancelPending(botId, chatId);
368
- messageBatcher.cancelManual(botId, chatId);
369
- await bridge.stopSessionRun(previous.session.sessionId, botId, chatId, "Chat switched to another session; previous session execution was stopped.");
370
- }
371
- await reply(ctx, `Switched this chat to session ${sessionId}.\n\n${bridge.formatCurrentSession(mapping)}`);
446
+ await reply(ctx, await switchChatSession(ctx, sessionId));
372
447
  });
373
448
  bot.command("plan", async (ctx) => {
374
449
  const { args, rest } = parseCommand(ctx.message?.text, 1);
@@ -437,6 +512,14 @@ ${bridge.formatStatus(mapping)}`);
437
512
 
438
513
  ${bridge.formatStatus(mapping)}`);
439
514
  });
515
+ const setChatModel = async (ctx, model) => {
516
+ if (!ctx.chat) {
517
+ throw new Error("Telegram chat context is missing.");
518
+ }
519
+ const mapping = await bridge.setModel(getBotId(), String(ctx.chat.id), model);
520
+ const selectedModel = mapping.session[mapping.session.mode]?.model ?? model;
521
+ return `Set ${mapping.session.mode} model to ${selectedModel}.\n\n${bridge.formatStatus(mapping)}`;
522
+ };
440
523
  bot.command("model", async (ctx) => {
441
524
  const botId = getBotId();
442
525
  const chatId = String(ctx.chat.id);
@@ -449,13 +532,17 @@ ${bridge.formatStatus(mapping)}`);
449
532
  return;
450
533
  }
451
534
  if (!model) {
535
+ const selection = await bridge.getModelSelection(botId, chatId);
536
+ const rows = selection.presets.map((preset) => [
537
+ actionButton(ctx, `${preset === selection.currentModel ? "✓ " : ""}${preset}`, { kind: "model.set", model: preset }),
538
+ ]);
452
539
  await reply(ctx, await bridge.formatModelSelection(botId, chatId), {
453
540
  parse_mode: "Markdown",
541
+ ...(keyboardOptions(rows) ?? {}),
454
542
  });
455
543
  return;
456
544
  }
457
- const mapping = await bridge.setModel(botId, chatId, model);
458
- await reply(ctx, `Set ${mapping.session.mode} model to ${model}.\n\n${bridge.formatStatus(mapping)}`);
545
+ await reply(ctx, await setChatModel(ctx, model));
459
546
  });
460
547
  bot.command("queue", async (ctx) => {
461
548
  const botId = getBotId();
@@ -470,22 +557,22 @@ ${bridge.formatStatus(mapping)}`);
470
557
  return;
471
558
  }
472
559
  if (!action || action === "list") {
473
- await reply(ctx, formatQueuedWorkLoops(activeKey, mapping?.session.publicId));
560
+ const queued = listQueuedWorkLoops(activeKey);
561
+ const rows = queued.map((entry) => [{
562
+ text: `Remove ${entry.id}`,
563
+ callback_data: `remoteagent:queue:remove:${entry.id}`,
564
+ }]);
565
+ if (queued.length > 0) {
566
+ rows.push([{ text: "Remove latest", callback_data: "remoteagent:queue:del" }]);
567
+ }
568
+ await reply(ctx, formatQueuedWorkLoops(activeKey, mapping?.session.publicId), keyboardOptions(rows));
474
569
  return;
475
570
  }
476
571
  if ((action === "remove" || action === "rm") && !selector) {
477
572
  await reply(ctx, "Usage: `/queue remove <id>`", { parse_mode: "Markdown" });
478
573
  return;
479
574
  }
480
- const removed = removeQueuedWorkLoop(activeKey, selector);
481
- if (!removed) {
482
- const target = selector ? normalizeQueueId(selector) : "the latest queued instruction";
483
- await reply(ctx, `Queued instruction was not found: ${target}`);
484
- return;
485
- }
486
- await bridge.logSystem(botId, chatId, `Removed queued instruction ${removed.id} for ${activeKey}.`);
487
- await reply(ctx, `Removed queued instruction ${removed.id} from ${removed.publicSessionId ?? "this session"}.\n`
488
- + `Remaining queued instructions: ${listQueuedWorkLoops(activeKey).length}`);
575
+ await reply(ctx, await removeQueuedInstruction(botId, chatId, selector));
489
576
  });
490
577
  bot.command("stop", async (ctx) => {
491
578
  const botId = getBotId();
@@ -529,7 +616,16 @@ ${bridge.formatStatus(mapping)}`);
529
616
  const option = args[0]?.toLowerCase();
530
617
  const value = args[1];
531
618
  if (!option) {
532
- await reply(ctx, formatRuntimeOptions());
619
+ await reply(ctx, formatRuntimeOptions(), keyboardOptions([
620
+ [
621
+ actionButton(ctx, "Retry", { kind: "option.show", option: "retry" }),
622
+ actionButton(ctx, "Timeout", { kind: "option.show", option: "timeout" }),
623
+ ],
624
+ [
625
+ actionButton(ctx, "Intent", { kind: "option.show", option: "intent" }),
626
+ actionButton(ctx, "Command menu", { kind: "option.show", option: "command-menu" }),
627
+ ],
628
+ ]));
533
629
  return;
534
630
  }
535
631
  if (option !== "retry" && option !== "timeout" && option !== "intent" && option !== "command-menu") {
@@ -539,14 +635,7 @@ ${bridge.formatStatus(mapping)}`);
539
635
  return;
540
636
  }
541
637
  if (!value) {
542
- const current = option === "retry"
543
- ? `Current automatic continuation retry limit: ${formatRetryLimit(config.telegramAutoProgressMaxTurns)}\n\nUsage: \`/option retry <count>\``
544
- : option === "intent"
545
- ? `Current untagged intent retry limit: ${formatRetryLimit(config.telegramUntaggedIntentRetries)}\n\nUsage: \`/option intent <count>\``
546
- : option === "command-menu"
547
- ? `Current Telegram command menu: ${config.telegramCommandMenuEnabled ? "on" : "off"}\n\nUsage: \`/option command-menu on\`, \`/option command-menu off\`, or \`/option command-menu refresh\``
548
- : `Current provider execution timeout: ${formatTimeoutSeconds(config.commandTimeoutMs)}\n\nUsage: \`/option timeout <seconds>\``;
549
- await reply(ctx, current, {
638
+ await reply(ctx, formatRuntimeOptionDetail(option), {
550
639
  parse_mode: "Markdown",
551
640
  });
552
641
  return;
@@ -758,15 +847,29 @@ ${bridge.formatStatus(mapping)}`);
758
847
  }
759
848
  await reply(ctx, "Usage: `/docs list`, `/docs find <keyword>`, `/docs pin <keyword> <path>`, `/docs remove <keyword>`, or `/docs reinforce <1-10>`", { parse_mode: "Markdown" });
760
849
  });
761
- const handleMacroCommand = async (ctx, text) => {
850
+ const runMacro = async (ctx, target) => {
762
851
  if (!ctx.chat) {
763
852
  throw new Error("Telegram chat context is missing.");
764
853
  }
765
854
  const botId = getBotId();
766
855
  const chatId = String(ctx.chat.id);
856
+ const macro = await memoryService.getMacro(target);
857
+ if (!macro) {
858
+ return `Macro was not found: ${target}\n\n${await memoryService.listMacros()}`;
859
+ }
860
+ await bridge.logSystem(botId, chatId, `Macro executed: ${macro.alias}`);
861
+ await messageBatcher.enqueue({ botToken: token, telegramChatId: ctx.chat.id }, botId, chatId, macro.prompt);
862
+ return undefined;
863
+ };
864
+ const handleMacroCommand = async (ctx, text) => {
865
+ const botId = getBotId();
767
866
  const parsed = parseMacroCommandText(text, botId);
768
867
  if (parsed.kind === "help") {
769
- await reply(ctx, await formatMacroHelp(memoryService));
868
+ const macros = await memoryService.getMacros();
869
+ const rows = macros.slice(0, 20).map((macro) => [
870
+ actionButton(ctx, truncateButtonLabel(macro.alias), { kind: "macro.run", alias: macro.alias }),
871
+ ]);
872
+ await reply(ctx, await formatMacroHelp(memoryService), keyboardOptions(rows));
770
873
  return;
771
874
  }
772
875
  if (parsed.kind === "set") {
@@ -781,25 +884,34 @@ ${bridge.formatStatus(mapping)}`);
781
884
  await reply(ctx, removed ? `Removed macro '${parsed.alias}'.` : `Macro was not found: ${parsed.alias}`);
782
885
  return;
783
886
  }
784
- const macro = await memoryService.getMacro(parsed.target);
785
- if (!macro) {
786
- await reply(ctx, `Macro was not found: ${parsed.target}\n\n${await memoryService.listMacros()}`);
787
- return;
887
+ const result = await runMacro(ctx, parsed.target);
888
+ if (result) {
889
+ await reply(ctx, result);
788
890
  }
789
- await bridge.logSystem(botId, chatId, `Macro executed: ${macro.alias}`);
790
- await messageBatcher.enqueue({ botToken: token, telegramChatId: ctx.chat.id }, botId, chatId, macro.prompt);
791
891
  };
792
892
  bot.command("macro", async (ctx) => {
793
893
  await handleMacroCommand(ctx, ctx.message?.text ?? "/macro");
794
894
  });
795
- bot.command("bots", async (ctx) => {
895
+ const replyBotList = async (ctx) => {
796
896
  await ensureOwnerControlAccess(ctx);
797
897
  const pendingNotice = await botManagement.getPendingOperationNotice();
798
- if (pendingNotice?.pending) {
799
- await reply(ctx, `${pendingNotice.message}\n\n${await botManagement.listBots()}`);
800
- return;
801
- }
802
- await reply(ctx, await botManagement.listBots());
898
+ const [botList, choices] = await Promise.all([
899
+ botManagement.listBots(),
900
+ botManagement.listBotChoices(),
901
+ ]);
902
+ const rows = [];
903
+ for (let index = 0; index < choices.length; index += 2) {
904
+ rows.push(choices.slice(index, index + 2).map((choice) => ({
905
+ text: `@${choice.username}`,
906
+ url: `https://t.me/${choice.username}`,
907
+ })));
908
+ }
909
+ rows.push([actionButton(ctx, "Refresh", { kind: "bots.refresh" })]);
910
+ const message = pendingNotice?.pending ? `${pendingNotice.message}\n\n${botList}` : botList;
911
+ await reply(ctx, message, keyboardOptions(rows));
912
+ };
913
+ bot.command("bots", async (ctx) => {
914
+ await replyBotList(ctx);
803
915
  });
804
916
  bot.command("bot", async (ctx) => {
805
917
  await ensureOwnerControlAccess(ctx);
@@ -878,20 +990,39 @@ ${bridge.formatStatus(mapping)}`);
878
990
  return { chunks: flattenChunks([output], 3900) };
879
991
  });
880
992
  });
993
+ const setChatSandbox = async (ctx, sandboxMode) => {
994
+ if (!ctx.chat) {
995
+ throw new Error("Telegram chat context is missing.");
996
+ }
997
+ const mapping = await bridge.setCodexSandboxMode(getBotId(), String(ctx.chat.id), sandboxMode);
998
+ return `Set Codex sandbox to ${sandboxMode}.\n\n${bridge.formatStatus(mapping)}`;
999
+ };
881
1000
  bot.command("sandbox", async (ctx) => {
882
1001
  const botId = getBotId();
883
1002
  const chatId = String(ctx.chat.id);
884
1003
  const { args } = parseCommand(ctx.message?.text, 2);
885
1004
  const provider = args[0]?.toLowerCase();
886
1005
  const sandboxMode = args[1]?.toLowerCase();
1006
+ if (!provider && !sandboxMode) {
1007
+ const mapping = await bridge.status(botId, chatId);
1008
+ if (!mapping?.session.codex) {
1009
+ await reply(ctx, "No Codex session is paired with this chat.");
1010
+ return;
1011
+ }
1012
+ const current = mapping.session.codex.sandboxMode ?? config.codexSandboxMode;
1013
+ const modes = ["read-only", "workspace-write", "danger-full-access"];
1014
+ await reply(ctx, `Codex sandbox\ncurrent: ${current}\n\nChoose a mode:`, keyboardOptions(modes.map((mode) => [
1015
+ actionButton(ctx, `${mode === current ? "✓ " : ""}${mode}`, { kind: "sandbox.set", mode }),
1016
+ ])));
1017
+ return;
1018
+ }
887
1019
  if (provider !== "codex" || !sandboxMode || !isCodexSandboxMode(sandboxMode)) {
888
1020
  await reply(ctx, "Usage: `/sandbox codex <read-only|workspace-write|danger-full-access>`", {
889
1021
  parse_mode: "Markdown",
890
1022
  });
891
1023
  return;
892
1024
  }
893
- const mapping = await bridge.setCodexSandboxMode(botId, chatId, sandboxMode);
894
- await reply(ctx, `Set Codex sandbox to ${sandboxMode}.\n\n${bridge.formatStatus(mapping)}`);
1025
+ await reply(ctx, await setChatSandbox(ctx, sandboxMode));
895
1026
  });
896
1027
  bot.command("reset", async (ctx) => {
897
1028
  const botId = getBotId();
@@ -900,6 +1031,88 @@ ${bridge.formatStatus(mapping)}`);
900
1031
  await bridge.reset(botId, chatId);
901
1032
  await reply(ctx, "Cleared all pairings for this chat.");
902
1033
  });
1034
+ bot.on("callback_query:data", async (ctx) => {
1035
+ const data = ctx.callbackQuery.data;
1036
+ const callbackChat = ctx.callbackQuery.message?.chat;
1037
+ if (!callbackChat) {
1038
+ await callTelegramApi(token, "answerCallbackQuery", {
1039
+ callback_query_id: ctx.callbackQuery.id,
1040
+ text: "This action is no longer available.",
1041
+ });
1042
+ return;
1043
+ }
1044
+ const botId = getBotId();
1045
+ const chatId = String(callbackChat.id);
1046
+ const queueMatch = /^remoteagent:queue:(remove:(Q\d+)|del)$/i.exec(data);
1047
+ if (queueMatch) {
1048
+ const result = await removeQueuedInstruction(botId, chatId, queueMatch[2]);
1049
+ await callTelegramApi(token, "answerCallbackQuery", {
1050
+ callback_query_id: ctx.callbackQuery.id,
1051
+ text: result.split("\n", 1)[0],
1052
+ });
1053
+ await sendTelegramMessage(token, callbackChat.id, result);
1054
+ return;
1055
+ }
1056
+ if (!data.startsWith("remoteagent:action:")) {
1057
+ return;
1058
+ }
1059
+ const action = inlineActions.resolve(data, chatId, String(ctx.from.id));
1060
+ if (!action) {
1061
+ await callTelegramApi(token, "answerCallbackQuery", {
1062
+ callback_query_id: ctx.callbackQuery.id,
1063
+ text: "This button expired. Open the command list again.",
1064
+ });
1065
+ return;
1066
+ }
1067
+ await callTelegramApi(token, "answerCallbackQuery", {
1068
+ callback_query_id: ctx.callbackQuery.id,
1069
+ text: action.kind === "macro.run" ? "Macro selected." : "Applying...",
1070
+ });
1071
+ try {
1072
+ if (action.kind === "session.switch") {
1073
+ await reply(ctx, await switchChatSession(ctx, action.selector));
1074
+ return;
1075
+ }
1076
+ if (action.kind === "session.list") {
1077
+ await replySessionList(ctx, action.showAll);
1078
+ return;
1079
+ }
1080
+ if (action.kind === "model.set") {
1081
+ await reply(ctx, await setChatModel(ctx, action.model));
1082
+ return;
1083
+ }
1084
+ if (action.kind === "macro.run") {
1085
+ const result = await runMacro(ctx, action.alias);
1086
+ if (result) {
1087
+ await reply(ctx, result);
1088
+ }
1089
+ return;
1090
+ }
1091
+ if (action.kind === "option.show") {
1092
+ await ensureOwnerControlAccess(ctx);
1093
+ await reply(ctx, formatRuntimeOptionDetail(action.option), { parse_mode: "Markdown" });
1094
+ return;
1095
+ }
1096
+ if (action.kind === "bots.refresh") {
1097
+ await replyBotList(ctx);
1098
+ return;
1099
+ }
1100
+ if (action.mode === "danger-full-access" && !action.confirmed) {
1101
+ await reply(ctx, "Confirm Codex sandbox change to danger-full-access.", keyboardOptions([[
1102
+ actionButton(ctx, "Confirm danger-full-access", {
1103
+ kind: "sandbox.set",
1104
+ mode: "danger-full-access",
1105
+ confirmed: true,
1106
+ }),
1107
+ ]]));
1108
+ return;
1109
+ }
1110
+ await reply(ctx, await setChatSandbox(ctx, action.mode));
1111
+ }
1112
+ catch (error) {
1113
+ await reply(ctx, error instanceof Error ? error.message : String(error));
1114
+ }
1115
+ });
903
1116
  bot.on("message", async (ctx) => {
904
1117
  const botId = getBotId();
905
1118
  const chatId = String(ctx.chat.id);
@@ -1197,14 +1410,17 @@ async function runWithPendingAnimation(botToken, chatId, task) {
1197
1410
  pulseTyping();
1198
1411
  try {
1199
1412
  const helpers = {
1200
- reportProgress: async (chunks, parseMode) => {
1413
+ reportProgress: async (chunks, parseMode, messageOptions) => {
1201
1414
  const normalized = await normalizeTelegramDelivery(chunks);
1202
1415
  const progressChunks = flattenChunks(normalized.chunks, 3900);
1203
1416
  if (progressChunks.length === 0 && normalized.documents.length === 0) {
1204
1417
  return;
1205
1418
  }
1206
1419
  const rendered = formatProviderTelegramChunks(progressChunks, parseMode);
1207
- const extra = rendered.parseMode ? { parse_mode: rendered.parseMode } : undefined;
1420
+ const extra = {
1421
+ ...messageOptions,
1422
+ ...(rendered.parseMode ? { parse_mode: rendered.parseMode } : {}),
1423
+ };
1208
1424
  for (const chunk of rendered.chunks) {
1209
1425
  await sendTelegramMessage(botToken, chatId, chunk, extra).catch((error) => {
1210
1426
  console.warn(`[telegram-progress-delivery] chat=${chatId} dropped progress message: ${formatTelegramDeliveryError(error)}`);
@@ -1275,10 +1491,24 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1275
1491
  });
1276
1492
  try {
1277
1493
  await bridge.logSystem(botId, chatId, `Queued overlapping Telegram work loop ${queuedEntry.id} for ${activeKey}.`);
1278
- await helpers.reportProgress([
1279
- `Queued instruction ${queuedEntry.id} for ${currentSession?.session.publicId ?? "this session"}. It will run after the active work finishes.`,
1280
- `Remove it with \`/queue remove ${queuedEntry.id}\`, or remove the latest queued instruction with \`/queue del\`.`,
1281
- ]);
1494
+ await helpers.reportProgress([[
1495
+ `Queued instruction ${queuedEntry.id} for ${currentSession?.session.publicId ?? "this session"}. It will run after the active work finishes.`,
1496
+ "",
1497
+ `Remove it with \`/queue remove ${queuedEntry.id}\`, or remove the latest queued instruction with \`/queue del\`.`,
1498
+ ].join("\n")], undefined, {
1499
+ reply_markup: JSON.stringify({
1500
+ inline_keyboard: [[
1501
+ {
1502
+ text: `/queue remove ${queuedEntry.id}`,
1503
+ callback_data: `remoteagent:queue:remove:${queuedEntry.id}`,
1504
+ },
1505
+ {
1506
+ text: "/queue del",
1507
+ callback_data: "remoteagent:queue:del",
1508
+ },
1509
+ ]],
1510
+ }),
1511
+ });
1282
1512
  await previousTail.catch(() => undefined);
1283
1513
  }
1284
1514
  catch (error) {
@@ -1328,6 +1558,7 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1328
1558
  let missingEvidenceRetryCount = 0;
1329
1559
  let deliveredProgressCount = 0;
1330
1560
  let providerCompleted = false;
1561
+ const streamedProgressKeys = new Set();
1331
1562
  const ensureStillBound = async (phase) => {
1332
1563
  if (!sessionId) {
1333
1564
  return;
@@ -1339,6 +1570,23 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1339
1570
  await bridge.stopSessionRun(sessionId, botId, chatId, `Telegram work loop stopped during ${phase} because the chat is now bound to another session.`);
1340
1571
  throw new SilentTelegramAbort(`Session ${currentSession?.session.publicId ?? sessionId} is no longer bound to this chat.`);
1341
1572
  };
1573
+ const deliverStreamedProgress = async (response) => {
1574
+ const parsed = parseReportResponses(bridge.formatResponses([response]), transform);
1575
+ if (parsed.kind !== "progress" || parsed.chunks.length === 0) {
1576
+ return;
1577
+ }
1578
+ const key = progressDeliveryKey(parsed.chunks);
1579
+ if (streamedProgressKeys.has(key)) {
1580
+ return;
1581
+ }
1582
+ streamedProgressKeys.add(key);
1583
+ deliveredProgressCount += 1;
1584
+ await ensureStillBound(`${label} streamed progress delivery`);
1585
+ if (currentSession) {
1586
+ await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
1587
+ }
1588
+ await helpers.reportProgress(parsed.chunks);
1589
+ };
1342
1590
  if (currentSession) {
1343
1591
  await botManagement.markProviderRunning(botId, sessionId);
1344
1592
  }
@@ -1361,8 +1609,8 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1361
1609
  await bridge.logSystem(botId, chatId, `${turnLabel} started.`);
1362
1610
  try {
1363
1611
  const responses = sessionId
1364
- ? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt)
1365
- : await bridge.routeMessage(botId, chatId, prompt);
1612
+ ? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt, deliverStreamedProgress)
1613
+ : await bridge.routeMessage(botId, chatId, prompt, deliverStreamedProgress);
1366
1614
  await ensureStillBound(`${turnLabel} response`);
1367
1615
  const parsed = parseReportResponses(bridge.formatResponses(responses), transform);
1368
1616
  await bridge.logSystem(botId, chatId, `${turnLabel} returned ${parsed.kind}.`);
@@ -1371,21 +1619,25 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1371
1619
  if (parsed.kind === "progress") {
1372
1620
  untaggedIntentRetryCount = 0;
1373
1621
  missingEvidenceRetryCount = 0;
1374
- deliveredProgressCount += 1;
1375
- if (currentSession) {
1376
- const progress = await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
1377
- if (progress.repeated) {
1378
- const repeatedMessage = [
1379
- "Repeated progress detected. The same work pattern has appeared 3 or more times.",
1380
- "Automatic continuation stopped so the task can be inspected instead of looping.",
1381
- ].join("\n");
1382
- await bridge.logSystem(botId, chatId, repeatedMessage);
1383
- autoContinue.clear(botId, chatId, sessionId);
1384
- return [repeatedMessage];
1622
+ const key = progressDeliveryKey(parsed.chunks);
1623
+ if (!streamedProgressKeys.has(key)) {
1624
+ streamedProgressKeys.add(key);
1625
+ deliveredProgressCount += 1;
1626
+ if (currentSession) {
1627
+ const progress = await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
1628
+ if (progress.repeated) {
1629
+ const repeatedMessage = [
1630
+ "Repeated progress detected. The same work pattern has appeared 3 or more times.",
1631
+ "Automatic continuation stopped so the task can be inspected instead of looping.",
1632
+ ].join("\n");
1633
+ await bridge.logSystem(botId, chatId, repeatedMessage);
1634
+ autoContinue.clear(botId, chatId, sessionId);
1635
+ return [repeatedMessage];
1636
+ }
1385
1637
  }
1638
+ await ensureStillBound(`${turnLabel} progress delivery`);
1639
+ await helpers.reportProgress(parsed.chunks);
1386
1640
  }
1387
- await ensureStillBound(`${turnLabel} progress delivery`);
1388
- await helpers.reportProgress(parsed.chunks);
1389
1641
  if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
1390
1642
  const stopMessage = "Automatic continuation stopped after the latest progress report.";
1391
1643
  await bridge.logSystem(botId, chatId, stopMessage);
@@ -1640,6 +1892,9 @@ function parseReportResponses(formattedBlocks, transform) {
1640
1892
  const chunks = transform(parsedBlocks.map((item) => item.text));
1641
1893
  return { kind, chunks };
1642
1894
  }
1895
+ function progressDeliveryKey(chunks) {
1896
+ return chunks.join("\n").replace(/\s+/g, " ").trim();
1897
+ }
1643
1898
  function formatProviderTelegramChunks(chunks, explicitParseMode) {
1644
1899
  if (explicitParseMode) {
1645
1900
  return { chunks, parseMode: explicitParseMode };
@@ -2199,6 +2454,26 @@ function formatRuntimeOptions() {
2199
2454
  "`command-menu refresh` reapplies Telegram slash-command autocomplete without changing the saved option.",
2200
2455
  ].join("\n");
2201
2456
  }
2457
+ function formatRuntimeOptionDetail(option) {
2458
+ if (option === "retry") {
2459
+ return `Current automatic continuation retry limit: ${formatRetryLimit(config.telegramAutoProgressMaxTurns)}\n\nUsage: \`/option retry <count>\``;
2460
+ }
2461
+ if (option === "intent") {
2462
+ return `Current untagged intent retry limit: ${formatRetryLimit(config.telegramUntaggedIntentRetries)}\n\nUsage: \`/option intent <count>\``;
2463
+ }
2464
+ if (option === "command-menu") {
2465
+ return `Current Telegram command menu: ${config.telegramCommandMenuEnabled ? "on" : "off"}\n\nUsage: \`/option command-menu on\`, \`/option command-menu off\`, or \`/option command-menu refresh\``;
2466
+ }
2467
+ return `Current provider execution timeout: ${formatTimeoutSeconds(config.commandTimeoutMs)}\n\nUsage: \`/option timeout <seconds>\``;
2468
+ }
2469
+ function truncateButtonLabel(value, maxLength = 48) {
2470
+ const normalized = value.replace(/\s+/g, " ").trim();
2471
+ return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`;
2472
+ }
2473
+ function workspaceLeaf(workspace) {
2474
+ const normalized = workspace.replace(/[\\/]+$/, "");
2475
+ return normalized.split(/[\\/]/).at(-1) || workspace;
2476
+ }
2202
2477
  function formatRetryLimit(value) {
2203
2478
  return value === 0 ? "unlimited" : `${value}`;
2204
2479
  }
@@ -2745,6 +3020,7 @@ async function sendTelegramMessage(botToken, chatId, text, extra) {
2745
3020
  chat_id: String(chatId),
2746
3021
  text,
2747
3022
  parse_mode: extra?.parse_mode,
3023
+ reply_markup: extra?.reply_markup,
2748
3024
  });
2749
3025
  }
2750
3026
  catch (error) {
@@ -2755,6 +3031,7 @@ async function sendTelegramMessage(botToken, chatId, text, extra) {
2755
3031
  return await callTelegramApi(botToken, "sendMessage", {
2756
3032
  chat_id: String(chatId),
2757
3033
  text: stripTelegramHtml(text),
3034
+ reply_markup: extra?.reply_markup,
2758
3035
  });
2759
3036
  }
2760
3037
  }