appback-remoteagent 0.17.1 → 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/README.md CHANGED
@@ -103,6 +103,8 @@ Current command surface implemented in `src/bot.ts`:
103
103
  | `/new` | Creates and binds a new session using the saved default mode in a new managed workspace |
104
104
  | `/switch <session>` | Rebinds this chat to an existing RemoteAgent session |
105
105
  | `/status` | Shows current session, workspace, provider, and sandbox state |
106
+ | `/model [name]` | Lists selectable provider models or changes the current session model |
107
+ | `/sandbox [codex <mode>]` | Lists Codex sandbox choices or changes the current session sandbox |
106
108
  | `/option retry <count>` | Sets the automatic continuation turn limit and persists it to `~/.remoteagent/.env` |
107
109
  | `/option timeout <seconds>` | Sets the provider execution timeout and persists it to `~/.remoteagent/.env` |
108
110
  | `/option intent <count>` | Sets retries for untagged intent-only provider replies and persists it to `~/.remoteagent/.env` |
@@ -110,6 +112,7 @@ Current command surface implemented in `src/bot.ts`:
110
112
  | `/state clear` | Clears the current session ledger without deleting the session |
111
113
  | `/state note <text>` | Adds an operator note to the session ledger |
112
114
  | `/bots` | Lists the currently configured Telegram bots |
115
+ | `/macro [alias\|number]` | Lists stored macros or runs one against the current session |
113
116
  | `/bot add <token>` | Adds a conversation bot, restarts the runtime, and confirms the result after restart |
114
117
  | `/bot doctor` | Checks configured Telegram bots and removes bots that Telegram reports as permanently dead |
115
118
  | `/bot remove <username\|id>` | Removes a configured Telegram bot, restarts the runtime, and confirms the result after restart |
@@ -127,6 +130,8 @@ Current command surface implemented in `src/bot.ts`:
127
130
  | `/queue remove <id>` | Removes one waiting instruction by its `Q001`-style id |
128
131
  | `/queue del` | Removes the most recently queued instruction |
129
132
 
133
+ Selection-oriented replies include Telegram inline buttons for sessions, models, macros, runtime option details, sandbox modes, queued instructions, and configured bot links. Text commands remain the canonical compatible interface. Callback actions use short-lived opaque IDs bound to the originating chat and user; they do not expose secrets or rely on visible list positions as internal identity. Selecting `danger-full-access` through a button requires a second confirmation.
134
+
130
135
  Multi-bot polling is tiered by recent activity and active provider work. See [docs/BOT_POLLING_POLICY.md](./docs/BOT_POLLING_POLICY.md).
131
136
 
132
137
  ### 2. Terminal control
@@ -115,10 +115,7 @@ export class CodexAdapter {
115
115
  }
116
116
  let text;
117
117
  try {
118
- const event = JSON.parse(line);
119
- text = event.type === "item.completed" && event.item?.type === "agent_message"
120
- ? event.item.text?.trim()
121
- : undefined;
118
+ text = this.extractEventAgentText(JSON.parse(line));
122
119
  }
123
120
  catch {
124
121
  // Ignore non-JSON stdout and let the normal final-response parser handle it.
@@ -128,6 +125,27 @@ export class CodexAdapter {
128
125
  await onProgress(text);
129
126
  }
130
127
  }
