appback-remoteagent 0.14.3 → 0.14.5

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
@@ -14,6 +14,7 @@ TELEGRAM_ACTIVE_POLL_INTERVAL_MS=3000
14
14
  TELEGRAM_RUNNING_POLL_INTERVAL_MS=60000
15
15
  TELEGRAM_SECONDARY_POLL_INTERVAL_MS=60000
16
16
  TELEGRAM_TERTIARY_POLL_INTERVAL_MS=180000
17
+ TELEGRAM_RECOVERY_CHECK_INTERVAL_MS=60000
17
18
  ARTIFACT_CLEANUP_ENABLED=true
18
19
  ARTIFACT_RETENTION_DAYS=30
19
20
  ARTIFACT_CLEANUP_INTERVAL_MS=86400000
package/dist/bot.js CHANGED
@@ -1087,10 +1087,14 @@ async function runWithPendingAnimation(botToken, chatId, task) {
1087
1087
  const rendered = formatProviderTelegramChunks(progressChunks, parseMode);
1088
1088
  const extra = rendered.parseMode ? { parse_mode: rendered.parseMode } : undefined;
1089
1089
  for (const chunk of rendered.chunks) {
1090
- await sendTelegramMessage(botToken, chatId, chunk, extra);
1090
+ await sendTelegramMessage(botToken, chatId, chunk, extra).catch((error) => {
1091
+ console.warn(`[telegram-progress-delivery] chat=${chatId} dropped progress message: ${formatTelegramDeliveryError(error)}`);
1092
+ });
1091
1093
  }
1092
1094
  if (normalized.documents.length > 0) {
1093
- await sendTelegramDocuments(botToken, chatId, normalized.documents);
1095
+ await sendTelegramDocuments(botToken, chatId, normalized.documents).catch((error) => {
1096
+ console.warn(`[telegram-progress-delivery] chat=${chatId} dropped progress document(s): ${formatTelegramDeliveryError(error)}`);
1097
+ });
1094
1098
  }
1095
1099
  },
1096
1100
  };
