appback-remoteagent 0.13.21 → 0.14.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/.env.example CHANGED
@@ -1,6 +1,5 @@
1
1
  TELEGRAM_BOT_TOKEN=your-telegram-bot-token
2
2
  TELEGRAM_BOT_TOKENS=
3
- TELEGRAM_MAIN_BOT_ID=
4
3
  TELEGRAM_OWNER_ID=
5
4
  TELEGRAM_MESSAGE_BATCH_MS=1500
6
5
  TELEGRAM_AUTO_PROGRESS_MAX_TURNS=6
@@ -9,12 +8,11 @@ TELEGRAM_RETRYABLE_ERROR_RETRIES=2
9
8
  TELEGRAM_RETRYABLE_ERROR_DELAY_MS=5000
10
9
  TELEGRAM_UNTAGGED_INTENT_RETRIES=2
11
10
  TELEGRAM_SCHEDULER_TICK_MS=1000
12
- TELEGRAM_TIERED_POLLING_MIN_BOTS=4
11
+ TELEGRAM_TIERED_POLLING_MIN_BOTS=5
13
12
  TELEGRAM_ACTIVE_POLL_INTERVAL_MS=3000
14
- TELEGRAM_IDLE_POLL_INTERVAL_MS=30000
15
- TELEGRAM_COLD_POLL_INTERVAL_MS=300000
16
- TELEGRAM_ACTIVE_IDLE_MS=1800000
17
- TELEGRAM_COLD_IDLE_MS=21600000
13
+ TELEGRAM_RUNNING_POLL_INTERVAL_MS=60000
14
+ TELEGRAM_SECONDARY_POLL_INTERVAL_MS=60000
15
+ TELEGRAM_TERTIARY_POLL_INTERVAL_MS=180000
18
16
  ARTIFACT_CLEANUP_ENABLED=true
19
17
  ARTIFACT_RETENTION_DAYS=30
20
18
  ARTIFACT_CLEANUP_INTERVAL_MS=86400000
package/README.md CHANGED
@@ -112,11 +112,8 @@ Current command surface implemented in `src/bot.ts`:
112
112
  | `/bots` | Lists the currently configured Telegram bots |
113
113
  | `/bot add <token>` | Adds a conversation bot, restarts the runtime, and confirms the result after restart |
114
114
  | `/bot doctor` | Checks configured Telegram bots and removes bots that Telegram reports as permanently dead |
115
- | `/bot main <number\|@username\|id>` | Selects the main bot. If no valid main is configured, the oldest configured bot is used |
116
115
  | `/bot remove <username\|id>` | Removes a configured Telegram bot, restarts the runtime, and confirms the result after restart |
117
116
  | `/bot reload` | Restarts the runtime and confirms the result after restart |
118
- | `/sleep [bot]` | Puts a sub bot into deep sleep without removing its configuration |
119
- | `/wake <bot>` | Wakes a sleeping sub bot |
120
117
  | `/install codex\|claude` | Runs the configured provider install or update command for the bot owner |
121
118
  | `/login codex` | Starts the Codex device-auth login flow and returns a browser URL when available |
122
119
  | `/login claude [token]` | Starts or finishes the configured Claude Code login flow for the bot owner |
@@ -127,7 +124,7 @@ Current command surface implemented in `src/bot.ts`:
127
124
  | `/batch cancel` | Discards the current batch |
128
125
  | `/batch status` | Shows current batch state |
129
126
 
130
- Bot sleep keeps long-idle sub bots configured while stopping their polling load. See [docs/BOT_SLEEP.md](./docs/BOT_SLEEP.md).
127
+ Multi-bot polling is tiered by recent activity and active provider work. See [docs/BOT_POLLING_POLICY.md](./docs/BOT_POLLING_POLICY.md).
131
128
 
132
129
  ### 2. Terminal control
133
130
 
@@ -31,7 +31,7 @@ export class CodexAdapter {
31
31
  };
32
32
  }
33
33
  if (code !== 0) {
34
- throw new Error(this.formatProcessError(stdout, stderr, timedOut));
34
+ throw new Error(this.formatProcessError(stdout, stderr, timedOut, code));
35
35
  }