128
+ extractEventAgentText(event) {
129
+ if (!event || typeof event !== "object") {
130
+ return undefined;
131
+ }
132
+ const record = event;
133
+ if (record.type === "item.completed" && record.item?.type === "agent_message") {
134
+ return record.item.text?.trim();
135
+ }
136
+ if (record.type === "event_msg" && record.payload?.type === "agent_message") {
137
+ return (record.payload.message ?? record.payload.text)?.trim();
138
+ }
139
+ if (record.type === "response_item" && record.payload?.type === "message") {
140
+ const text = record.payload.content
141
+ ?.filter((item) => item.type === "output_text" && typeof item.text === "string")
142
+ .map((item) => item.text)
143
+ .join("\n")
144
+ .trim();
145
+ return text || record.payload.text?.trim();
146
+ }
147
+ return undefined;
148
+ }
131
149
  extractThreadId(stdout) {
132
150
  for (const line of stdout.split(/\r?\n/)) {
133
151
  if (!line.startsWith("{")) {
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,20 @@ 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
+ };
232
282
  const removeQueuedInstruction = async (botId, chatId, selector) => {
233
283
  const mapping = await bridge.status(botId, chatId);
234
284
  const activeKey = workLoopKey(botId, chatId, mapping?.session.sessionId);
@@ -329,14 +379,14 @@ ${bridge.formatStatus(mapping)}`);
329
379
  bot.command("help", async (ctx) => {
330
380
  await reply(ctx, HELP_TEXT);
331
381
  });
332
- const replySessionList = async (ctx) => {
382
+ const replySessionList = async (ctx, requestedShowAll) => {
333
383
  if (!ctx.chat) {
334
384
  throw new Error("Telegram chat context is missing.");
335
385
  }
336
386
  const botId = getBotId();
337
387
  const chatId = String(ctx.chat.id);
338
388
  const { args } = parseCommand(ctx.message?.text, 1);
339
- const showAll = args[0] === "-a" || args[0] === "--all";
389
+ const showAll = requestedShowAll ?? (args[0] === "-a" || args[0] === "--all");
340
390
  const [mapping, sessions] = await Promise.all([
341
391
  bridge.status(botId, chatId),
342
392
  bridge.listSessions(),
@@ -345,9 +395,33 @@ ${bridge.formatStatus(mapping)}`);
345
395
  const sessionList = showAll
346
396
  ? await bridge.formatSessionListDetailed(sessions, mapping?.session.sessionId, await bridge.listActiveSessionIds())
347
397
  : bridge.formatSessionList(sessions, mapping?.session.sessionId);
348
- for (const chunk of flattenChunks([`${sessionList}\n\n${botSummary}`], 3900)) {
349
- 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.");
350
423
  }
424
+ return `Switched this chat to session ${mapping.session.publicId}.\n\n${bridge.formatCurrentSession(mapping)}`;
351
425
  };
352
426
  bot.command("list", async (ctx) => {
353
427
  await replySessionList(ctx);
@@ -361,8 +435,6 @@ ${bridge.formatStatus(mapping)}`);
361
435
  await reply(ctx, `Created and bound a new ${mapping.session.mode} session.\n\n${bridge.formatCurrentSession(mapping)}`);
362
436
  });
363
437
  bot.command("switch", async (ctx) => {
364
- const botId = getBotId();
365
- const chatId = String(ctx.chat.id);
366
438
  const { args } = parseCommand(ctx.message?.text, 1);
367
439
  const sessionId = args[0];
368
440
  if (!sessionId) {
@@ -371,16 +443,7 @@ ${bridge.formatStatus(mapping)}`);
371
443
  });
372
444
  return;
373
445
  }
374
- const previous = await bridge.status(botId, chatId).catch(() => undefined);
375
- const mapping = await bridge.switchSession(botId, chatId, sessionId);
376
- if (previous && previous.session.sessionId !== mapping.session.sessionId) {
377
- autoContinue.requestSessionStop(previous.session.sessionId);
378
- cancelQueuedWorkLoops(botId, chatId, previous.session.sessionId);
379
- messageBatcher.cancelPending(botId, chatId);
380
- messageBatcher.cancelManual(botId, chatId);
381
- await bridge.stopSessionRun(previous.session.sessionId, botId, chatId, "Chat switched to another session; previous session execution was stopped.");
382
- }
383
- await reply(ctx, `Switched this chat to session ${sessionId}.\n\n${bridge.formatCurrentSession(mapping)}`);
446
+ await reply(ctx, await switchChatSession(ctx, sessionId));
384
447
  });
