appback-remoteagent 0.13.16 → 0.13.18

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
@@ -8,6 +8,13 @@ TELEGRAM_EMPTY_RESPONSE_RETRIES=1
8
8
  TELEGRAM_RETRYABLE_ERROR_RETRIES=2
9
9
  TELEGRAM_RETRYABLE_ERROR_DELAY_MS=5000
10
10
  TELEGRAM_UNTAGGED_INTENT_RETRIES=2
11
+ TELEGRAM_SCHEDULER_TICK_MS=1000
12
+ TELEGRAM_TIERED_POLLING_MIN_BOTS=4
13
+ 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
11
18
  ARTIFACT_CLEANUP_ENABLED=true
12
19
  ARTIFACT_RETENTION_DAYS=30
13
20
  ARTIFACT_CLEANUP_INTERVAL_MS=86400000
package/README.md CHANGED
@@ -115,6 +115,8 @@ Current command surface implemented in `src/bot.ts`:
115
115
  | `/bot main <number\|@username\|id>` | Selects the main bot. If no valid main is configured, the oldest configured bot is used |
116
116
  | `/bot remove <username\|id>` | Removes a configured Telegram bot, restarts the runtime, and confirms the result after restart |
117
117
  | `/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 |
118
120
  | `/install codex\|claude` | Runs the configured provider install or update command for the bot owner |
119
121
  | `/login codex` | Starts the Codex device-auth login flow and returns a browser URL when available |
120
122
  | `/login claude [token]` | Starts or finishes the configured Claude Code login flow for the bot owner |
@@ -125,6 +127,8 @@ Current command surface implemented in `src/bot.ts`:
125
127
  | `/batch cancel` | Discards the current batch |
126
128
  | `/batch status` | Shows current batch state |
127
129
 
130
+ Bot sleep keeps long-idle sub bots configured while stopping their polling load. See [docs/BOT_SLEEP.md](./docs/BOT_SLEEP.md).
131
+
128
132
  ### 2. Terminal control
129
133
 
130
134
  Remote shell control is available through:
package/dist/bot.js CHANGED
@@ -36,6 +36,8 @@ const HELP_TEXT = [
36
36
  "/bot main <number|@username|id>",
37
37
  "/bot remove <username|id>",
38
38
  "/bot reload",
39
+ "/sleep [bot]",
40
+ "/wake <bot>",
39
41
  "/install codex|claude",
40
42
  "/login codex",
41
43
  "/login claude [token]",
@@ -84,6 +86,8 @@ const RECOGNIZED_COMMANDS = new Set([
84
86
  "docs",
85
87
  "bots",
86
88
  "bot",
89
+ "sleep",
90
+ "wake",
87
91
  "install",
88
92
  "login",
89
93
  "reset",
@@ -734,6 +738,19 @@ ${bridge.formatStatus(mapping)}`);
734
738
  const result = await botManagement.reloadBots(sourceBotId, sourceBotToken, ctx.chat.id);
735
739
  await reply(ctx, result.message);
736
740
  });
