appback-remoteagent 0.14.4 → 0.14.6

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
@@ -89,6 +89,7 @@ const RECOGNIZED_COMMANDS = new Set([
89
89
  ]);
90
90
  const TELEGRAM_STALE_UPDATE_GRACE_SECONDS = 10;
91
91
  const TELEGRAM_PROCESS_STARTED_AT_SECONDS = Math.floor(Date.now() / 1000);
92
+ const activeWorkLoopKeys = new Set();
92
93
  const REPORT_CONTINUE_PROMPT = [
93
94
  "Continue the same task now.",
94
95
  "Do more concrete work before replying again.",
@@ -1132,6 +1133,16 @@ async function runWithPendingAnimation(botToken, chatId, task) {
1132
1133
  async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botManagement, helpers, autoContinue, memoryService, transform = (blocks) => blocks) {
1133
1134
  const currentSession = await bridge.status(botId, chatId);
1134
1135
  const sessionId = currentSession?.session.sessionId;
1136
+ const activeKey = workLoopKey(botId, chatId, sessionId);
1137
+ if (activeWorkLoopKeys.has(activeKey)) {
1138
+ const message = [
1139
+ `Session ${currentSession?.session.publicId ?? sessionId ?? `${botId}:${chatId}`} is already running.`,
1140
+ "Send /stop to interrupt the active work, then send the instruction again.",
1141
+ ].join("\n");
1142
+ await bridge.logSystem(botId, chatId, `Rejected overlapping Telegram work loop for ${activeKey}.`);
1143
+ return [message];
1144
+ }
1145
+ activeWorkLoopKeys.add(activeKey);
1135
1146
  if (currentSession) {
1136
1147
  await memoryService.recordInstruction(currentSession.session, message);
1137
1148
  }
@@ -1319,6 +1330,7 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1319
1330
  }
1320
1331
  }
1321
1332
  finally {
1333
+ activeWorkLoopKeys.delete(activeKey);
1322
1334
  if (currentSession) {
1323
1335
  if (providerCompleted) {
1324
1336
  await botManagement.markProviderCompleted(botId, sessionId);
@@ -1329,6 +1341,9 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1329
1341
  }
1330
1342
  }
1331
1343
  }
1344
+ function workLoopKey(botId, chatId, sessionId) {
1345
+ return sessionId ? `session:${sessionId}` : `chat:${botId}:${chatId}`;
1346
+ }
1332
1347
  class SilentTelegramAbort extends Error {
1333
1348
  constructor(message) {
1334
1349
  super(message);
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" },
@@ -100,8 +100,11 @@ journalctl -u remoteagent -f
100
100
  Restart after build:
101
101
 
102
102
  ```bash
103
- sudo npm install -g appback-remoteagent@<version>
103
+ export PATH="/home/au2223/.local/bin:/home/au2223/.nvm/versions/node/v22.22.0/bin:$PATH"
104
+ npm install -g appback-remoteagent@<version>
105
+ remoteagent-install
104
106
  sudo systemctl restart remoteagent
107
+ node -p 'require("/home/au2223/.nvm/versions/node/v22.22.0/lib/node_modules/appback-remoteagent/package.json").version'
105
108
  ```
106
109
 
107
110
  Check the lock owner:
package/docs/RELEASING.md CHANGED
@@ -100,14 +100,20 @@ Server 30 uses a systemd service:
100
100
  VERSION=0.14.0
101
101
  ssh au2223@192.168.0.30 "VERSION=$VERSION bash -s" <<'REMOTE'
102
102
  set -euo pipefail
103
- sudo -n env "PATH=$PATH" npm install -g appback-remoteagent@$VERSION
103
+ export PATH="/home/au2223/.local/bin:/home/au2223/.nvm/versions/node/v22.22.0/bin:$PATH"
104
+ npm install -g appback-remoteagent@$VERSION
104
105
  /home/au2223/.nvm/versions/node/v22.22.0/bin/remoteagent-install
105
106
  sudo -n systemctl restart remoteagent
106
107
  systemctl is-active remoteagent
108
+ node -p 'require("/home/au2223/.nvm/versions/node/v22.22.0/lib/node_modules/appback-remoteagent/package.json").version'
107
109
  journalctl -u remoteagent --since '2 minutes ago' --no-pager
108
110
  REMOTE
109
111
  ```
110
112
 
113
+ Do not run `sudo npm install -g` on server 30. The global RemoteAgent package is installed under
114
+ `/home/au2223/.nvm/versions/node/v22.22.0`; only the `systemctl restart remoteagent` step needs sudo.
115
+ Runtime state remains in `/home/au2223/.remoteagent` and must not be deleted during npm upgrades.
116
+
111
117
  Server 26 currently uses user-level start/stop scripts, not a systemd `remoteagent.service`:
112
118
 
113
119
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.14.4",
3
+ "version": "0.14.6",
4
4
  "description": "Personal installable session server for continuing local AI work across PC and Telegram",
5
5
  "license": "MIT",
6
6
  "type": "module",