385
448
  bot.command("plan", async (ctx) => {
386
449
  const { args, rest } = parseCommand(ctx.message?.text, 1);
@@ -449,6 +512,14 @@ ${bridge.formatStatus(mapping)}`);
449
512
 
450
513
  ${bridge.formatStatus(mapping)}`);
451
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
+ };
452
523
  bot.command("model", async (ctx) => {
453
524
  const botId = getBotId();
454
525
  const chatId = String(ctx.chat.id);
@@ -461,13 +532,17 @@ ${bridge.formatStatus(mapping)}`);
461
532
  return;
462
533
  }
463
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
+ ]);
464
539
  await reply(ctx, await bridge.formatModelSelection(botId, chatId), {
465
540
  parse_mode: "Markdown",
541
+ ...(keyboardOptions(rows) ?? {}),
466
542
  });
467
543
  return;
468
544
  }
469
- const mapping = await bridge.setModel(botId, chatId, model);
470
- await reply(ctx, `Set ${mapping.session.mode} model to ${model}.\n\n${bridge.formatStatus(mapping)}`);
545
+ await reply(ctx, await setChatModel(ctx, model));
471
546
  });
472
547
  bot.command("queue", async (ctx) => {
473
548
  const botId = getBotId();
@@ -482,7 +557,15 @@ ${bridge.formatStatus(mapping)}`);
482
557
  return;
483
558
  }
484
559
  if (!action || action === "list") {
485
- 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));
486
569
  return;
487
570
  }
488
571
  if ((action === "remove" || action === "rm") && !selector) {
@@ -533,7 +616,16 @@ ${bridge.formatStatus(mapping)}`);
533
616
  const option = args[0]?.toLowerCase();
534
617
  const value = args[1];
535
618
  if (!option) {
536
- 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
+ ]));
537
629
  return;
538
630
  }
539
631
  if (option !== "retry" && option !== "timeout" && option !== "intent" && option !== "command-menu") {
@@ -543,14 +635,7 @@ ${bridge.formatStatus(mapping)}`);
543
635
  return;
544
636
  }