@@ -2233,23 +2237,38 @@ async function sendTelegramDocument(botToken, chatId, document) {
2233
2237
  async function sendTelegramMessage(botToken, chatId, text, extra) {
2234
2238
  const startedAt = Date.now();
2235
2239
  try {
2236
- try {
2237
- return await callTelegramApi(botToken, "sendMessage", {
2238
- chat_id: String(chatId),
2239
- text,
2240
- parse_mode: extra?.parse_mode,
2241
- });
2242
- }
2243
- catch (error) {
2244
- if (!extra?.parse_mode || !isTelegramEntityParseError(error)) {
2245
- throw error;
2240
+ let lastError;
2241
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
2242
+ try {
2243
+ try {
2244
+ return await callTelegramApi(botToken, "sendMessage", {
2245
+ chat_id: String(chatId),
2246
+ text,
2247
+ parse_mode: extra?.parse_mode,
2248
+ });
2249
+ }
2250
+ catch (error) {
2251
+ if (!extra?.parse_mode || !isTelegramEntityParseError(error)) {
2252
+ throw error;
2253
+ }
2254
+ console.warn(`[telegram-sendMessage-fallback] chat=${chatId} parseMode=${extra.parse_mode}: ${formatTelegramDeliveryError(error)}`);
2255
+ return await callTelegramApi(botToken, "sendMessage", {
2256
+ chat_id: String(chatId),
2257
+ text: stripTelegramHtml(text),
2258
+ });
2259
+ }
2260
+ }
2261
+ catch (error) {
2262
+ lastError = error;
2263
+ if (attempt >= 3 || !isRetryableTelegramDeliveryError(error)) {
2264
+ throw error;
2265
+ }
2266
+ const delayMs = attempt * 1500;
2267
+ console.warn(`[telegram-sendMessage-retry] chat=${chatId} attempt=${attempt}/3 delayMs=${delayMs}: ${formatTelegramDeliveryError(error)}`);
2268
+ await sleep(delayMs);
2246
2269
  }
2247
- console.warn(`[telegram-sendMessage-fallback] chat=${chatId} parseMode=${extra.parse_mode}: ${error instanceof Error ? error.message : String(error)}`);
2248
- return await callTelegramApi(botToken, "sendMessage", {
2249
- chat_id: String(chatId),
2250
- text: stripTelegramHtml(text),
2251
- });
2252
2270
  }
2271
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
2253
2272
  }
2254
2273
  finally {
2255
2274
  const elapsedMs = Date.now() - startedAt;
@@ -2289,7 +2308,16 @@ async function callTelegramApi(botToken, method, params) {
2289
2308
  args.push("--data-urlencode", `${key}=${value}`);
2290
2309
  }
2291
2310
  }
2292
- const { stdout, stderr } = await execFileAsync("curl", args);
2311
+ let stdout;
2312
+ let stderr;
2313
+ try {
2314
+ const result = await execFileAsync("curl", args);
2315
+ stdout = result.stdout;
2316
+ stderr = result.stderr;
2317
+ }
2318
+ catch (error) {
2319
+ throw new TelegramApiError(method, summarizeCurlTelegramError(error, method));
2320
+ }
2293
2321
  if (stderr?.trim()) {
2294
2322
  console.error(`curl stderr for ${method}: ${stderr.trim()}`);
2295
2323
  }
@@ -2309,6 +2337,26 @@ class TelegramApiError extends Error {
2309
2337
  this.name = "TelegramApiError";
2310
2338
  }
2311
2339
  }
2340
+ function summarizeCurlTelegramError(error, method) {
2341
+ if (!(error instanceof Error)) {
2342
+ return `Telegram API ${method} failed: ${String(error)}`;
2343
+ }
2344
+ const stderr = typeof error === "object" && error !== null && "stderr" in error
2345
+ ? String(error.stderr ?? "").trim()
2346
+ : "";
2347
+ const code = typeof error === "object" && error !== null && "code" in error
2348
+ ? String(error.code ?? "")
2349
+ : "";
2350
+ const message = stderr || error.message;
2351
+ return [`Telegram API ${method} curl failed`, code ? `code=${code}` : undefined, message].filter(Boolean).join(": ");
2352
+ }
2353
+ function formatTelegramDeliveryError(error) {
2354
+ return error instanceof Error ? error.message : String(error);
2355
+ }
2356
+ function isRetryableTelegramDeliveryError(error) {
2357
+ const message = formatTelegramDeliveryError(error);
2358
+ return /timed out|timeout|Bad Gateway|502|503|504|ECONNRESET|connection reset|EAI_AGAIN|ENOTFOUND/i.test(message);
2359
+ }
2312
2360
  function isTelegramForbiddenError(error) {
2313
2361
  if (error instanceof GrammyError) {
2314
2362
  return error.error_code === 403 || /^Forbidden:/i.test(error.description);
package/dist/config.js CHANGED
@@ -140,6 +140,7 @@ export const config = {
140
140
  telegramRunningPollIntervalMs: readTimeout("TELEGRAM_RUNNING_POLL_INTERVAL_MS", 60_000),
141
141
  telegramSecondaryPollIntervalMs: readTimeout("TELEGRAM_SECONDARY_POLL_INTERVAL_MS", 60_000),
142
142
  telegramTertiaryPollIntervalMs: readTimeout("TELEGRAM_TERTIARY_POLL_INTERVAL_MS", 180_000),
143
+ telegramRecoveryCheckIntervalMs: readTimeout("TELEGRAM_RECOVERY_CHECK_INTERVAL_MS", 60_000),
143
144
  telegramOwnerId: readOptional("TELEGRAM_OWNER_ID"),
144
145
  telegramMessageBatchMs: readNonNegativeTimeout("TELEGRAM_MESSAGE_BATCH_MS", 1500),
145
146
  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 { ProviderRecoveryService } from "./services/provider-recovery-service.js";
18
19
  import { computePolicyPollIntervalMs, computeRecentMessageRanks } from "./services/polling-policy.js";
19
20
  import { terminateAllSpawnedExecutions } from "./adapters/windows-shell.js";
20
21
  import { setTelegramCommandMenu } from "./telegram-command-menu.js";
@@ -139,6 +140,9 @@ main().catch((error) => {
139
140
  async function startManualPollingScheduler(bots) {
140
141
  const pollingBots = bots;
141
142
  const runtimeStates = new Map();
143
+ const providerRecovery = new ProviderRecoveryService(config.dataDir, botPollingState);
144
+ let nextRecoveryCheckAt = 0;
145
+ let recoveryCheckInFlight = false;
142
146
  const botIds = pollingBots.map((bot) => String(bot.botInfo.id));
143
147
  await botPollingState.prune(botIds);
144
148
  for (const [index, bot] of pollingBots.entries()) {
@@ -158,6 +162,23 @@ async function startManualPollingScheduler(bots) {
158
162
  }
159
163
  while (true) {
160
164
  const now = Date.now();
165
+ if (!recoveryCheckInFlight && now >= nextRecoveryCheckAt) {
166
+ recoveryCheckInFlight = true;
167
+ nextRecoveryCheckAt = now + config.telegramRecoveryCheckIntervalMs;
168
+ void providerRecovery.reconcileStaleRunningStates(pollingBots.map((bot) => ({
169
+ botId: String(bot.botInfo.id),
170
+ username: bot.botInfo.username,
171
+ token: bot.token,
172
+ }))).then((result) => {
173
+ if (result.recovered > 0) {
174
+ console.warn(`[provider-recovery] cleared ${result.recovered} stale running session(s) after checking ${result.checked}.`);
175
+ }
176
+ }).catch((error) => {
177
+ console.error("[provider-recovery] reconciliation failed:", error);
178
+ }).finally(() => {
179
+ recoveryCheckInFlight = false;
180
+ });
181
+ }
161
182
  let activePolls = [...runtimeStates.values()].filter((state) => state.inFlight).length;
162
183
  const pollingStates = await botPollingState.list();
163
184
  const rankByBotId = computeRecentMessageRanks(botIds, pollingStates);
@@ -98,6 +98,25 @@ export class BotPollingStateService {
98
98
  };
99
99
  this.scheduleWrite();
100
100
  }
101
+ async clearRunningSessions(botId, sessionIds, patch = {}) {
102
+ const remove = new Set(sessionIds);
103
+ if (remove.size === 0) {
104
+ return undefined;
105
+ }
106
+ const state = await this.read();
107
+ const current = state.bots[botId];
108
+ if (!current?.runningSessionIds?.length) {
109
+ return current;
110
+ }
111
+ current.runningSessionIds = current.runningSessionIds.filter((value) => !remove.has(value));
112
+ if (current.runningSessionIds.length === 0) {
113
+ delete current.runningSessionIds;
114
+ }
115
+ Object.assign(current, patch);
116
+ state.bots[botId] = current;
117
+ await this.writeNow();
118
+ return current;
119
+ }
101
120
  async prune(validBotIds) {
102
121
  const valid = new Set(validBotIds);
103
122
  const state = await this.read();
@@ -0,0 +1,261 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ const EMPTY_STATE = { chats: {}, sessions: {}, telegramContacts: {}, settings: {} };
8
+ export class ProviderRecoveryService {
9
+ dataDir;
10
+ pollingState;
11
+ noticePath;
12
+ constructor(dataDir, pollingState) {
13
+ this.dataDir = dataDir;
14
+ this.pollingState = pollingState;
15
+ this.noticePath = path.join(dataDir, "provider-recovery-notices.json");
16
+ }
17
+ async reconcileStaleRunningStates(bots) {
18
+ const botById = new Map(bots.map((bot) => [bot.botId, bot]));
19
+ const pollingStates = await this.pollingState.list();
20
+ const runningEntries = Object.entries(pollingStates)
21
+ .filter(([, state]) => state.runningSessionIds && state.runningSessionIds.length > 0);
22
+ if (runningEntries.length === 0) {
23
+ return { checked: 0, recovered: 0 };
24
+ }
25
+ const bridgeState = await this.readBridgeState();
26
+ const processNeedles = await readProviderProcessNeedles();
27
+ let checked = 0;
28
+ let recovered = 0;
29
+ for (const [botId, state] of runningEntries) {
30
+ const bot = botById.get(botId);
31
+ if (!bot) {
32
+ continue;
33
+ }
34
+ const staleSessionIds = [];
35
+ for (const sessionId of state.runningSessionIds ?? []) {
36
+ checked += 1;
37
+ const session = bridgeState.sessions[sessionId];
38
+ if (!session) {
39
+ staleSessionIds.push(sessionId);
40
+ continue;
41
+ }
42
+ const needles = providerNeedlesForSession(session);
43
+ if (needles.length === 0 || processNeedles.hasAny(needles)) {
44
+ continue;
45
+ }
46
+ staleSessionIds.push(sessionId);
47
+ }
48
+ if (staleSessionIds.length === 0) {
49
+ continue;
50
+ }
51
+ const now = new Date().toISOString();
52
+ await this.pollingState.clearRunningSessions(botId, staleSessionIds, {
53
+ username: bot.username,
54
+ lastProviderFinishedAt: now,
55
+ lastRecoveryAt: now,
56
+ lastRecoveryReason: "stale running marker cleared; no matching provider process was found",
57
+ });
58
+ recovered += staleSessionIds.length;
59
+ for (const sessionId of staleSessionIds) {
60
+ const session = bridgeState.sessions[sessionId];
61
+ const binding = findBinding(bridgeState, botId, sessionId);
62
+ if (!session || !binding) {
63
+ continue;
64
+ }
65
+ await this.notifyRecovered(bot, binding, session).catch((error) => {
66
+ console.error(`[provider-recovery] failed to notify @${bot.username ?? bot.botId} for ${session.publicId}:`, summarizeError(error));
67
+ });
68
+ }
69
+ }
70
+ return { checked, recovered };
71
+ }
72
+ async notifyRecovered(bot, binding, session) {
73
+ const noticeKey = [
74
+ "stale-running-cleared",
75
+ bot.botId,
76
+ binding.chatId,
77
+ session.sessionId,
78
+ session.updatedAt,
79
+ ].join(":");
80
+ if (await this.wasNoticeDelivered(noticeKey)) {
81
+ return;
82
+ }
83
+ const text = [
84
+ `[RemoteAgent Recovery | ${session.publicId}]`,
85
+ "Stale running state was cleared.",
86
+ "",
87
+ "Reason:",
88
+ "RemoteAgent had a running marker for this session, but no matching Codex/Claude process was found.",
89
+ "",
90
+ "Action:",
91
+ "- cleared the running marker",
92
+ "- kept session, workspace, and history unchanged",
93
+ "- the next message can start a fresh provider execution",
94
+ ].join("\n");
95
+ await sendTelegramPlainText(bot.token, binding.chatId, text);
96
+ await this.markNoticeDelivered(noticeKey);
97
+ }
98
+ async wasNoticeDelivered(key) {
99
+ const notices = await this.readNoticeFile();
100
+ return Boolean(notices.delivered[key]);
101
+ }
102
+ async markNoticeDelivered(key) {
103
+ const notices = await this.readNoticeFile();
104
+ notices.updatedAt = new Date().toISOString();
105
+ notices.delivered[key] = notices.updatedAt;
106
+ await writeJsonAtomic(this.noticePath, notices);
107
+ }
108
+ async readNoticeFile() {
109
+ const raw = await fs.readFile(this.noticePath, "utf8").catch(() => "");
110
+ if (!raw.trim()) {
111
+ return { version: 1, updatedAt: "", delivered: {} };
112
+ }
113
+ try {
114
+ const parsed = JSON.parse(raw);
115
+ return {
116
+ version: 1,
117
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
118
+ delivered: parsed.delivered && typeof parsed.delivered === "object" ? parsed.delivered : {},
119
+ };
120
+ }
121
+ catch {
122
+ return { version: 1, updatedAt: "", delivered: {} };
123
+ }
124
+ }
125
+ async readBridgeState() {
126
+ const state = await this.readLegacyState();
127
+ const sessionsDir = path.join(this.dataDir, "sessions");
128
+ const channelsDir = path.join(this.dataDir, "channels", "telegram");
129
+ const sessionDirs = await fs.readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
130
+ for (const entry of sessionDirs) {
131
+ if (!entry.isDirectory()) {
132
+ continue;
133
+ }
134
+ const session = await readJson(path.join(sessionsDir, entry.name, "session.json"));
135
+ if (session?.sessionId) {
136
+ state.sessions[session.sessionId] = session;
137
+ }
138
+ }
139
+ const botDirs = await fs.readdir(channelsDir, { withFileTypes: true }).catch(() => []);
140
+ for (const botEntry of botDirs) {
141
+ if (!botEntry.isDirectory()) {
142
+ continue;
143
+ }
144
+ const botId = decodeURIComponent(botEntry.name);
145
+ const bindingFiles = await fs.readdir(path.join(channelsDir, botEntry.name), { withFileTypes: true }).catch(() => []);
146
+ for (const fileEntry of bindingFiles) {
147
+ if (!fileEntry.isFile() || !fileEntry.name.endsWith(".json")) {
148
+ continue;
149
+ }
150
+ const binding = await readJson(path.join(channelsDir, botEntry.name, fileEntry.name));
151
+ if (binding?.chatId && binding.sessionId) {
152
+ binding.botId = binding.botId || botId;
153
+ state.chats[`${binding.botId}:${binding.chatId}`] = binding;
154
+ }
155
+ }
156
+ }
157
+ return state;
158
+ }
159
+ async readLegacyState() {
160
+ const raw = await fs.readFile(path.join(this.dataDir, "state.json"), "utf8").catch(() => "");
161
+ if (!raw.trim()) {
162
+ return { ...EMPTY_STATE, chats: {}, sessions: {}, telegramContacts: {}, settings: {} };
163
+ }
164
+ try {
165
+ const parsed = JSON.parse(raw);
166
+ return {
167
+ chats: parsed.chats && typeof parsed.chats === "object" ? parsed.chats : {},
168
+ sessions: parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {},
169
+ telegramContacts: parsed.telegramContacts && typeof parsed.telegramContacts === "object" ? parsed.telegramContacts : {},
170
+ settings: parsed.settings && typeof parsed.settings === "object" ? parsed.settings : {},
171
+ };
172
+ }
173
+ catch {
174
+ return { ...EMPTY_STATE, chats: {}, sessions: {}, telegramContacts: {}, settings: {} };
175
+ }
176
+ }
177
+ }
178
+ function providerNeedlesForSession(session) {
179
+ return [
180
+ `REMOTEAGENT_SESSION_ID=${session.sessionId}`,
181
+ session.codex?.sessionId,
182
+ session.claude?.sessionId,
183
+ ].filter((value) => Boolean(value && value.length >= 8));
184
+ }
185
+ function findBinding(state, botId, sessionId) {
186
+ return Object.values(state.chats).find((binding) => binding.botId === botId && binding.sessionId === sessionId);
187
+ }
188
+ async function readProviderProcessNeedles() {
189
+ const text = process.platform === "linux"
190
+ ? await readLinuxProcessText()
191
+ : await readPsProcessText();
192
+ return {
193
+ hasAny(needles) {
194
+ return needles.some((needle) => text.includes(needle));
195
+ },
196
+ };
197
+ }
198
+ async function readLinuxProcessText() {
199
+ const entries = await fs.readdir("/proc", { withFileTypes: true }).catch(() => []);
200
+ const chunks = [];
201
+ for (const entry of entries) {
202
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) {
203
+ continue;
204
+ }
205
+ const procDir = path.join("/proc", entry.name);
206
+ const [cmdline, environ] = await Promise.all([
207
+ fs.readFile(path.join(procDir, "cmdline"), "utf8").catch(() => ""),
208
+ fs.readFile(path.join(procDir, "environ"), "utf8").catch(() => ""),
209
+ ]);
210
+ if (cmdline || environ) {
211
+ chunks.push(cmdline.replace(/\0/g, " "), environ.replace(/\0/g, " "));
212
+ }
213
+ }
214
+ return chunks.join("\n");
215
+ }
216
+ async function readPsProcessText() {
217
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid=,args="]).catch(() => ({ stdout: "" }));
218
+ return stdout;
219
+ }
220
+ async function sendTelegramPlainText(token, chatId, text) {
221
+ const payload = JSON.stringify({
222
+ chat_id: chatId,
223
+ text,
224
+ });
225
+ const { stdout } = await execFileAsync("curl", [
226
+ "-sS",
227
+ "-4",
228
+ "--max-time",
229
+ "15",
230
+ "-H",
231
+ "Content-Type: application/json",
232
+ "-d",
233
+ payload,
234
+ `https://api.telegram.org/bot${token}/sendMessage`,
235
+ ]);
236
+ const parsed = JSON.parse(stdout);
237
+ if (!parsed.ok) {
238
+ throw new Error(parsed.description || "sendMessage failed");
239
+ }
240
+ }
241
+ async function readJson(filePath) {
242
+ const raw = await fs.readFile(filePath, "utf8").catch(() => "");
243
+ if (!raw.trim()) {
244
+ return undefined;
245
+ }
246
+ try {
247
+ return JSON.parse(raw);
248
+ }
249
+ catch {
250
+ return undefined;
251
+ }
252
+ }
253
+ async function writeJsonAtomic(filePath, value) {
254
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
255
+ const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
256
+ await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
257
+ await fs.rename(temporaryPath, filePath);
258
+ }
259
+ function summarizeError(error) {
260
+ return error instanceof Error ? error.message.replace(/bot\d+:[A-Za-z0-9_-]+/g, "bot[redacted]") : String(error);
261
+ }
@@ -4,13 +4,19 @@ const execFileAsync = promisify(execFile);
4
4
  export const TELEGRAM_COMMAND_MENU = [
5
5
  { command: "start", description: "Start a new Codex or Claude session" },
6
6
  { command: "list", description: "List sessions" },
7
+ { command: "new", description: "Start a fresh session" },
7
8
  { command: "switch", description: "Switch to a session" },
8
9
  { command: "status", description: "Show current session status" },
10
+ { command: "attach", description: "Attach an existing provider session" },
9
11
  { command: "state", description: "Show or edit session state notes" },
10
12
  { command: "option", description: "Show or change runtime options" },
13
+ { command: "secret", description: "Store or manage hidden secret values" },
14
+ { command: "docs", description: "Pin or find session documents" },
11
15
  { command: "model", description: "Show or change provider model" },
12
16
  { command: "stop", description: "Stop active work and clear queued messages" },
17
+ { command: "sandbox", description: "Set Codex sandbox mode" },
13
18
  { command: "batch", description: "Collect and send a multi-message batch" },
19
+ { command: "artifacts", description: "List or clean uploaded artifacts" },
14
20
  { command: "bots", description: "List configured Telegram bots" },
15
21
  { command: "bot", description: "Manage Telegram bots" },
16
22
  { command: "install", description: "Install or update Codex or Claude" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
4
4
  "description": "Personal installable session server for continuing local AI work across PC and Telegram",
5
5
  "license": "MIT",
6
6
  "type": "module",