741
+ bot.command("sleep", async (ctx) => {
742
+ await ensureOwnerControlAccess(ctx);
743
+ const sourceBotId = getBotId();
744
+ const { rest } = parseCommand(ctx.message?.text, 2);
745
+ const result = await botManagement.sleepBot(rest?.trim() ?? "", sourceBotId);
746
+ await reply(ctx, result.message);
747
+ });
748
+ bot.command("wake", async (ctx) => {
749
+ await ensureOwnerControlAccess(ctx);
750
+ const { rest } = parseCommand(ctx.message?.text, 2);
751
+ const result = await botManagement.wakeBot(rest?.trim() ?? "");
752
+ await reply(ctx, result.message);
753
+ });
737
754
  bot.command("install", async (ctx) => {
738
755
  await ensureOwnerControlAccess(ctx);
739
756
  const { args } = parseCommand(ctx.message?.text, 1);
package/dist/config.js CHANGED
@@ -134,6 +134,13 @@ export const config = {
134
134
  telegramPollingBackoffMinMs: readTimeout("TELEGRAM_POLLING_BACKOFF_MIN_MS", 60_000),
135
135
  telegramPollingBackoffMaxMs: readTimeout("TELEGRAM_POLLING_BACKOFF_MAX_MS", 900_000),
136
136
  telegramPollingMaxConcurrency: readTimeout("TELEGRAM_POLLING_MAX_CONCURRENCY", 3),
137
+ telegramSchedulerTickMs: readTimeout("TELEGRAM_SCHEDULER_TICK_MS", 1000),
138
+ telegramTieredPollingMinBots: readTimeout("TELEGRAM_TIERED_POLLING_MIN_BOTS", 4),
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),
137
144
  telegramOwnerId: readOptional("TELEGRAM_OWNER_ID"),
138
145
  telegramMessageBatchMs: readNonNegativeTimeout("TELEGRAM_MESSAGE_BATCH_MS", 1500),
139
146
  telegramTypingIntervalMs: readTimeout("TELEGRAM_TYPING_INTERVAL_MS", 10_000),
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { BridgeService } from "./services/bridge-service.js";
14
14
  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
+ import { BotPollingStateService } from "./services/bot-polling-state-service.js";
17
18
  import { terminateAllSpawnedExecutions } from "./adapters/windows-shell.js";
18
19
  import { setTelegramCommandMenu } from "./telegram-command-menu.js";
19
20
  const execFileAsync = promisify(execFile);
@@ -23,10 +24,12 @@ let processLockPath;
23
24
  let telegramTransportStatusPath;
24
25
  const telegramTransportStatuses = {};
25
26
  let telegramPollingLimiter;
27
+ let botPollingState;
26
28
  async function main() {
27
29
  processLockPath = await acquireProcessLock(config.dataDir);
28
30
  telegramTransportStatusPath = path.join(config.dataDir, "telegram-transport.json");
29
31
  telegramPollingLimiter = new AsyncSemaphore(config.telegramPollingMaxConcurrency);
32
+ botPollingState = new BotPollingStateService(config.dataDir);
30
33
  registerProcessLifecycle();
31
34
  const store = new FileStore(config.dataDir, config.defaultMode);
32
35
  await store.init();
@@ -49,7 +52,7 @@ async function main() {
49
52
  const availableProviders = ["codex", "claude"].filter((provider) => isProviderInstalled(provider));
50
53
  console.log(`Available providers: ${availableProviders.length > 0 ? availableProviders.join(", ") : "none"}`);
51
54
  const bridge = new BridgeService(store, adapters, config.defaultWorkspace, config.workspaceRoot, isProviderInstalled, config.defaultMode, config.codexSandboxMode);
52
- const botManagement = new BotManagementService(config.dataDir, config.botRestartServiceName, config.botRestartHelperPath);
55
+ const botManagement = new BotManagementService(config.dataDir, config.botRestartServiceName, config.botRestartHelperPath, botPollingState);
53
56
  startArtifactCleanupSchedule(new AgentMemoryService(config.dataDir));
54
57
  if (config.localUiEnabled) {
55
58
  const localUi = new LocalUiService(bridge, config.localUiHost, config.localUiPort);
@@ -81,7 +84,7 @@ async function main() {
81
84
  await botManagement.reportPendingOperationResult().catch((error) => {
82
85
  console.error("Failed to report pending bot operation result:", error);
83
86
  });
84
- await Promise.all(bots.map((bot, index) => startManualPolling(bot, index)));
87
+ await startManualPollingScheduler(bots);
85
88
  }
86
89
  function startArtifactCleanupSchedule(memoryService) {
87
90
  if (!config.artifactCleanupEnabled) {
@@ -132,36 +135,78 @@ main().catch((error) => {
132
135
  releaseProcessLockSync();
133
136
  process.exitCode = 1;
134
137
  });
135
- async function startManualPolling(bot, index) {
136
- const pollingBot = bot;
137
- let offset = 0;
138
- let consecutiveFailures = 0;
139
- let lastFailureLogAt = 0;
140
- const activeHandlers = new Set();
141
- const initialDelayMs = Math.min(30_000, index * 3_000 + stableJitterMs(pollingBot.botInfo.username));
142
- if (initialDelayMs > 0) {
143
- console.log(`Starting polling for @${pollingBot.botInfo.username} in ${formatDuration(initialDelayMs)}.`);
144
- await sleep(initialDelayMs);
138
+ async function startManualPollingScheduler(bots) {
139
+ const pollingBots = bots;
140
+ const runtimeStates = new Map();
141
+ const botIds = pollingBots.map((bot) => String(bot.botInfo.id));
142
+ 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
+ for (const [index, bot] of pollingBots.entries()) {
148
+ const botId = String(bot.botInfo.id);
149
+ runtimeStates.set(botId, {
150
+ offset: 0,
151
+ inFlight: false,
152
+ consecutiveFailures: 0,
153
+ lastFailureLogAt: 0,
154
+ });
155
+ const initialDelayMs = Math.min(30_000, index * 3_000 + stableJitterMs(bot.botInfo.username));
156
+ await botPollingState.recordPoll(botId, {
157
+ username: bot.botInfo.username,
158
+ nextPollAt: new Date(Date.now() + initialDelayMs).toISOString(),
159
+ });
160
+ console.log(`Scheduled polling for @${bot.botInfo.username} in ${formatDuration(initialDelayMs)}.`);
145
161
  }
146
162
  while (true) {
147
- try {
148
- const payload = await telegramPollingLimiter.run(() => getUpdatesViaCurl(pollingBot.token, offset));
149
- if (consecutiveFailures > 0) {
150
- console.warn(`Telegram polling recovered for @${pollingBot.botInfo.username} after ${consecutiveFailures} failure(s).`);
151
- await writeTelegramTransportStatus(pollingBot.botInfo.username, {
152
- status: "ok",
153
- consecutiveFailures: 0,
154
- lastRecoveredAt: new Date().toISOString(),
155
- }).catch((error) => {
156
- console.error(`Failed to write Telegram transport recovery status for @${pollingBot.botInfo.username}:`, error);
157
- });
158
- consecutiveFailures = 0;
159
- lastFailureLogAt = 0;
163
+ const now = Date.now();
164
+ for (const bot of pollingBots) {
165
+ const botId = String(bot.botInfo.id);
166
+ const runtime = runtimeStates.get(botId);
167
+ if (!runtime || runtime.inFlight) {
168
+ continue;
160
169
  }
161
- if (payload.result.length === 0) {
170
+ const state = await botPollingState.get(botId, bot.botInfo.username);
171
+ const isMain = botId === mainBotId;
172
+ if (!isMain && state.sleepMode === "deep") {
162
173
  continue;
163
174
  }
164
- const orderedUpdates = orderUpdatesForDispatch(payload.result);
175
+ const nextPollAt = state.nextPollAt ? Date.parse(state.nextPollAt) : 0;
176
+ if (Number.isFinite(nextPollAt) && nextPollAt > now) {
177
+ continue;
178
+ }
179
+ runtime.inFlight = true;
180
+ void pollTelegramBot(bot, runtime, {
181
+ isMain,
182
+ totalBots: pollingBots.length,
183
+ lastMessageAt: state.lastMessageAt,
184
+ }).finally(() => {
185
+ runtime.inFlight = false;
186
+ });
187
+ }
188
+ await sleep(config.telegramSchedulerTickMs);
189
+ }
190
+ }
191
+ async function pollTelegramBot(pollingBot, runtime, options) {
192
+ const botId = String(pollingBot.botInfo.id);
193
+ try {
194
+ const payload = await telegramPollingLimiter.run(() => getUpdatesViaCurl(pollingBot.token, runtime.offset));
195
+ if (runtime.consecutiveFailures > 0) {
196
+ console.warn(`Telegram polling recovered for @${pollingBot.botInfo.username} after ${runtime.consecutiveFailures} failure(s).`);
197
+ await writeTelegramTransportStatus(pollingBot.botInfo.username, {
198
+ status: "ok",
199
+ consecutiveFailures: 0,
200
+ lastRecoveredAt: new Date().toISOString(),
201
+ }).catch((error) => {
202
+ console.error(`Failed to write Telegram transport recovery status for @${pollingBot.botInfo.username}:`, error);
203
+ });
204
+ runtime.consecutiveFailures = 0;
205
+ runtime.lastFailureLogAt = 0;
206
+ }
207
+ const now = Date.now();
208
+ const orderedUpdates = orderUpdatesForDispatch(payload.result);
209
+ if (orderedUpdates.length > 0) {
165
210
  const stopUpdates = orderedUpdates.filter((update) => isStopCommandUpdate(update));
166
211
  if (stopUpdates.length > 0) {
167
212
  for (const update of stopUpdates) {
@@ -176,38 +221,69 @@ async function startManualPolling(bot, index) {
176
221
  }
177
222
  else {
178
223
  for (const update of orderedUpdates) {
179
- const handler = pollingBot.handleUpdates([update]).catch((error) => {
224
+ void pollingBot.handleUpdates([update]).catch((error) => {
180
225
  console.error(`Telegram update ${update.update_id} handler failed for @${pollingBot.botInfo.username}:`, error);
181
- }).finally(() => {
182
- activeHandlers.delete(handler);
183
226
  });
184
- activeHandlers.add(handler);
185
227
  }
186
228
  }
187
- offset = payload.result[payload.result.length - 1].update_id + 1;
229
+ runtime.offset = orderedUpdates[orderedUpdates.length - 1].update_id + 1;
188
230
  }
189
- catch (error) {
190
- consecutiveFailures += 1;
191
- const issue = summarizeTelegramTransportError(error);
192
- const delayMs = Math.max(nextPollingBackoffMs(consecutiveFailures, pollingBot.botInfo.username), getRetryAfterMs(error) ?? 0);
193
- const now = Date.now();
194
- if (consecutiveFailures === 1 || now - lastFailureLogAt >= 60_000) {
195
- lastFailureLogAt = now;
196
- console.error(`Polling failed for @${pollingBot.botInfo.username}: ${issue}. `
197
- + `consecutiveFailures=${consecutiveFailures}; nextRetryIn=${formatDuration(delayMs)}.`);
198
- }
199
- await writeTelegramTransportStatus(pollingBot.botInfo.username, {
200
- status: "degraded",
201
- consecutiveFailures,
202
- lastIssue: issue,
203
- lastFailureAt: new Date().toISOString(),
204
- nextRetryAt: new Date(Date.now() + delayMs).toISOString(),
205
- }).catch((statusError) => {
206
- console.error(`Failed to write Telegram transport failure status for @${pollingBot.botInfo.username}:`, statusError);
207
- });
208
- await sleep(delayMs);
231
+ const lastMessageAt = orderedUpdates.some(hasMessagePayload) ? new Date(now).toISOString() : options.lastMessageAt;
232
+ const nextPollAt = now + computePolicyPollIntervalMs(options.isMain, options.totalBots, lastMessageAt, now);
233
+ await botPollingState.recordPoll(botId, {
234
+ username: pollingBot.botInfo.username,
235
+ lastPollAt: new Date(now).toISOString(),
236
+ lastUpdateAt: orderedUpdates.length > 0 ? new Date(now).toISOString() : undefined,
237
+ lastMessageAt,
238
+ nextPollAt: new Date(nextPollAt).toISOString(),
239
+ consecutiveFailures: 0,
240
+ });
241
+ }
242
+ catch (error) {
243
+ runtime.consecutiveFailures += 1;
244
+ const issue = summarizeTelegramTransportError(error);
245
+ const delayMs = Math.max(nextPollingBackoffMs(runtime.consecutiveFailures, pollingBot.botInfo.username), getRetryAfterMs(error) ?? 0);
246
+ const now = Date.now();
247
+ if (runtime.consecutiveFailures === 1 || now - runtime.lastFailureLogAt >= 60_000) {
248
+ runtime.lastFailureLogAt = now;
249
+ console.error(`Polling failed for @${pollingBot.botInfo.username}: ${issue}. `
250
+ + `consecutiveFailures=${runtime.consecutiveFailures}; nextRetryIn=${formatDuration(delayMs)}.`);
209
251
  }
252
+ await writeTelegramTransportStatus(pollingBot.botInfo.username, {
253
+ status: "degraded",
254
+ consecutiveFailures: runtime.consecutiveFailures,
255
+ lastIssue: issue,
256
+ lastFailureAt: new Date().toISOString(),
257
+ nextRetryAt: new Date(Date.now() + delayMs).toISOString(),
258
+ }).catch((statusError) => {
259
+ console.error(`Failed to write Telegram transport failure status for @${pollingBot.botInfo.username}:`, statusError);
260
+ });
261
+ await botPollingState.recordPoll(botId, {
262
+ username: pollingBot.botInfo.username,
263
+ consecutiveFailures: runtime.consecutiveFailures,
264
+ lastPollAt: new Date(now).toISOString(),
265
+ nextPollAt: new Date(now + delayMs).toISOString(),
266
+ });
267
+ }
268
+ }
269
+ function computePolicyPollIntervalMs(isMain, totalBots, lastMessageAt, now) {
270
+ if (isMain || totalBots < config.telegramTieredPollingMinBots) {
271
+ return config.telegramActivePollIntervalMs;
210
272
  }
273
+ const lastMessageTime = lastMessageAt ? Date.parse(lastMessageAt) : undefined;
274
+ const idleMs = lastMessageTime && Number.isFinite(lastMessageTime)
275
+ ? now - lastMessageTime
276
+ : Number.POSITIVE_INFINITY;
277
+ if (idleMs <= config.telegramActiveIdleMs) {
278
+ return config.telegramActivePollIntervalMs;
279
+ }
280
+ if (idleMs <= config.telegramColdIdleMs) {
281
+ return config.telegramIdlePollIntervalMs;
282
+ }
283
+ return config.telegramColdPollIntervalMs;
284
+ }
285
+ function hasMessagePayload(update) {
286
+ return Boolean(update.message || update.edited_message || update.channel_post);
211
287
  }
212
288
  function orderUpdatesForDispatch(updates) {
213
289
  return [...updates].sort((left, right) => {
@@ -4,18 +4,21 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
6
  import { promisify } from "node:util";
7
+ import { BotPollingStateService } from "./bot-polling-state-service.js";
7
8
  const execFileAsync = promisify(execFile);
8
9
  export class BotManagementService {
9
10
  dataDir;
10
11
  serviceName;
11
12
  restartHelperPath;
13
+ pollingState;
12
14
  envPath;
13
15
  pendingPath;
14
16
  backupsDir;
15
- constructor(dataDir, serviceName, restartHelperPath) {
17
+ constructor(dataDir, serviceName, restartHelperPath, pollingState = new BotPollingStateService(dataDir)) {
16
18
  this.dataDir = dataDir;
17
19
  this.serviceName = serviceName;
18
20
  this.restartHelperPath = restartHelperPath;
21
+ this.pollingState = pollingState;
19
22
  this.envPath = path.join(this.dataDir, ".env");
20
23
  this.pendingPath = path.join(this.dataDir, "pending-bot-operation.json");
21
24
  this.backupsDir = path.join(this.dataDir, "backups");
@@ -26,7 +29,7 @@ export class BotManagementService {
26
29
  if (bots.length === 0) {
27
30
  return "No Telegram bots are configured.";
28
31
  }
29
- return this.formatBots(bots, env.mainBotId);
32
+ return this.formatBots(bots, env.mainBotId, await this.pollingState.list());
30
33
  }
31
34
  async formatCurrentBotSummary(currentBotId) {
32
35
  const env = await this.readEnvConfig();
@@ -36,13 +39,48 @@ export class BotManagementService {
36
39
  const currentLabel = current ? `@${current.username} (${current.id})` : currentBotId;
37
40
  const mainLabel = main ? `@${main.username} (${main.id})` : "not configured";
38
41
  const role = current && main && current.id === main.id ? "main" : "sub";
42
+ const states = await this.pollingState.list();
43
+ const currentState = current ? states[String(current.id)] : undefined;
39
44
  return [
40
45
  `bot: ${currentLabel}${current ? ` [${role}]` : ""}`,
41
46
  `mainBot: ${mainLabel}`,
42
47
  `botCount: ${bots.length}`,
43
- "sleep: awake",
48
+ `sleep: ${this.pollingState.formatMode(String(current?.id ?? currentBotId), role === "main", currentState)}`,
44
49
  ].join("\n");
45
50
  }
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
+ };
71
+ }
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
+ };
83
+ }
46
84
  async getPendingOperationNotice() {
47
85
  const pending = await this.readPendingOperation();
48
86
  if (!pending) {
@@ -551,22 +589,51 @@ export class BotManagementService {
551
589
  || String(bot.id) === withoutAt
552
590
  || bot.token === normalized);
553
591
  }
554
- formatBots(bots, mainBotId) {
592
+ formatBots(bots, mainBotId, states = {}) {
555
593
  if (bots.length === 0) {
556
594
  return "No Telegram bots are configured.";
557
595
  }
558
596
  return [
559
597
  `Configured bots (${bots.length})`,
560
- ...this.formatBotListLines(bots, mainBotId),
598
+ ...this.formatBotListLines(bots, mainBotId, states),
561
599
  ].join("\n");
562
600
  }
563
- formatBotListLines(bots, explicitMainBotId) {
601
+ formatBotListLines(bots, explicitMainBotId, states = {}) {
564
602
  const mainBotId = this.resolveMainBotId(bots, explicitMainBotId);
565
603
  return bots.map((bot, index) => {
566
- const suffix = String(bot.id) === mainBotId ? " [main]" : "";
567
- return `${index + 1}. @${bot.username} (${bot.id})${suffix}`;
604
+ const botId = String(bot.id);
605
+ const isMain = botId === mainBotId;
606
+ const state = states[botId];
607
+ const details = [
608
+ `[${isMain ? "main" : "sub"}]`,
609
+ this.pollingState.formatMode(botId, isMain, state),
610
+ state?.lastMessageAt ? `lastMessage=${this.formatAge(state.lastMessageAt)}` : undefined,
611
+ state?.lastPollAt ? `lastPoll=${this.formatAge(state.lastPollAt)}` : undefined,
612
+ state?.nextPollAt && state.sleepMode !== "deep" ? `nextPoll=${this.formatAge(state.nextPollAt)}` : undefined,
613
+ state?.consecutiveFailures ? `failures=${state.consecutiveFailures}` : undefined,
614
+ ].filter(Boolean).join(" ");
615
+ return `${index + 1}. @${bot.username} (${bot.id}) ${details}`;
568
616
  });
569
617
  }
618
+ formatAge(value) {
619
+ const timestamp = Date.parse(value);
620
+ if (!Number.isFinite(timestamp)) {
621
+ return value;
622
+ }
623
+ const diffMs = timestamp - Date.now();
624
+ const absMs = Math.abs(diffMs);
625
+ const suffix = diffMs > 0 ? "from now" : "ago";
626
+ if (absMs < 60_000) {
627
+ return `${Math.max(0, Math.round(absMs / 1000))}s ${suffix}`;
628
+ }
629
+ if (absMs < 3_600_000) {
630
+ return `${Math.round(absMs / 60_000)}m ${suffix}`;
631
+ }
632
+ if (absMs < 86_400_000) {
633
+ return `${Math.round(absMs / 3_600_000)}h ${suffix}`;
634
+ }
635
+ return `${Math.round(absMs / 86_400_000)}d ${suffix}`;
636
+ }
570
637
  resolveMainBot(bots, mainBotId) {
571
638
  if (bots.length === 0) {
572
639
  return undefined;
@@ -0,0 +1,140 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ const DEFAULT_STATE = {
5
+ version: 1,
6
+ updatedAt: "",
7
+ bots: {},
8
+ };
9
+ export class BotPollingStateService {
10
+ filePath;
11
+ cache;
12
+ pendingWrite;
13
+ writeInFlight = Promise.resolve();
14
+ constructor(dataDir) {
15
+ this.filePath = path.join(dataDir, "bot-polling-state.json");
16
+ }
17
+ async get(botId, username) {
18
+ const state = await this.read();
19
+ const current = state.bots[botId] ?? {
20
+ botId,
21
+ username,
22
+ sleepMode: "awake",
23
+ consecutiveFailures: 0,
24
+ };
25
+ if (username && current.username !== username) {
26
+ current.username = username;
27
+ state.bots[botId] = current;
28
+ this.scheduleWrite();
29
+ }
30
+ return current;
31
+ }
32
+ async list() {
33
+ return { ...(await this.read()).bots };
34
+ }
35
+ async setSleepMode(botId, sleepMode, username) {
36
+ const state = await this.read();
37
+ const current = state.bots[botId] ?? {
38
+ botId,
39
+ username,
40
+ sleepMode,
41
+ consecutiveFailures: 0,
42
+ };
43
+ current.sleepMode = sleepMode;
44
+ if (username) {
45
+ current.username = username;
46
+ }
47
+ if (sleepMode === "awake") {
48
+ current.nextPollAt = new Date().toISOString();
49
+ }
50
+ state.bots[botId] = current;
51
+ await this.writeNow();
52
+ return current;
53
+ }
54
+ async recordPoll(botId, patch) {
55
+ const state = await this.read();
56
+ const current = state.bots[botId] ?? {
57
+ botId,
58
+ sleepMode: "awake",
59
+ consecutiveFailures: 0,
60
+ };
61
+ state.bots[botId] = {
62
+ ...current,
63
+ ...patch,
64
+ botId,
65
+ };
66
+ this.scheduleWrite();
67
+ }
68
+ async prune(validBotIds) {
69
+ const valid = new Set(validBotIds);
70
+ const state = await this.read();
71
+ let changed = false;
72
+ for (const botId of Object.keys(state.bots)) {
73
+ if (!valid.has(botId)) {
74
+ delete state.bots[botId];
75
+ changed = true;
76
+ }
77
+ }
78
+ if (changed) {
79
+ await this.writeNow();
80
+ }
81
+ }
82
+ formatMode(botId, isMain, state) {
83
+ if (isMain) {
84
+ return "awake";
85
+ }
86
+ return state?.sleepMode === "deep" ? "deep sleep" : "awake";
87
+ }
88
+ async flush() {
89
+ if (this.pendingWrite) {
90
+ clearTimeout(this.pendingWrite);
91
+ this.pendingWrite = undefined;
92
+ }
93
+ await this.writeNow();
94
+ }
95
+ async read() {
96
+ if (this.cache) {
97
+ return this.cache;
98
+ }
99
+ const raw = await fs.readFile(this.filePath, "utf8").catch(() => "");
100
+ if (!raw.trim()) {
101
+ this.cache = { ...DEFAULT_STATE, bots: {} };
102
+ return this.cache;
103
+ }
104
+ try {
105
+ const parsed = JSON.parse(raw);
106
+ this.cache = {
107
+ version: 1,
108
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
109
+ bots: parsed.bots && typeof parsed.bots === "object" ? parsed.bots : {},
110
+ };
111
+ }
112
+ catch {
113
+ this.cache = { ...DEFAULT_STATE, bots: {} };
114
+ }
115
+ return this.cache;
116
+ }
117
+ scheduleWrite() {
118
+ if (this.pendingWrite) {
119
+ return;
120
+ }
121
+ this.pendingWrite = setTimeout(() => {
122
+ this.pendingWrite = undefined;
123
+ void this.writeNow().catch((error) => {
124
+ console.error("Failed to write bot polling state:", error);
125
+ });
126
+ }, 1000);
127
+ this.pendingWrite.unref();
128
+ }
129
+ async writeNow() {
130
+ const state = await this.read();
131
+ state.updatedAt = new Date().toISOString();
132
+ const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
133
+ this.writeInFlight = this.writeInFlight.then(async () => {
134
+ await fs.mkdir(path.dirname(this.filePath), { recursive: true });
135
+ await fs.writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
136
+ await fs.rename(tmpPath, this.filePath);
137
+ });
138
+ await this.writeInFlight;
139
+ }
140
+ }
@@ -13,6 +13,8 @@ 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" },
16
18
  { command: "install", description: "Install or update Codex or Claude" },
17
19
  { command: "login", description: "Run provider login flow" },
18
20
  { command: "reset", description: "Clear this chat binding" },
@@ -0,0 +1,78 @@
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.
@@ -35,6 +35,10 @@ 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).
41
+
38
42
  ## Workspace policy
39
43
 
40
44
  Default fresh sessions should not use a broad parent folder like `/home/au2223/projects` as their direct working directory.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.13.16",
3
+ "version": "0.13.18",
4
4
  "description": "Personal installable session server for continuing local AI work across PC and Telegram",
5
5
  "license": "MIT",
6
6
  "type": "module",