545
637
  if (!value) {
546
- const current = option === "retry"
547
- ? `Current automatic continuation retry limit: ${formatRetryLimit(config.telegramAutoProgressMaxTurns)}\n\nUsage: \`/option retry <count>\``
548
- : option === "intent"
549
- ? `Current untagged intent retry limit: ${formatRetryLimit(config.telegramUntaggedIntentRetries)}\n\nUsage: \`/option intent <count>\``
550
- : option === "command-menu"
551
- ? `Current Telegram command menu: ${config.telegramCommandMenuEnabled ? "on" : "off"}\n\nUsage: \`/option command-menu on\`, \`/option command-menu off\`, or \`/option command-menu refresh\``
552
- : `Current provider execution timeout: ${formatTimeoutSeconds(config.commandTimeoutMs)}\n\nUsage: \`/option timeout <seconds>\``;
553
- await reply(ctx, current, {
638
+ await reply(ctx, formatRuntimeOptionDetail(option), {
554
639
  parse_mode: "Markdown",
555
640
  });
556
641
  return;
@@ -762,15 +847,29 @@ ${bridge.formatStatus(mapping)}`);
762
847
  }
763
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" });
764
849
  });
765
- const handleMacroCommand = async (ctx, text) => {
850
+ const runMacro = async (ctx, target) => {
766
851
  if (!ctx.chat) {
767
852
  throw new Error("Telegram chat context is missing.");
768
853
  }
769
854
  const botId = getBotId();
770
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();
771
866
  const parsed = parseMacroCommandText(text, botId);
772
867
  if (parsed.kind === "help") {
773
- 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));
774
873
  return;
775
874
  }
776
875
  if (parsed.kind === "set") {
@@ -785,25 +884,34 @@ ${bridge.formatStatus(mapping)}`);
785
884
  await reply(ctx, removed ? `Removed macro '${parsed.alias}'.` : `Macro was not found: ${parsed.alias}`);
786
885
  return;
787
886
  }
788
- const macro = await memoryService.getMacro(parsed.target);
789
- if (!macro) {
790
- await reply(ctx, `Macro was not found: ${parsed.target}\n\n${await memoryService.listMacros()}`);
791
- return;
887
+ const result = await runMacro(ctx, parsed.target);
888
+ if (result) {
889
+ await reply(ctx, result);
792
890
  }
793
- await bridge.logSystem(botId, chatId, `Macro executed: ${macro.alias}`);
794
- await messageBatcher.enqueue({ botToken: token, telegramChatId: ctx.chat.id }, botId, chatId, macro.prompt);
795
891
  };
796
892
  bot.command("macro", async (ctx) => {
797
893
  await handleMacroCommand(ctx, ctx.message?.text ?? "/macro");
798
894
  });
799
- bot.command("bots", async (ctx) => {
895
+ const replyBotList = async (ctx) => {
800
896
  await ensureOwnerControlAccess(ctx);
801
897
  const pendingNotice = await botManagement.getPendingOperationNotice();
802
- if (pendingNotice?.pending) {
803
- await reply(ctx, `${pendingNotice.message}\n\n${await botManagement.listBots()}`);
804
- return;
805
- }
806
- 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);
807
915
  });
808
916
  bot.command("bot", async (ctx) => {
809
917
  await ensureOwnerControlAccess(ctx);
@@ -882,20 +990,39 @@ ${bridge.formatStatus(mapping)}`);
882
990
  return { chunks: flattenChunks([output], 3900) };
883
991
  });
884
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
+ };
885
1000
  bot.command("sandbox", async (ctx) => {
886
1001
  const botId = getBotId();
887
1002
  const chatId = String(ctx.chat.id);
888
1003
  const { args } = parseCommand(ctx.message?.text, 2);
889
1004
  const provider = args[0]?.toLowerCase();
890
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
+ }
891
1019
  if (provider !== "codex" || !sandboxMode || !isCodexSandboxMode(sandboxMode)) {
892
1020
  await reply(ctx, "Usage: `/sandbox codex <read-only|workspace-write|danger-full-access>`", {
893
1021
  parse_mode: "Markdown",
894
1022
  });
895
1023
  return;
896
1024
  }
897
- const mapping = await bridge.setCodexSandboxMode(botId, chatId, sandboxMode);
898
- await reply(ctx, `Set Codex sandbox to ${sandboxMode}.\n\n${bridge.formatStatus(mapping)}`);
1025
+ await reply(ctx, await setChatSandbox(ctx, sandboxMode));
899
1026
  });
900
1027
  bot.command("reset", async (ctx) => {
901
1028
  const botId = getBotId();
@@ -905,26 +1032,86 @@ ${bridge.formatStatus(mapping)}`);
905
1032
  await reply(ctx, "Cleared all pairings for this chat.");
906
1033
  });
907
1034
  bot.on("callback_query:data", async (ctx) => {
908
- const match = /^remoteagent:queue:(remove:(Q\d+)|del)$/i.exec(ctx.callbackQuery.data);
909
- if (!match) {
910
- return;
911
- }
1035
+ const data = ctx.callbackQuery.data;
912
1036
  const callbackChat = ctx.callbackQuery.message?.chat;
913
1037
  if (!callbackChat) {
914
1038
  await callTelegramApi(token, "answerCallbackQuery", {
915
1039
  callback_query_id: ctx.callbackQuery.id,
916
- text: "This queue action is no longer available.",
1040
+ text: "This action is no longer available.",
917
1041
  });
918
1042
  return;
919
1043
  }
920
1044
  const botId = getBotId();
921
1045
  const chatId = String(callbackChat.id);
922
- const result = await removeQueuedInstruction(botId, chatId, match[2]);
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
+ }
923
1067
  await callTelegramApi(token, "answerCallbackQuery", {
924
1068
  callback_query_id: ctx.callbackQuery.id,
925
- text: result.split("\n", 1)[0],
1069
+ text: action.kind === "macro.run" ? "Macro selected." : "Applying...",
926
1070
  });
927
- await sendTelegramMessage(token, callbackChat.id, result);
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
+ }
928
1115
  });