36
36
  if (timedOut) {
37
37
  throw new Error(this.formatTimeoutError());
@@ -147,7 +147,7 @@ export class CodexAdapter {
147
147
  async readOutput(outputPath) {
148
148
  return (await fs.readFile(outputPath, "utf8").catch(() => "")).trim();
149
149
  }
150
- formatProcessError(stdout, stderr, timedOut = false) {
150
+ formatProcessError(stdout, stderr, timedOut = false, code) {
151
151
  const structured = this.extractStructuredError(stdout, stderr);
152
152
  if (structured) {
153
153
  return structured;
@@ -158,7 +158,7 @@ export class CodexAdapter {
158
158
  }
159
159
  return timedOut
160
160
  ? this.formatTimeoutError()
161
- : "Codex execution failed without any output.";
161
+ : `Codex process exited with code ${code ?? "unknown"} without stdout/stderr.`;
162
162
  }
163
163
  extractStructuredError(stdout, stderr) {
164
164
  const messages = [];
package/dist/bot.js CHANGED
@@ -33,11 +33,8 @@ const HELP_TEXT = [
33
33
  "/bots",
34
34
  "/bot add <token>",
35
35
  "/bot doctor",
36
- "/bot main <number|@username|id>",
37
36
  "/bot remove <username|id>",
38
37
  "/bot reload",
39
- "/sleep [bot]",
40
- "/wake <bot>",
41
38
  "/install codex|claude",
42
39
  "/login codex",
43
40
  "/login claude [token]",
@@ -86,8 +83,6 @@ const RECOGNIZED_COMMANDS = new Set([
86
83
  "docs",
87
84
  "bots",
88
85
  "bot",
89
- "sleep",
90
- "wake",
91
86
  "install",
92
87
  "login",
93
88
  "reset",
@@ -232,7 +227,7 @@ export function createBot(token, bridge, botManagement, botInfo) {
232
227
  await bridge.logSystem(botId, chatId, `Telegram text dispatch (${text.length} chars).`);
233
228
  await runWithPendingAnimation(target.botToken, target.telegramChatId, async (helpers) => {
234
229
  return {
235
- chunks: await routeTelegramWorkLoop(bridge, botId, chatId, text, "Telegram text request", helpers, autoContinue, memoryService),
230
+ chunks: await routeTelegramWorkLoop(bridge, botId, chatId, text, "Telegram text request", botManagement, helpers, autoContinue, memoryService),
236
231
  };
237
232
  });
238
233
  });
@@ -701,8 +696,8 @@ ${bridge.formatStatus(mapping)}`);
701
696
  const sourceBotId = getBotId();
702
697
  const { args, rest } = parseCommand(ctx.message?.text, 2);
703
698
  const action = args[0]?.toLowerCase();
704
- if (!action || !["add", "doctor", "main", "remove", "reload"].includes(action)) {
705
- await reply(ctx, "Usage: `/bot add <token>`, `/bot doctor`, `/bot main <number|@username|id>`, `/bot remove <username|id>`, or `/bot reload`", {
699
+ if (!action || !["add", "doctor", "remove", "reload"].includes(action)) {
700
+ await reply(ctx, "Usage: `/bot add <token>`, `/bot doctor`, `/bot remove <username|id>`, or `/bot reload`", {
706
701
  parse_mode: "Markdown",
707
702
  });
708
703
  return;
@@ -725,11 +720,6 @@ ${bridge.formatStatus(mapping)}`);
725
720
  await reply(ctx, result.message);
726
721
  return;
727
722
  }
728
- if (action === "main") {
729
- const result = await botManagement.setMainBot(commandTarget(args, rest));
730
- await reply(ctx, result.message);
731
- return;
732
- }
733
723
  if (action === "remove") {
734
724
  const result = await botManagement.removeBot(commandTarget(args, rest), sourceBotId, sourceBotToken, ctx.chat.id);
735
725
  await reply(ctx, result.message);
@@ -738,19 +728,6 @@ ${bridge.formatStatus(mapping)}`);
738
728
  const result = await botManagement.reloadBots(sourceBotId, sourceBotToken, ctx.chat.id);
739
729
  await reply(ctx, result.message);
740
730
  });
741
- bot.command("sleep", async (ctx) => {
742
- await ensureOwnerControlAccess(ctx);
743
- const sourceBotId = getBotId();
744
- const { args, rest } = parseCommand(ctx.message?.text, 1);
745
- const result = await botManagement.sleepBot(commandTarget(args, rest), sourceBotId);
746
- await reply(ctx, result.message);
747
- });
748
- bot.command("wake", async (ctx) => {
749
- await ensureOwnerControlAccess(ctx);
750
- const { args, rest } = parseCommand(ctx.message?.text, 1);
751
- const result = await botManagement.wakeBot(commandTarget(args, rest));
752
- await reply(ctx, result.message);
753
- });
754
731
  bot.command("install", async (ctx) => {
755
732
  await ensureOwnerControlAccess(ctx);
756
733
  const { args } = parseCommand(ctx.message?.text, 1);
@@ -849,7 +826,7 @@ ${bridge.formatStatus(mapping)}`);
849
826
  await bridge.logSystem(botId, chatId, `Telegram image received: ${downloaded.path}`);
850
827
  await runWithPendingAnimation(token, ctx.chat.id, async (helpers) => {
851
828
  return {
852
- chunks: await routeTelegramWorkLoop(bridge, botId, chatId, message, "Telegram image request", helpers, autoContinue, memoryService, sanitizeAttachmentResponseBlocks),
829
+ chunks: await routeTelegramWorkLoop(bridge, botId, chatId, message, "Telegram image request", botManagement, helpers, autoContinue, memoryService, sanitizeAttachmentResponseBlocks),
853
830
  };
854
831
  });
855
832
  return;
@@ -874,7 +851,7 @@ ${bridge.formatStatus(mapping)}`);
874
851
  await bridge.logSystem(botId, chatId, `Telegram ${attachment.kind} received: ${downloaded.path}`);
875
852
  await runWithPendingAnimation(token, ctx.chat.id, async (helpers) => {
876
853
  return {
877
- chunks: await routeTelegramWorkLoop(bridge, botId, chatId, message, `Telegram ${attachment.kind} request`, helpers, autoContinue, memoryService, sanitizeAttachmentResponseBlocks),
854
+ chunks: await routeTelegramWorkLoop(bridge, botId, chatId, message, `Telegram ${attachment.kind} request`, botManagement, helpers, autoContinue, memoryService, sanitizeAttachmentResponseBlocks),
878
855
  };
879
856
  });
880
857
  return;
@@ -895,7 +872,7 @@ ${bridge.formatStatus(mapping)}`);
895
872
  await bridge.logSystem(botId, chatId, `Telegram ${attachmentKind} received: ${downloaded.path}`);
896
873
  await runWithPendingAnimation(token, ctx.chat.id, async (helpers) => {
897
874
  return {
898
- chunks: await routeTelegramWorkLoop(bridge, botId, chatId, message, `Telegram ${attachmentKind} request`, helpers, autoContinue, memoryService, sanitizeAttachmentResponseBlocks),
875
+ chunks: await routeTelegramWorkLoop(bridge, botId, chatId, message, `Telegram ${attachmentKind} request`, botManagement, helpers, autoContinue, memoryService, sanitizeAttachmentResponseBlocks),
899
876
  };
900
877
  });
901
878
  return;
@@ -1148,7 +1125,7 @@ async function runWithPendingAnimation(botToken, chatId, task) {
1148
1125
  }
1149
1126
  }
1150
1127
  }
1151
- async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, helpers, autoContinue, memoryService, transform = (blocks) => blocks) {
1128
+ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botManagement, helpers, autoContinue, memoryService, transform = (blocks) => blocks) {
1152
1129
  const currentSession = await bridge.status(botId, chatId);
1153
1130
  const sessionId = currentSession?.session.sessionId;
1154
1131
  if (currentSession) {
@@ -1180,154 +1157,164 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, help
1180
1157
  await bridge.stopSessionRun(sessionId, botId, chatId, `Telegram work loop stopped during ${phase} because the chat is now bound to another session.`);
1181
1158
  throw new SilentTelegramAbort(`Session ${currentSession?.session.publicId ?? sessionId} is no longer bound to this chat.`);
1182
1159
  };
1183
- for (let turn = 1;; turn += 1) {
1184
- if (typeof maxTurns === "number" && maxTurns > 0 && turn > maxTurns) {
1185
- const limitMessage = `Automatic continue limit (${maxTurns}) reached before a final result.`;
1186
- await bridge.logSystem(botId, chatId, limitMessage);
1187
- autoContinue.clear(botId, chatId, sessionId);
1188
- return [limitMessage];
1189
- }
1190
- if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
1191
- const stopMessage = "Automatic continuation stopped.";
1192
- await bridge.logSystem(botId, chatId, stopMessage);
1193
- autoContinue.clear(botId, chatId, sessionId);
1194
- return [stopMessage];
1195
- }
1196
- await ensureStillBound(`turn ${turn} start`);
1197
- const turnLabel = `${label} turn ${turn}`;
1198
- await bridge.logSystem(botId, chatId, `${turnLabel} started.`);
1199
- try {
1200
- const responses = sessionId
1201
- ? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt)
1202
- : await bridge.routeMessage(botId, chatId, prompt);
1203
- await ensureStillBound(`${turnLabel} response`);
1204
- const parsed = parseReportResponses(bridge.formatResponses(responses), transform);
1205
- await bridge.logSystem(botId, chatId, `${turnLabel} returned ${parsed.kind}.`);
1206
- emptyResponseRetryCount = 0;
1207
- retryableErrorCount = 0;
1208
- if (parsed.kind === "progress") {
1209
- untaggedIntentRetryCount = 0;
1210
- missingEvidenceRetryCount = 0;
1211
- deliveredProgressCount += 1;
1212
- if (currentSession) {
1213
- const progress = await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
1214
- if (progress.repeated) {
1215
- const repeatedMessage = [
1216
- "Repeated progress detected. The same work pattern has appeared 3 or more times.",
1217
- "Automatic continuation stopped so the task can be inspected instead of looping.",
1160
+ if (currentSession) {
1161
+ await botManagement.markProviderRunning(botId, sessionId);
1162
+ }
1163
+ try {
1164
+ for (let turn = 1;; turn += 1) {
1165
+ if (typeof maxTurns === "number" && maxTurns > 0 && turn > maxTurns) {
1166
+ const limitMessage = `Automatic continue limit (${maxTurns}) reached before a final result.`;
1167
+ await bridge.logSystem(botId, chatId, limitMessage);
1168
+ autoContinue.clear(botId, chatId, sessionId);
1169
+ return [limitMessage];
1170
+ }
1171
+ if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
1172
+ const stopMessage = "Automatic continuation stopped.";
1173
+ await bridge.logSystem(botId, chatId, stopMessage);
1174
+ autoContinue.clear(botId, chatId, sessionId);
1175
+ return [stopMessage];
1176
+ }
1177
+ await ensureStillBound(`turn ${turn} start`);
1178
+ const turnLabel = `${label} turn ${turn}`;
1179
+ await bridge.logSystem(botId, chatId, `${turnLabel} started.`);
1180
+ try {
1181
+ const responses = sessionId
1182
+ ? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt)
1183
+ : await bridge.routeMessage(botId, chatId, prompt);
1184
+ await ensureStillBound(`${turnLabel} response`);
1185
+ const parsed = parseReportResponses(bridge.formatResponses(responses), transform);
1186
+ await bridge.logSystem(botId, chatId, `${turnLabel} returned ${parsed.kind}.`);
1187
+ emptyResponseRetryCount = 0;
1188
+ retryableErrorCount = 0;
1189
+ if (parsed.kind === "progress") {
1190
+ untaggedIntentRetryCount = 0;
1191
+ missingEvidenceRetryCount = 0;
1192
+ deliveredProgressCount += 1;
1193
+ if (currentSession) {
1194
+ const progress = await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
1195
+ if (progress.repeated) {
1196
+ const repeatedMessage = [
1197
+ "Repeated progress detected. The same work pattern has appeared 3 or more times.",
1198
+ "Automatic continuation stopped so the task can be inspected instead of looping.",
1199
+ ].join("\n");
1200
+ await bridge.logSystem(botId, chatId, repeatedMessage);
1201
+ autoContinue.clear(botId, chatId, sessionId);
1202
+ return [repeatedMessage];
1203
+ }
1204
+ }
1205
+ await ensureStillBound(`${turnLabel} progress delivery`);
1206
+ await helpers.reportProgress(parsed.chunks);
1207
+ if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
1208
+ const stopMessage = "Automatic continuation stopped after the latest progress report.";
1209
+ await bridge.logSystem(botId, chatId, stopMessage);
1210
+ autoContinue.clear(botId, chatId, sessionId);
1211
+ return [stopMessage];
1212
+ }
1213
+ prompt = appendManagedContext(REPORT_CONTINUE_PROMPT, managedContext);
1214
+ continue;
1215
+ }
1216
+ if (parsed.kind === "result") {
1217
+ untaggedIntentRetryCount = 0;
1218
+ const resultText = parsed.chunks.join("\n");
1219
+ const evidenceIssue = classifyMissingResultEvidence(resultText);
1220
+ if (evidenceIssue && missingEvidenceRetryCount < 1) {
1221
+ missingEvidenceRetryCount += 1;
1222
+ const retryMessage = `${turnLabel} returned a result without required evidence: ${evidenceIssue}`;
1223
+ await bridge.logSystem(botId, chatId, retryMessage);
1224
+ prompt = appendManagedContext(formatMissingEvidenceRetryPrompt(resultText, evidenceIssue), managedContext);
1225
+ continue;
1226
+ }
1227
+ if (evidenceIssue) {
1228
+ const blockedMessage = [
1229
+ "Provider reported a completed result without concrete evidence after a retry.",
1230
+ `Reason: ${evidenceIssue}`,
1231
+ "Automatic continuation stopped so the work is not accepted on an unsupported claim.",
1218
1232
  ].join("\n");
1219
- await bridge.logSystem(botId, chatId, repeatedMessage);
1233
+ await bridge.logSystem(botId, chatId, blockedMessage);
1220
1234
  autoContinue.clear(botId, chatId, sessionId);
1221
- return [repeatedMessage];
1235
+ return [blockedMessage];
1236
+ }
1237
+ await ensureStillBound(`${turnLabel} final delivery`);
1238
+ if (currentSession) {
1239
+ await memoryService.completeTask(currentSession.session, parsed.chunks.join("\n"));
1222
1240
  }
1241
+ autoContinue.clear(botId, chatId, sessionId);
1242
+ return parsed.chunks;
1223
1243
  }
1224
- await ensureStillBound(`${turnLabel} progress delivery`);
1225
- await helpers.reportProgress(parsed.chunks);
1226
- if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
1227
- const stopMessage = "Automatic continuation stopped after the latest progress report.";
1228
- await bridge.logSystem(botId, chatId, stopMessage);
1244
+ if (parsed.kind === "blocked") {
1245
+ untaggedIntentRetryCount = 0;
1246
+ missingEvidenceRetryCount = 0;
1247
+ await ensureStillBound(`${turnLabel} final delivery`);
1229
1248
  autoContinue.clear(botId, chatId, sessionId);
1230
- return [stopMessage];
1249
+ return parsed.chunks;
1231
1250
  }
1232
- prompt = appendManagedContext(REPORT_CONTINUE_PROMPT, managedContext);
1233
- continue;
1234
- }
1235
- if (parsed.kind === "result") {
1236
- untaggedIntentRetryCount = 0;
1237
- const resultText = parsed.chunks.join("\n");
1238
- const evidenceIssue = classifyMissingResultEvidence(resultText);
1239
- if (evidenceIssue && missingEvidenceRetryCount < 1) {
1240
- missingEvidenceRetryCount += 1;
1241
- const retryMessage = `${turnLabel} returned a result without required evidence: ${evidenceIssue}`;
1251
+ if (looksLikeUntaggedIntentOnlyResponse(parsed.chunks.join("\n")) && untaggedIntentRetryCount < untaggedIntentRetries) {
1252
+ untaggedIntentRetryCount += 1;
1253
+ const retryMessage = `${turnLabel} returned an untagged intent-only response; asking provider to do concrete work before replying.`;
1242
1254
  await bridge.logSystem(botId, chatId, retryMessage);
1243
- prompt = appendManagedContext(formatMissingEvidenceRetryPrompt(resultText, evidenceIssue), managedContext);
1255
+ prompt = appendManagedContext(formatUntaggedIntentRetryPrompt(parsed.chunks.join("\n")), managedContext);
1244
1256
  continue;
1245
1257
  }
1246
- if (evidenceIssue) {
1247
- const blockedMessage = [
1248
- "Provider reported a completed result without concrete evidence after a retry.",
1249
- `Reason: ${evidenceIssue}`,
1250
- "Automatic continuation stopped so the work is not accepted on an unsupported claim.",
1251
- ].join("\n");
1252
- await bridge.logSystem(botId, chatId, blockedMessage);
1253
- autoContinue.clear(botId, chatId, sessionId);
1254
- return [blockedMessage];
1255
- }
1256
- await ensureStillBound(`${turnLabel} final delivery`);
1257
- if (currentSession) {
1258
- await memoryService.completeTask(currentSession.session, parsed.chunks.join("\n"));
1259
- }
1258
+ await bridge.logSystem(botId, chatId, `${turnLabel} returned an untagged response; treating it as final output.`);
1260
1259
  autoContinue.clear(botId, chatId, sessionId);
1261
1260
  return parsed.chunks;
1262
1261
  }
1263
- if (parsed.kind === "blocked") {
1264
- untaggedIntentRetryCount = 0;
1265
- missingEvidenceRetryCount = 0;
1266
- await ensureStillBound(`${turnLabel} final delivery`);
1267
- autoContinue.clear(botId, chatId, sessionId);
1268
- return parsed.chunks;
1269
- }
1270
- if (looksLikeUntaggedIntentOnlyResponse(parsed.chunks.join("\n")) && untaggedIntentRetryCount < untaggedIntentRetries) {
1271
- untaggedIntentRetryCount += 1;
1272
- const retryMessage = `${turnLabel} returned an untagged intent-only response; asking provider to do concrete work before replying.`;
1273
- await bridge.logSystem(botId, chatId, retryMessage);
1274
- prompt = appendManagedContext(formatUntaggedIntentRetryPrompt(parsed.chunks.join("\n")), managedContext);
1275
- continue;
1276
- }
1277
- await bridge.logSystem(botId, chatId, `${turnLabel} returned an untagged response; treating it as final output.`);
1278
- autoContinue.clear(botId, chatId, sessionId);
1279
- return parsed.chunks;
1280
- }
1281
- catch (error) {
1282
- if (error instanceof SilentTelegramAbort) {
1283
- throw error;
1284
- }
1285
- const messageText = error instanceof Error ? error.message : "An unexpected error occurred.";
1286
- const retryable = classifyRetryableProviderIssue(messageText, retryableErrorDelayMs);
1287
- if (isProviderTimeoutError(messageText)) {
1288
- const timeoutMessage = formatProviderTimeoutFinalMessage(messageText);
1289
- console.warn(`[telegram-route] bot=${botId} chat=${chatId} ${turnLabel} timed out: ${messageText}`);
1290
- await bridge.logSystem(botId, chatId, `${turnLabel} timed out: ${messageText}`);
1291
- autoContinue.clear(botId, chatId, sessionId);
1292
- return [timeoutMessage];
1293
- }
1294
- if (isEmptyResponseError(messageText) && emptyResponseRetryCount < emptyResponseRetries) {
1295
- emptyResponseRetryCount += 1;
1296
- const retryMessage = `${turnLabel} returned an empty response; retrying automatic continuation (${emptyResponseRetryCount}/${emptyResponseRetries}).`;
1297
- console.warn(`[telegram-route] bot=${botId} chat=${chatId} ${retryMessage}`);
1298
- await bridge.logSystem(botId, chatId, retryMessage);
1299
- prompt = appendManagedContext(REPORT_CONTINUE_PROMPT, managedContext);
1300
- continue;
1301
- }
1302
- if (retryable && retryableErrorCount < retryableErrorRetries) {
1303
- retryableErrorCount += 1;
1304
- const retryMessage = formatRetryableProviderRetryMessage(retryable, retryableErrorCount, retryableErrorRetries);
1305
- console.warn(`[telegram-route] bot=${botId} chat=${chatId} ${turnLabel} retrying: ${messageText}`);
1306
- await bridge.logSystem(botId, chatId, `${turnLabel} retrying after temporary provider issue: ${messageText}`);
1307
- await helpers.reportProgress([retryMessage]);
1308
- if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
1309
- const stopMessage = "Automatic continuation stopped after the latest retry notice.";
1310
- await bridge.logSystem(botId, chatId, stopMessage);
1262
+ catch (error) {
1263
+ if (error instanceof SilentTelegramAbort) {
1264
+ throw error;
1265
+ }
1266
+ const messageText = error instanceof Error ? error.message : "An unexpected error occurred.";
1267
+ const retryable = classifyRetryableProviderIssue(messageText, retryableErrorDelayMs);
1268
+ if (isProviderTimeoutError(messageText)) {
1269
+ const timeoutMessage = formatProviderTimeoutFinalMessage(messageText);
1270
+ console.warn(`[telegram-route] bot=${botId} chat=${chatId} ${turnLabel} timed out: ${messageText}`);
1271
+ await bridge.logSystem(botId, chatId, `${turnLabel} timed out: ${messageText}`);
1311
1272
  autoContinue.clear(botId, chatId, sessionId);
1312
- return [stopMessage];
1273
+ return [timeoutMessage];
1313
1274
  }
1314
- await sleep(retryable.retryAfterMs);
1315
- prompt = appendManagedContext(REPORT_CONTINUE_PROMPT, managedContext);
1316
- continue;
1317
- }
1318
- console.error(`[telegram-route] bot=${botId} chat=${chatId} ${turnLabel} failed: ${messageText}`, error);
1319
- await bridge.logSystem(botId, chatId, `${turnLabel} failed: ${messageText}`);
1320
- autoContinue.clear(botId, chatId, sessionId);
1321
- if (retryable) {
1322
- return [formatRetryableProviderFinalMessage(retryable)];
1323
- }
1324
- if (isEmptyResponseError(messageText) && deliveredProgressCount > 0) {
1325
- return [
1326
- "The last progress report was delivered, but the follow-up provider response came back empty. Automatic continuation stopped here.",
1327
- "Send a new message such as `continue` to resume the same session from the latest state.",
1328
- ];
1275
+ if (isEmptyResponseError(messageText) && emptyResponseRetryCount < emptyResponseRetries) {
1276
+ emptyResponseRetryCount += 1;
1277
+ const retryMessage = `${turnLabel} returned an empty response; retrying automatic continuation (${emptyResponseRetryCount}/${emptyResponseRetries}).`;
1278
+ console.warn(`[telegram-route] bot=${botId} chat=${chatId} ${retryMessage}`);
1279
+ await bridge.logSystem(botId, chatId, retryMessage);
1280
+ prompt = appendManagedContext(REPORT_CONTINUE_PROMPT, managedContext);
1281
+ continue;
1282
+ }
1283
+ if (retryable && retryableErrorCount < retryableErrorRetries) {
1284
+ retryableErrorCount += 1;
1285
+ const retryMessage = formatRetryableProviderRetryMessage(retryable, retryableErrorCount, retryableErrorRetries);
1286
+ console.warn(`[telegram-route] bot=${botId} chat=${chatId} ${turnLabel} retrying: ${messageText}`);
1287
+ await bridge.logSystem(botId, chatId, `${turnLabel} retrying after temporary provider issue: ${messageText}`);
1288
+ await helpers.reportProgress([retryMessage]);
1289
+ if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
1290
+ const stopMessage = "Automatic continuation stopped after the latest retry notice.";
1291
+ await bridge.logSystem(botId, chatId, stopMessage);
1292
+ autoContinue.clear(botId, chatId, sessionId);
1293
+ return [stopMessage];
1294
+ }
1295
+ await sleep(retryable.retryAfterMs);
1296
+ prompt = appendManagedContext(REPORT_CONTINUE_PROMPT, managedContext);
1297
+ continue;
1298
+ }
1299
+ console.error(`[telegram-route] bot=${botId} chat=${chatId} ${turnLabel} failed: ${messageText}`, error);
1300
+ await bridge.logSystem(botId, chatId, `${turnLabel} failed: ${messageText}`);
1301
+ autoContinue.clear(botId, chatId, sessionId);
1302
+ if (retryable) {
1303
+ return [formatRetryableProviderFinalMessage(retryable)];
1304
+ }
1305
+ if (isEmptyResponseError(messageText) && deliveredProgressCount > 0) {
1306
+ return [
1307
+ "The last progress report was delivered, but the follow-up provider response came back empty. Automatic continuation stopped here.",
1308
+ "Send a new message such as `continue` to resume the same session from the latest state.",
1309
+ ];
1310
+ }
1311
+ throw error;
1329
1312
  }
1330
- throw error;
1313
+ }
1314
+ }
1315
+ finally {
1316
+ if (currentSession) {
1317
+ await botManagement.markProviderIdle(botId, sessionId);
1331
1318
  }
1332
1319
  }
1333
1320
  }
@@ -1542,7 +1529,7 @@ function formatMissingEvidenceRetryPrompt(lastResponse, issue) {
1542
1529
  ].join("\n");
1543
1530
  }
1544
1531
  function isEmptyResponseError(message) {
1545
- return /empty response/i.test(message);
1532
+ return /empty response|failed without any output|without stdout\/stderr/i.test(message);
1546
1533
  }
1547
1534
  function looksLikeBlockedBody(text) {
1548
1535
  if (!text.trim()) {
package/dist/config.js CHANGED
@@ -135,12 +135,11 @@ export const config = {
135
135
  telegramPollingBackoffMaxMs: readTimeout("TELEGRAM_POLLING_BACKOFF_MAX_MS", 900_000),
136
136
  telegramPollingMaxConcurrency: readTimeout("TELEGRAM_POLLING_MAX_CONCURRENCY", 3),
137
137
  telegramSchedulerTickMs: readTimeout("TELEGRAM_SCHEDULER_TICK_MS", 1000),
138
- telegramTieredPollingMinBots: readTimeout("TELEGRAM_TIERED_POLLING_MIN_BOTS", 4),
138
+ telegramTieredPollingMinBots: readTimeout("TELEGRAM_TIERED_POLLING_MIN_BOTS", 5),
139
139
  telegramActivePollIntervalMs: readTimeout("TELEGRAM_ACTIVE_POLL_INTERVAL_MS", 3000),
140
- telegramIdlePollIntervalMs: readTimeout("TELEGRAM_IDLE_POLL_INTERVAL_MS", 30_000),
141
- telegramColdPollIntervalMs: readTimeout("TELEGRAM_COLD_POLL_INTERVAL_MS", 300_000),
142
- telegramActiveIdleMs: readTimeout("TELEGRAM_ACTIVE_IDLE_MS", 1_800_000),
143
- telegramColdIdleMs: readTimeout("TELEGRAM_COLD_IDLE_MS", 21_600_000),
140
+ telegramRunningPollIntervalMs: readTimeout("TELEGRAM_RUNNING_POLL_INTERVAL_MS", 60_000),
141
+ telegramSecondaryPollIntervalMs: readTimeout("TELEGRAM_SECONDARY_POLL_INTERVAL_MS", 60_000),
142
+ telegramTertiaryPollIntervalMs: readTimeout("TELEGRAM_TERTIARY_POLL_INTERVAL_MS", 180_000),
144
143
  telegramOwnerId: readOptional("TELEGRAM_OWNER_ID"),
145
144
  telegramMessageBatchMs: readNonNegativeTimeout("TELEGRAM_MESSAGE_BATCH_MS", 1500),
146
145
  telegramTypingIntervalMs: readTimeout("TELEGRAM_TYPING_INTERVAL_MS", 10_000),
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { BotManagementService } from "./services/bot-management-service.js";
15
15
  import { LocalUiService } from "./services/local-ui-service.js";
16
16
  import { AgentMemoryService } from "./services/agent-memory-service.js";
17
17
  import { BotPollingStateService } from "./services/bot-polling-state-service.js";
18
+ import { computePolicyPollIntervalMs, computeRecentMessageRanks } from "./services/polling-policy.js";
18
19
  import { terminateAllSpawnedExecutions } from "./adapters/windows-shell.js";
19
20
  import { setTelegramCommandMenu } from "./telegram-command-menu.js";
20
21
  const execFileAsync = promisify(execFile);
@@ -140,10 +141,6 @@ async function startManualPollingScheduler(bots) {
140
141
  const runtimeStates = new Map();
141
142
  const botIds = pollingBots.map((bot) => String(bot.botInfo.id));
142
143
  await botPollingState.prune(botIds);
143
- const explicitMainBotId = process.env.TELEGRAM_MAIN_BOT_ID?.trim();
144
- const mainBotId = explicitMainBotId && botIds.includes(explicitMainBotId)
145
- ? explicitMainBotId
146
- : botIds[0];
147
144
  for (const [index, bot] of pollingBots.entries()) {
148
145
  const botId = String(bot.botInfo.id);
149
146
  runtimeStates.set(botId, {
@@ -162,6 +159,8 @@ async function startManualPollingScheduler(bots) {
162
159
  while (true) {
163
160
  const now = Date.now();
164
161
  let activePolls = [...runtimeStates.values()].filter((state) => state.inFlight).length;
162
+ const pollingStates = await botPollingState.list();
163
+ const rankByBotId = computeRecentMessageRanks(botIds, pollingStates);
165
164
  for (const bot of pollingBots) {
166
165
  if (activePolls >= config.telegramPollingMaxConcurrency) {
167
166
  break;
@@ -172,10 +171,6 @@ async function startManualPollingScheduler(bots) {
172
171
  continue;
173
172
  }
174
173
  const state = await botPollingState.get(botId, bot.botInfo.username);
175
- const isMain = botId === mainBotId;
176
- if (!isMain && state.sleepMode === "deep") {
177
- continue;
178
- }
179
174
  const nextPollAt = state.nextPollAt ? Date.parse(state.nextPollAt) : 0;
180
175
  if (Number.isFinite(nextPollAt) && nextPollAt > now) {
181
176
  continue;
@@ -183,9 +178,9 @@ async function startManualPollingScheduler(bots) {
183
178
  runtime.inFlight = true;
184
179
  activePolls += 1;
185
180
  void pollTelegramBot(bot, runtime, {
186
- isMain,
187
181
  totalBots: pollingBots.length,
188
- lastMessageAt: state.lastMessageAt,
182
+ botRank: rankByBotId.get(botId) ?? pollingBots.length,
183
+ state,
189
184
  }).finally(() => {
190
185
  runtime.inFlight = false;
191
186
  });
@@ -233,8 +228,20 @@ async function pollTelegramBot(pollingBot, runtime, options) {
233
228
  }
234
229
  runtime.offset = orderedUpdates[orderedUpdates.length - 1].update_id + 1;
235
230
  }
236
- const lastMessageAt = orderedUpdates.some(hasMessagePayload) ? new Date(now).toISOString() : options.lastMessageAt;
237
- const nextPollAt = now + computePolicyPollIntervalMs(options.isMain, options.totalBots, lastMessageAt, now);
231
+ const receivedMessage = orderedUpdates.some(hasMessagePayload);
232
+ const lastMessageAt = receivedMessage ? new Date(now).toISOString() : options.state?.lastMessageAt;
233
+ const nextPollAt = now + computePolicyPollIntervalMs(options.totalBots, receivedMessage ? 1 : options.botRank, {
234
+ ...options.state,
235
+ botId,
236
+ consecutiveFailures: options.state?.consecutiveFailures ?? runtime.consecutiveFailures,
237
+ lastMessageAt,
238
+ }, {
239
+ tieredPollingMinBots: config.telegramTieredPollingMinBots,
240
+ activePollIntervalMs: config.telegramActivePollIntervalMs,
241
+ runningPollIntervalMs: config.telegramRunningPollIntervalMs,
242
+ secondaryPollIntervalMs: config.telegramSecondaryPollIntervalMs,
243
+ tertiaryPollIntervalMs: config.telegramTertiaryPollIntervalMs,
244
+ });
238
245
  await botPollingState.recordPoll(botId, {
239
246
  username: pollingBot.botInfo.username,
240
247
  lastPollAt: new Date(now).toISOString(),
@@ -271,22 +278,6 @@ async function pollTelegramBot(pollingBot, runtime, options) {
271
278
  });
272
279
  }
273
280
  }
274
- function computePolicyPollIntervalMs(isMain, totalBots, lastMessageAt, now) {
275
- if (isMain || totalBots < config.telegramTieredPollingMinBots) {
276
- return config.telegramActivePollIntervalMs;
277
- }
278
- const lastMessageTime = lastMessageAt ? Date.parse(lastMessageAt) : undefined;
279
- const idleMs = lastMessageTime && Number.isFinite(lastMessageTime)
280
- ? now - lastMessageTime
281
- : Number.POSITIVE_INFINITY;
282
- if (idleMs <= config.telegramActiveIdleMs) {
283
- return config.telegramActivePollIntervalMs;
284
- }
285
- if (idleMs <= config.telegramColdIdleMs) {
286
- return config.telegramIdlePollIntervalMs;
287
- }
288
- return config.telegramColdPollIntervalMs;
289
- }
290
281
  function hasMessagePayload(update) {
291
282
  return Boolean(update.message || update.edited_message || update.channel_post);
292
283
  }
@@ -29,57 +29,30 @@ export class BotManagementService {
29
29
  if (bots.length === 0) {
30
30
  return "No Telegram bots are configured.";
31
31
  }
32
- return this.formatBots(bots, env.mainBotId, await this.pollingState.list());
32
+ return this.formatBots(bots, await this.pollingState.list());
33
33
  }
34
34
  async formatCurrentBotSummary(currentBotId) {
35
35
  const env = await this.readEnvConfig();
36
36
  const bots = this.zipBots(env.tokens, env.usernames);
37
37
  const current = this.resolveBotSelector(bots, currentBotId);
38
- const main = this.resolveMainBot(bots, env.mainBotId);
39
38
  const currentLabel = current ? `@${current.username} (${current.id})` : currentBotId;
40
- const mainLabel = main ? `@${main.username} (${main.id})` : "not configured";
41
- const role = current && main && current.id === main.id ? "main" : "sub";
42
39
  const states = await this.pollingState.list();
43
40
  const currentState = current ? states[String(current.id)] : undefined;
44
41
  return [
45
- `bot: ${currentLabel}${current ? ` [${role}]` : ""}`,
46
- `mainBot: ${mainLabel}`,
42
+ `bot: ${currentLabel}`,
47
43
  `botCount: ${bots.length}`,
48
- `sleep: ${this.pollingState.formatMode(String(current?.id ?? currentBotId), role === "main", currentState)}`,
44
+ `pollingState: ${this.pollingState.formatMode(currentState)}`,
49
45
  ].join("\n");
50
46
  }
51
- async sleepBot(selector, currentBotId) {
52
- const env = await this.readEnvConfig();
53
- const bots = this.zipBots(env.tokens, env.usernames);
54
- if (bots.length === 0) {
55
- throw new Error("No Telegram bots are configured.");
56
- }
57
- const mainBotId = this.resolveMainBotId(bots, env.mainBotId);
58
- const target = selector.trim()
59
- ? this.resolveBotSelector(bots, selector)
60
- : this.resolveBotSelector(bots, currentBotId);
61
- if (!target) {
62
- throw new Error("Usage: /sleep <bot>");
63
- }
64
- if (String(target.id) === mainBotId) {
65
- throw new Error(`@${target.username} is the main bot and cannot enter deep sleep.`);
66
- }
67
- await this.pollingState.setSleepMode(String(target.id), "deep", target.username);
68
- return {
69
- message: `Set @${target.username} to deep sleep.\n\nIt remains configured. RemoteAgent will stop polling it until /wake is used from an awake bot.`,
70
- };
47
+ async markProviderRunning(botId, sessionId) {
48
+ const bot = await this.findConfiguredBot(botId);
49
+ const pollingBotId = bot ? String(bot.id) : botId;
50
+ await this.pollingState.markRunning(pollingBotId, sessionId, bot?.username);
71
51
  }
72
- async wakeBot(selector) {
73
- const env = await this.readEnvConfig();
74
- const bots = this.zipBots(env.tokens, env.usernames);
75
- const target = this.resolveBotSelector(bots, selector);
76
- if (!target) {
77
- throw new Error("Usage: /wake <bot>");
78
- }
79
- await this.pollingState.setSleepMode(String(target.id), "awake", target.username);
80
- return {
81
- message: `Woke @${target.username}. It will be polled again.`,
82
- };
52
+ async markProviderIdle(botId, sessionId) {
53
+ const bot = await this.findConfiguredBot(botId);
54
+ const pollingBotId = bot ? String(bot.id) : botId;
55
+ await this.pollingState.markIdle(pollingBotId, sessionId, bot?.username);
83
56
  }
84
57
  async getPendingOperationNotice() {
85
58
  const pending = await this.readPendingOperation();
@@ -109,7 +82,6 @@ export class BotManagementService {
109
82
  }
110
83
  const tokens = [...env.tokens, trimmed];
111
84
  const usernames = [...env.usernames, target.username];
112
- const mainBotId = this.resolveMainBotId(this.zipBots(tokens, usernames), env.mainBotId);
113
85
  const backupEnvPath = await this.backupEnv();
114
86
  const pending = {
115
87
  version: 1,
@@ -128,7 +100,7 @@ export class BotManagementService {
128
100
  };
129
101
  try {
130
102
  await this.writePendingOperation(pending);
131
- await this.writeEnvConfig(env.lines, tokens, usernames, mainBotId);
103
+ await this.writeEnvConfig(env.lines, tokens, usernames);
132
104
  await this.launchRestartJob();
133
105
  }
134
106
  catch (error) {
@@ -162,10 +134,6 @@ export class BotManagementService {
162
134
  const remainingBots = bots.filter((value) => value.token !== target.token);
163
135
  const tokens = remainingBots.map((value) => value.token);
164
136
  const usernames = remainingBots.map((value) => value.username);
165
- const currentMain = this.resolveMainBot(bots, env.mainBotId);
166
- const nextMainBotId = currentMain?.token === target.token
167
- ? this.promoteMainBotAfterRemoval(bots, target)?.id.toString()
168
- : this.resolveMainBotId(remainingBots, env.mainBotId);
169
137
  const replyToken = tokens.includes(sourceBotToken) ? sourceBotToken : tokens[0];
170
138
  const notifyViaUsername = remainingBots.find((bot) => bot.token === replyToken)?.username;
171
139
  const backupEnvPath = await this.backupEnv();
@@ -186,7 +154,7 @@ export class BotManagementService {
186
154
  };
187
155
  try {
188
156
  await this.writePendingOperation(pending);
189
- await this.writeEnvConfig(env.lines, tokens, usernames, nextMainBotId);
157
+ await this.writeEnvConfig(env.lines, tokens, usernames);
190
158
  await this.launchRestartJob();
191
159
  }
192
160
  catch (error) {
@@ -194,43 +162,16 @@ export class BotManagementService {
194
162
  await this.clearPendingOperation().catch(() => undefined);
195
163
  throw error;
196
164
  }
197
- const promoted = nextMainBotId ? remainingBots.find((bot) => String(bot.id) === nextMainBotId) : undefined;
198
- const mainLine = promoted && currentMain?.token === target.token
199
- ? `Main bot will be promoted to @${promoted.username} (${promoted.id}).`
200
- : undefined;
201
165
  const notifyLine = replyToken === sourceBotToken || !notifyViaUsername
202
166
  ? "The runtime will restart once and then report the result here."
203
167
  : `The runtime will restart once and then report the result through @${notifyViaUsername}.`;
204
168
  return {
205
169
  message: [
206
170
  `Applying bot removal for @${target.username} (${target.id}).`,
207
- mainLine,
208
171
  notifyLine,
209
172
  ].filter(Boolean).join("\n\n"),
210
173
  };
211
174
  }
212
- async setMainBot(selector) {
213
- await this.ensureSupported();
214
- await this.assertNoPendingOperation();
215
- const trimmed = selector.trim();
216
- if (!trimmed) {
217
- throw new Error("Usage: /bot main <number|@username|id>");
218
- }
219
- const env = await this.readEnvConfig();
220
- const bots = this.zipBots(env.tokens, env.usernames);
221
- const target = this.resolveBotSelector(bots, trimmed);
222
- if (!target) {
223
- throw new Error(`Bot was not found: ${trimmed}`);
224
- }
225
- await this.writeEnvConfig(env.lines, env.tokens, env.usernames, String(target.id));
226
- return {
227
- message: [
228
- `Set main bot to @${target.username} (${target.id}).`,
229
- "",
230
- this.formatBots(bots, String(target.id)),
231
- ].join("\n"),
232
- };
233
- }
234
175
  async doctorBots(sourceBotId, sourceBotToken, chatId) {
235
176
  await this.ensureSupported();
236
177
  await this.assertNoPendingOperation();
@@ -273,7 +214,6 @@ export class BotManagementService {
273
214
  }
274
215
  const tokens = alive.map((bot) => bot.token);
275
216
  const usernames = alive.map((bot) => bot.username);
276
- const mainBotId = this.resolveMainBotId(alive, env.mainBotId);
277
217
  const replyToken = tokens.includes(sourceBotToken) ? sourceBotToken : tokens[0];
278
218
  const notifyViaUsername = alive.find((bot) => bot.token === replyToken)?.username;
279
219
  const backupEnvPath = await this.backupEnv();
@@ -294,7 +234,7 @@ export class BotManagementService {
294
234
  };
295
235
  try {
296
236
  await this.writePendingOperation(pending);
297
- await this.writeEnvConfig(env.lines, tokens, usernames, mainBotId);
237
+ await this.writeEnvConfig(env.lines, tokens, usernames);
298
238
  await this.launchRestartJob();
299
239
  }
300
240
  catch (error) {
@@ -345,9 +285,8 @@ export class BotManagementService {
345
285
  }
346
286
  const env = await this.readEnvConfig().catch(() => undefined);
347
287
  const bots = env ? this.zipBots(env.tokens, env.usernames) : [];
348
- const mainBotId = this.resolveMainBotId(bots, env?.mainBotId);
349
288
  const listLines = bots.length > 0
350
- ? this.formatBotListLines(bots, mainBotId).map((line) => `- ${line.replace(/^\d+\.\s*/, "")}`)
289
+ ? this.formatBotListLines(bots).map((line) => `- ${line.replace(/^\d+\.\s*/, "")}`)
351
290
  : ["- none"];
352
291
  const lines = pending.status === "rolled_back"
353
292
  ? [
@@ -485,7 +424,6 @@ export class BotManagementService {
485
424
  const tokenLine = lines.find((line) => line.startsWith("TELEGRAM_BOT_TOKENS="));
486
425
  const singleTokenLine = lines.find((line) => line.startsWith("TELEGRAM_BOT_TOKEN="));
487
426
  const usernameLine = lines.find((line) => line.startsWith("TELEGRAM_BOT_USERNAMES="));
488
- const mainBotLine = lines.find((line) => line.startsWith("TELEGRAM_MAIN_BOT_ID="));
489
427
  const tokens = tokenLine
490
428
  ? this.parseCsv(tokenLine.slice("TELEGRAM_BOT_TOKENS=".length))
491
429
  : singleTokenLine
@@ -497,17 +435,14 @@ export class BotManagementService {
497
435
  lines,
498
436
  tokens,
499
437
  usernames,
500
- mainBotId: mainBotLine?.slice("TELEGRAM_MAIN_BOT_ID=".length).trim() || undefined,
501
438
  };
502
439
  }
503
- async writeEnvConfig(originalLines, tokens, usernames, mainBotId) {
440
+ async writeEnvConfig(originalLines, tokens, usernames) {
504
441
  const normalizedUsernames = await this.normalizeUsernamesFromTelegram(tokens, usernames);
505
- const normalizedMainBotId = this.resolveMainBotId(this.zipBots(tokens, normalizedUsernames), mainBotId);
506
442
  const nextLines = [];
507
443
  let hasMulti = false;
508
444
  let hasSingle = false;
509
445
  let hasUsernames = false;
510
- let hasMainBot = false;
511
446
  for (const line of originalLines) {
512
447
  if (line.startsWith("TELEGRAM_BOT_TOKENS=")) {
513
448
  nextLines.push(`TELEGRAM_BOT_TOKENS=${tokens.join(",")}`);
@@ -525,10 +460,6 @@ export class BotManagementService {
525
460
  continue;
526
461
  }
527
462
  if (line.startsWith("TELEGRAM_MAIN_BOT_ID=")) {
528
- if (normalizedMainBotId) {
529
- nextLines.push(`TELEGRAM_MAIN_BOT_ID=${normalizedMainBotId}`);
530
- }
531
- hasMainBot = true;
532
463
  continue;
533
464
  }
534
465
  nextLines.push(line);
@@ -542,9 +473,6 @@ export class BotManagementService {
542
473
  if (!hasUsernames) {
543
474
  nextLines.unshift(`TELEGRAM_BOT_USERNAMES=${normalizedUsernames.join(",")}`);
544
475
  }
545
- if (!hasMainBot && normalizedMainBotId) {
546
- nextLines.unshift(`TELEGRAM_MAIN_BOT_ID=${normalizedMainBotId}`);
547
- }
548
476
  const output = `${nextLines.filter((line, index, all) => !(index === all.length - 1 && line === "")).join("\n")}\n`;
549
477
  await fs.writeFile(this.envPath, output, "utf8");
550
478
  }
@@ -589,27 +517,24 @@ export class BotManagementService {
589
517
  || String(bot.id) === withoutAt
590
518
  || bot.token === normalized);
591
519
  }
592
- formatBots(bots, mainBotId, states = {}) {
520
+ formatBots(bots, states = {}) {
593
521
  if (bots.length === 0) {
594
522
  return "No Telegram bots are configured.";
595
523
  }
596
524
  return [
597
525
  `Configured bots (${bots.length})`,
598
- ...this.formatBotListLines(bots, mainBotId, states),
526
+ ...this.formatBotListLines(bots, states),
599
527
  ].join("\n");
600
528
  }
601
- formatBotListLines(bots, explicitMainBotId, states = {}) {
602
- const mainBotId = this.resolveMainBotId(bots, explicitMainBotId);
529
+ formatBotListLines(bots, states = {}) {
603
530
  return bots.map((bot, index) => {
604
531
  const botId = String(bot.id);
605
- const isMain = botId === mainBotId;
606
532
  const state = states[botId];
607
533
  const details = [
608
- `[${isMain ? "main" : "sub"}]`,
609
- this.pollingState.formatMode(botId, isMain, state),
534
+ this.pollingState.formatMode(state),
610
535
  state?.lastMessageAt ? `lastMessage=${this.formatAge(state.lastMessageAt)}` : undefined,
611
536
  state?.lastPollAt ? `lastPoll=${this.formatAge(state.lastPollAt)}` : undefined,
612
- state?.nextPollAt && state.sleepMode !== "deep" ? `nextPoll=${this.formatAge(state.nextPollAt)}` : undefined,
537
+ state?.nextPollAt ? `nextPoll=${this.formatAge(state.nextPollAt)}` : undefined,
613
538
  state?.consecutiveFailures ? `failures=${state.consecutiveFailures}` : undefined,
614
539
  ].filter(Boolean).join(" ");
615
540
  return `${index + 1}. @${bot.username} (${bot.id}) ${details}`;
@@ -634,24 +559,13 @@ export class BotManagementService {
634
559
  }
635
560
  return `${Math.round(absMs / 86_400_000)}d ${suffix}`;
636
561
  }
637
- resolveMainBot(bots, mainBotId) {
638
- if (bots.length === 0) {
639
- return undefined;
640
- }
641
- const explicit = mainBotId ? bots.find((bot) => String(bot.id) === mainBotId) : undefined;
642
- return explicit ?? bots[0];
643
- }
644
- resolveMainBotId(bots, mainBotId) {
645
- return this.resolveMainBot(bots, mainBotId)?.id.toString();
646
- }
647
- promoteMainBotAfterRemoval(bots, removed) {
648
- const remaining = bots.filter((bot) => bot.token !== removed.token);
649
- return remaining[removed.index] ?? remaining[0];
650
- }
651
562
  async listConfiguredBots() {
652
563
  const env = await this.readEnvConfig();
653
564
  return this.zipBots(env.tokens, env.usernames);
654
565
  }
566
+ async findConfiguredBot(botId) {
567
+ return this.resolveBotSelector(await this.listConfiguredBots(), botId);
568
+ }
655
569
  tokenId(token) {
656
570
  return Number.parseInt(token.split(":", 1)[0] ?? "0", 10);
657
571
  }
@@ -19,7 +19,6 @@ export class BotPollingStateService {
19
19
  const current = state.bots[botId] ?? {
20
20
  botId,
21
21
  username,
22
- sleepMode: "awake",
23
22
  consecutiveFailures: 0,
24
23
  };
25
24
  if (username && current.username !== username) {
@@ -32,30 +31,49 @@ export class BotPollingStateService {
32
31
  async list() {
33
32
  return { ...(await this.read()).bots };
34
33
  }
35
- async setSleepMode(botId, sleepMode, username) {
34
+ async markRunning(botId, sessionId, username) {
36
35
  const state = await this.read();
37
36
  const current = state.bots[botId] ?? {
38
37
  botId,
39
38
  username,
40
- sleepMode,
41
39
  consecutiveFailures: 0,
42
40
  };
43
- current.sleepMode = sleepMode;
44
41
  if (username) {
45
42
  current.username = username;
46
43
  }
47
- if (sleepMode === "awake") {
48
- current.nextPollAt = new Date().toISOString();
44
+ const sessions = new Set(current.runningSessionIds ?? []);
45
+ sessions.add(sessionId || "__unknown__");
46
+ current.runningSessionIds = [...sessions];
47
+ current.lastProviderStartedAt = new Date().toISOString();
48
+ state.bots[botId] = current;
49
+ await this.writeNow();
50
+ }
51
+ async markIdle(botId, sessionId, username) {
52
+ const state = await this.read();
53
+ const current = state.bots[botId] ?? {
54
+ botId,
55
+ username,
56
+ consecutiveFailures: 0,
57
+ };
58
+ if (username) {
59
+ current.username = username;
60
+ }
61
+ const runningSessionIds = current.runningSessionIds ?? [];
62
+ if (runningSessionIds.length > 0) {
63
+ const key = sessionId || "__unknown__";
64
+ current.runningSessionIds = runningSessionIds.filter((value) => value !== key);
65
+ }
66
+ if (!current.runningSessionIds || current.runningSessionIds.length === 0) {
67
+ delete current.runningSessionIds;
68
+ current.lastProviderFinishedAt = new Date().toISOString();
49
69
  }
50
70
  state.bots[botId] = current;
51
71
  await this.writeNow();
52
- return current;
53
72
  }
54
73
  async recordPoll(botId, patch) {
55
74
  const state = await this.read();
56
75
  const current = state.bots[botId] ?? {
57
76
  botId,
58
- sleepMode: "awake",
59
77
  consecutiveFailures: 0,
60
78
  };
61
79
  state.bots[botId] = {
@@ -79,11 +97,8 @@ export class BotPollingStateService {
79
97
  await this.writeNow();
80
98
  }
81
99
  }
82
- formatMode(botId, isMain, state) {
83
- if (isMain) {
84
- return "awake";
85
- }
86
- return state?.sleepMode === "deep" ? "deep sleep" : "awake";
100
+ formatMode(state) {
101
+ return state?.runningSessionIds && state.runningSessionIds.length > 0 ? "running" : "idle";
87
102
  }
88
103
  async flush() {
89
104
  if (this.pendingWrite) {
@@ -0,0 +1,34 @@
1
+ export function computeRecentMessageRanks(botIds, states) {
2
+ const ranked = botIds
3
+ .map((botId) => ({
4
+ botId,
5
+ timestamp: parseStateTime(states[botId]?.lastMessageAt)
6
+ ?? parseStateTime(states[botId]?.lastUpdateAt)
7
+ ?? parseStateTime(states[botId]?.lastPollAt)
8
+ ?? 0,
9
+ }))
10
+ .sort((left, right) => right.timestamp - left.timestamp);
11
+ return new Map(ranked.map((entry, index) => [entry.botId, index + 1]));
12
+ }
13
+ export function computePolicyPollIntervalMs(totalBots, botRank, state, policy) {
14
+ if (state?.runningSessionIds && state.runningSessionIds.length > 0) {
15
+ return policy.runningPollIntervalMs;
16
+ }
17
+ if (totalBots < policy.tieredPollingMinBots) {
18
+ return policy.activePollIntervalMs;
19
+ }
20
+ if (botRank <= 4) {
21
+ return policy.activePollIntervalMs;
22
+ }
23
+ if (botRank <= 8) {
24
+ return policy.secondaryPollIntervalMs;
25
+ }
26
+ return policy.tertiaryPollIntervalMs;
27
+ }
28
+ function parseStateTime(value) {
29
+ if (!value) {
30
+ return undefined;
31
+ }
32
+ const timestamp = Date.parse(value);
33
+ return Number.isFinite(timestamp) ? timestamp : undefined;
34
+ }
@@ -13,8 +13,6 @@ export const TELEGRAM_COMMAND_MENU = [
13
13
  { command: "batch", description: "Collect and send a multi-message batch" },
14
14
  { command: "bots", description: "List configured Telegram bots" },
15
15
  { command: "bot", description: "Manage Telegram bots" },
16
- { command: "sleep", description: "Deep sleep a sub bot" },
17
- { command: "wake", description: "Wake a sleeping sub bot" },
18
16
  { command: "install", description: "Install or update Codex or Claude" },
19
17
  { command: "login", description: "Run provider login flow" },
20
18
  { command: "reset", description: "Clear this chat binding" },
@@ -0,0 +1,32 @@
1
+ # Telegram bot polling policy
2
+
3
+ RemoteAgent can run multiple Telegram bots in one runtime. Polling is the loop that asks Telegram whether each bot has new updates.
4
+
5
+ ## Policy
6
+
7
+ When 4 or fewer bots are configured:
8
+
9
+ - every bot polls every 3 seconds
10
+
11
+ When 5 or more bots are configured:
12
+
13
+ - the 4 most recently messaged idle bots poll every 3 seconds
14
+ - the next 4 idle bots poll every 60 seconds
15
+ - all remaining idle bots poll every 180 seconds
16
+
17
+ When a bot has active provider work:
18
+
19
+ - that bot polls every 60 seconds
20
+ - `REPORT:progress` keeps it running
21
+ - `REPORT:result`, `REPORT:blocked`, timeout, fatal error, or `/stop` completion returns it to idle
22
+
23
+ ## Removed concepts
24
+
25
+ RemoteAgent no longer uses deep sleep, wake, or a special main bot for polling control. All configured bots remain reachable through polling, just at different intervals.
26
+
27
+ ## Operational notes
28
+
29
+ - `/bots` shows each configured bot and its polling state.
30
+ - `/bot doctor` removes bots that Telegram reports as permanently dead.
31
+ - `/bot remove <username|id>` removes a configured bot from the runtime.
32
+ - Polling state is stored by Telegram bot id, not by display order.
@@ -35,9 +35,9 @@ Current production bot ownership is intentionally split:
35
35
  Do not run the same Telegram bot token from multiple runtimes at the same time.
36
36
  Bot polling conflicts are treated as incidents, not harmless warnings.
37
37
 
38
- When a runtime has more than three configured Telegram bots, polling pressure can become operationally visible.
39
- Bot sleep is the planned mitigation: keep one main bot reachable and let long-idle sub bots stop polling without removing their configuration.
40
- See [BOT_SLEEP.md](./BOT_SLEEP.md).
38
+ When a runtime has several configured Telegram bots, polling pressure can become operationally visible.
39
+ RemoteAgent reduces that pressure with rank-based polling intervals instead of deep sleep or a special main bot.
40
+ See [BOT_POLLING_POLICY.md](./BOT_POLLING_POLICY.md).
41
41
 
42
42
  ## Workspace policy
43
43
 
@@ -0,0 +1,43 @@
1
+ # Telegram polling policy TODO
2
+
3
+ ## Goal
4
+
5
+ Simplify multi-bot polling so RemoteAgent can keep many Telegram bots configured without deep sleep, wake commands, or a special main bot.
6
+
7
+ ## Required policy
8
+
9
+ - If configured bot count is 4 or less, every bot polls every 3 seconds.
10
+ - If configured bot count is 5 or more:
11
+ - the 4 most recently messaged idle bots poll every 3 seconds
12
+ - the next 4 idle bots poll every 60 seconds
13
+ - all remaining idle bots poll every 180 seconds
14
+ - A bot with active provider work polls every 60 seconds.
15
+ - `REPORT:progress` keeps the bot in running state.
16
+ - Running state ends only after final result, blocked result, fatal error, timeout, or `/stop` completion.
17
+
18
+ ## Removed concepts
19
+
20
+ - No deep sleep state.
21
+ - No wake command.
22
+ - No main bot requirement.
23
+ - No separate sleep registry.
24
+
25
+ ## Steps
26
+
27
+ 1. Remove user-facing `/sleep`, `/wake`, and `/bot main` commands from help and command menu.
28
+ 2. Remove main/deep-sleep logic from bot management output and environment writing.
29
+ 3. Track bot provider activity as idle/running in the polling state file.
30
+ 4. Replace idle-threshold polling with rank-based polling.
31
+ 5. Update README, operations docs, and `.env.example`.
32
+ 6. Run local checks only. Do not deploy to server 30 until explicitly requested.
33
+
34
+ ## Local validation
35
+
36
+ - `npm run check`
37
+ - `npm run build`
38
+ - Confirm `/bots` output no longer shows main/sub or deep sleep.
39
+ - Confirm rank-based interval calculation is covered by a local test or deterministic helper check.
40
+
41
+ ## Deployment rule
42
+
43
+ Server 30 deployment is intentionally out of scope for this change until the operator explicitly requests it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.13.21",
3
+ "version": "0.14.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",
package/docs/BOT_SLEEP.md DELETED
@@ -1,78 +0,0 @@
1
- # Telegram Bot Sleep
2
-
3
- ## Why this exists
4
-
5
- RemoteAgent can run multiple Telegram bots in one runtime.
6
- Each active bot needs Telegram polling.
7
-
8
- Operationally, up to three active polling bots has been stable enough.
9
- When more than three bots are active, polling pressure and Telegram transport errors become more likely.
10
-
11
- Sleep exists to reduce polling load without deleting bot configuration.
12
-
13
- ## Definitions
14
-
15
- - main bot: the control bot that stays reachable
16
- - sub bot: any configured bot that is not the main bot
17
- - awake: the bot is polled normally
18
- - deep sleep: the bot is configured but not polled
19
-
20
- Sleep is not removal.
21
- Removing a bot deletes it from runtime configuration.
22
- Sleeping a bot keeps the bot registered and preserves its sessions.
23
-
24
- ## Policy
25
-
26
- 1. The main bot must not enter deep sleep.
27
- 2. Sub bots may enter deep sleep.
28
- 3. The main bot can wake a sleeping sub bot.
29
- 4. Bot doctor and sleep are separate features.
30
- 5. Bot doctor may remove permanently invalid Telegram bots.
31
- 6. Sleep must never remove a bot token or session state.
32
- 7. User-facing list output should show bot role and sleep state.
33
-
34
- ## Polling Strategy
35
-
36
- The intended strategy is tiered:
37
-
38
- 1. recently used bots poll normally
39
- 2. idle bots poll less often
40
- 3. manually sleeping bots do not poll
41
-
42
- This lets frequently used agents stay responsive while long-idle agents stop consuming polling capacity.
43
-
44
- ## Commands
45
-
46
- Command surface:
47
-
48
- ```text
49
- /sleep <bot>
50
- /wake <bot>
51
- /bot main <bot>
52
- /bots
53
- /list
54
- ```
55
-
56
- Expected behavior:
57
-
58
- - `/sleep <bot>` puts a sub bot into deep sleep
59
- - `/sleep` without a target may sleep the current bot only when it is not the main bot
60
- - `/wake <bot>` wakes a sleeping sub bot
61
- - `/bots` shows main/sub and awake/sleeping state
62
- - `/list` shows session state and bot state where relevant
63
-
64
- ## Non-goals
65
-
66
- - Do not infer sleep from natural-language provider replies.
67
- - Do not treat sleep as `/bot remove`.
68
- - Do not let Codex or Claude decide which bot should be removed.
69
- - Do not create a separate sleep-only bot registry that can drift from configured bots.
70
-
71
- ## Implementation Notes
72
-
73
- Sleep state should be keyed by stable Telegram bot id, not by display index.
74
-
75
- Display indexes are only for user convenience.
76
- Internal actions must resolve to bot id.
77
-
78
- The runtime should still keep one always-awake control path through the main bot.