929
1116
  bot.on("message", async (ctx) => {
930
1117
  const botId = getBotId();
@@ -2267,6 +2454,26 @@ function formatRuntimeOptions() {
2267
2454
  "`command-menu refresh` reapplies Telegram slash-command autocomplete without changing the saved option.",
2268
2455
  ].join("\n");
2269
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
+ }
2270
2477
  function formatRetryLimit(value) {
2271
2478
  return value === 0 ? "unlimited" : `${value}`;
2272
2479
  }
@@ -267,8 +267,7 @@ export class AgentMemoryService {
267
267
  return existed;
268
268
  }
269
269
  async getMacro(aliasOrIndex) {
270
- const macros = Object.values(await this.readMacros())
271
- .sort((left, right) => left.alias.localeCompare(right.alias, "ko"));
270
+ const macros = await this.getMacros();
272
271
  const trimmed = aliasOrIndex.trim();
273
272
  if (/^[0-9]+$/.test(trimmed)) {
274
273
  const index = Number.parseInt(trimmed, 10);
@@ -277,9 +276,12 @@ export class AgentMemoryService {
277
276
  const normalizedAlias = this.normalizeMacroAlias(trimmed);
278
277
  return macros.find((macro) => macro.alias === normalizedAlias);
279
278
  }
280
- async listMacros() {
281
- const macros = Object.values(await this.readMacros())
279
+ async getMacros() {
280
+ return Object.values(await this.readMacros())
282
281
  .sort((left, right) => left.alias.localeCompare(right.alias, "ko"));
282
+ }
283
+ async listMacros() {
284
+ const macros = await this.getMacros();
283
285
  if (macros.length === 0) {
284
286
  return "No macros are stored.";
285
287
  }
@@ -31,6 +31,9 @@ export class BotManagementService {
31
31
  }
32
32
  return this.formatBots(bots, await this.pollingState.list());
33
33
  }
34
+ async listBotChoices() {
35
+ return (await this.listConfiguredBots()).map(({ id, username }) => ({ id, username }));
36
+ }
34
37
  async formatCurrentBotSummary(currentBotId) {
35
38
  const env = await this.readEnvConfig();
36
39
  const bots = this.zipBots(env.tokens, env.usernames);
@@ -181,15 +181,12 @@ export class BridgeService {
181
181
  }, chatSession.session.workspace);
182
182
  }
183
183
  async formatModelSelection(botId, chatId) {
184
- const chatSession = await this.requireChat(botId, chatId);
185
- const provider = chatSession.session.mode;
186
- this.ensurePaired(chatSession, provider);
187
- const providerSession = chatSession.session[provider];
188
- const presets = MODEL_PRESETS[provider] ?? [];
184
+ const selection = await this.getModelSelection(botId, chatId);
185
+ const { provider, currentModel, presets } = selection;
189
186
  const lines = [
190
- `session: ${chatSession.session.publicId}`,
187
+ `session: ${selection.sessionPublicId}`,
191
188
  `mode: ${provider}`,
192
- `currentModel: ${providerSession.model ?? this.defaultModelFor(provider)}`,
189
+ `currentModel: ${currentModel}`,
193
190
  "availablePresets:",
194
191
  ...presets.map((item, index) => ` ${index + 1}. ${item}`),
195
192
  "",
@@ -200,6 +197,18 @@ export class BridgeService {
200
197
  }
201
198
  return lines.join("\n");
202
199
  }
200
+ async getModelSelection(botId, chatId) {
201
+ const chatSession = await this.requireChat(botId, chatId);
202
+ const provider = chatSession.session.mode;
203
+ this.ensurePaired(chatSession, provider);
204
+ const providerSession = chatSession.session[provider];
205
+ return {
206
+ sessionPublicId: chatSession.session.publicId,
207
+ provider,
208
+ currentModel: providerSession.model ?? this.defaultModelFor(provider),
209
+ presets: [...(MODEL_PRESETS[provider] ?? [])],
210
+ };
211
+ }
203
212
  async status(botId, chatId) {
204
213
  return this.store.getChatSession(botId, chatId);
205
214
  }
package/docs/RELEASING.md CHANGED
@@ -220,3 +220,25 @@ npm run selftest:telegram
220
220
  npm run release:publish
221
221
  npm run release:deploy -- 0.17.1 30
222
222
  ```
223
+
224
+ ## Release 0.18.0
225
+
226
+ Date: 2026-08-03
227
+
228
+ Changes:
229
+
230
+ - Telegram list and option responses provide inline command controls.
231
+ - Queue notices provide inline remove-latest and remove-by-id controls in one message.
232
+ - Codex progress streaming accepts both normalized CLI `item.completed` events and raw `event_msg`/`response_item` agent-message events.
233
+ - The Codex stream self-test covers every supported progress event shape and keeps final results out of the progress callback.
234
+
235
+ Validated:
236
+
237
+ ```bash
238
+ npm run check
239
+ npm run build
240
+ npm run selftest:codex-stream
241
+ npm run selftest:telegram
242
+ npm run release:publish
243
+ npm run release:deploy -- 0.18.0 30
244
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "Personal installable session server for continuing local AI work across PC and Telegram",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,9 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"stream-thread"}'
23
23
  printf '%s' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:progress\\nphase one"}}'
24
24
  printf '\\n'
25
25
  sleep 0.05
26
- printf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:progress\\nphase two"}}'
26
+ printf '%s\\n' '{"type":"event_msg","payload":{"type":"agent_message","message":"REPORT:progress\\nphase two"}}'
27
+ sleep 0.05
28
+ printf '%s\\n' '{"type":"response_item","payload":{"type":"message","content":[{"type":"output_text","text":"REPORT:progress\\nphase three"}]}}'
27
29
  sleep 0.05
28
30
  printf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:result\\nfinished"}}'
29
31
  printf '%s\\n' 'REPORT:result' 'finished' > "$output"
@@ -50,7 +52,12 @@ const responsePromise = adapter.send({
50
52
  const response = await responsePromise;
51
53
  settled = true;
52
54
 
53
- if (progress.length !== 2 || !progress[0]?.includes("phase one") || !progress[1]?.includes("phase two")) {
55
+ if (
56
+ progress.length !== 3
57
+ || !progress[0]?.includes("phase one")
58
+ || !progress[1]?.includes("phase two")
59
+ || !progress[2]?.includes("phase three")
60
+ ) {
54
61
  throw new Error(`Unexpected streamed progress: ${JSON.stringify(progress)}`);
55
62
  }
56
63
  if (progress.some((item) => item.includes("REPORT:result"))) {
@@ -254,6 +254,14 @@ async function click(data) {
254
254
  await injectedBot.handleUpdates([callbackUpdate(data)]);
255
255
  }
256
256
 
257
+ function findInlineButton(call, label) {
258
+ if (!call?.reply_markup) {
259
+ return undefined;
260
+ }
261
+ const markup = JSON.parse(call.reply_markup);
262
+ return markup.inline_keyboard?.flat().find((button) => button.text === label);
263
+ }
264
+
257
265
  async function readTelegramCalls() {
258
266
  return (await fs.readFile(telegramCalls, "utf8"))
259
267
  .trim()
@@ -440,6 +448,83 @@ if (calls.some((call) => /미완료 TODO|\/task|새 작업으로 접수/.test(ca
440
448
  throw new Error(`Task gate language leaked to Telegram replies. Calls: ${JSON.stringify(calls, null, 2)}`);
441
449
  }
442
450
 
451
+ await send("/new");
452
+ await send("/list");
453
+ const sessionListCall = await waitForTelegramCall((call) => call.text.includes("Sessions (2/2)"));
454
+ const firstSessionButton = findInlineButton(sessionListCall, `S001 · ${path.basename(session.workspace)}`);
455
+ if (!firstSessionButton?.callback_data?.startsWith("remoteagent:action:")) {
456
+ throw new Error(`Session switch button is missing: ${sessionListCall.reply_markup}`);
457
+ }
458
+ await click(firstSessionButton.callback_data);
459
+ await waitForTelegramCall((call) => call.text.includes("Switched this chat to session S001."));
460
+
461
+ await send("/model");
462
+ const modelListCall = await waitForTelegramCall((call) => call.text.includes("availablePresets:"));
463
+ const modelButton = findInlineButton(modelListCall, "gpt-5.6-terra");
464
+ if (!modelButton?.callback_data) {
465
+ throw new Error(`Model selection button is missing: ${modelListCall.reply_markup}`);
466
+ }
467
+ await click(modelButton.callback_data);
468
+ await waitForTelegramCall((call) => call.text.includes("Set codex model to gpt-5.6-terra."));
469
+ const modelState = JSON.parse(await fs.readFile(path.join(dataDir, "state.json"), "utf8"));
470
+ if (modelState.sessions[session.sessionId]?.codex?.model !== "gpt-5.6-terra") {
471
+ throw new Error("Model button did not update the bound session model");
472
+ }
473
+
474
+ await send("/option");
475
+ const optionListCall = await waitForTelegramCall((call) => call.text.startsWith("Runtime options"));
476
+ const timeoutButton = findInlineButton(optionListCall, "Timeout");
477
+ if (!timeoutButton?.callback_data) {
478
+ throw new Error(`Runtime option button is missing: ${optionListCall.reply_markup}`);
479
+ }
480
+ await click(timeoutButton.callback_data);
481
+ await waitForTelegramCall((call) => call.text.includes("Current provider execution timeout: 600s"));
482
+
483
+ await send("/sandbox");
484
+ const sandboxListCall = await waitForTelegramCall((call) => call.text.startsWith("Codex sandbox"));
485
+ const readOnlyButton = findInlineButton(sandboxListCall, "read-only");
486
+ const dangerButton = findInlineButton(sandboxListCall, "danger-full-access");
487
+ if (!readOnlyButton?.callback_data || !dangerButton?.callback_data) {
488
+ throw new Error(`Sandbox selection buttons are missing: ${sandboxListCall.reply_markup}`);
489
+ }
490
+ await click(readOnlyButton.callback_data);
491
+ await waitForTelegramCall((call) => call.text.includes("Set Codex sandbox to read-only."));
492
+ await click(dangerButton.callback_data);
493
+ const sandboxConfirmCall = await waitForTelegramCall((call) => call.text.includes("Confirm Codex sandbox change"));
494
+ if (!findInlineButton(sandboxConfirmCall, "Confirm danger-full-access")?.callback_data) {
495
+ throw new Error(`Danger sandbox confirmation button is missing: ${sandboxConfirmCall.reply_markup}`);
496
+ }
497
+
498
+ await send("/macro set button-test inspect the callback path");
499
+ await send("/batch start");
500
+ await send("/macro");
501
+ const macroListCall = await waitForTelegramCall((call) => call.text.includes("Macros (1)"));
502
+ const macroButton = findInlineButton(macroListCall, "button-test");
503
+ if (!macroButton?.callback_data) {
504
+ throw new Error(`Macro execution button is missing: ${macroListCall.reply_markup}`);
505
+ }
506
+ await click(macroButton.callback_data);
507
+ await send("/batch send");
508
+ await waitForTelegramCall((call) => call.text.includes("mock provider completed"));
509
+
510
+ await fs.appendFile(path.join(dataDir, ".env"), [
511
+ "TELEGRAM_BOT_TOKENS=000000:test-token",
512
+ "TELEGRAM_BOT_USERNAMES=remoteagent_test_bot",
513
+ "",
514
+ ].join("\n"), "utf8");
515
+ await send("/bots");
516
+ const botsCall = await waitForTelegramCall((call) => call.text.includes("Configured bots (1)"));
517
+ const botLink = findInlineButton(botsCall, "@bot_0");
518
+ const refreshButton = findInlineButton(botsCall, "Refresh");
519
+ if (botLink?.url !== "https://t.me/bot_0" || !refreshButton?.callback_data) {
520
+ throw new Error(`Bot link or refresh button is missing: ${botsCall.reply_markup}`);
521
+ }
522
+ await click(refreshButton.callback_data);
523
+ const refreshedBotsCalls = (await readTelegramCalls()).filter((call) => call.text.includes("Configured bots (1)"));
524
+ if (refreshedBotsCalls.length < 2) {
525
+ throw new Error("Bot refresh callback did not render the bot list again");
526
+ }
527
+
443
528
  providerMode = "timeout";
444
529
  await send("/batch start");
445
530
  await send("timeout regression test");