appback-remoteagent 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/adapters/codex-adapter.js +22 -3
- package/dist/adapters/windows-shell.js +27 -3
- package/dist/bot.js +226 -25
- package/dist/index.js +4 -37
- package/dist/services/bridge-service.js +28 -5
- package/dist/telegram-bot-identity.js +36 -0
- package/dist/telegram-command-menu.js +1 -0
- package/docs/OPERATIONS.md +4 -0
- package/docs/RELEASING.md +46 -0
- package/package.json +2 -1
- package/scripts/selftest-codex-stream.mjs +63 -0
- package/scripts/selftest-telegram-update.mjs +214 -2
package/README.md
CHANGED
|
@@ -123,6 +123,9 @@ Current command surface implemented in `src/bot.ts`:
|
|
|
123
123
|
| `/batch done` | Alias for `/batch send` |
|
|
124
124
|
| `/batch cancel` | Discards the current batch |
|
|
125
125
|
| `/batch status` | Shows current batch state |
|
|
126
|
+
| `/queue` | Lists instructions waiting behind the active session work |
|
|
127
|
+
| `/queue remove <id>` | Removes one waiting instruction by its `Q001`-style id |
|
|
128
|
+
| `/queue del` | Removes the most recently queued instruction |
|
|
126
129
|
|
|
127
130
|
Multi-bot polling is tiered by recent activity and active provider work. See [docs/BOT_POLLING_POLICY.md](./docs/BOT_POLLING_POLICY.md).
|
|
128
131
|
|
|
@@ -18,7 +18,7 @@ export class CodexAdapter {
|
|
|
18
18
|
const args = request.sessionId
|
|
19
19
|
? this.buildResumeArgs(request, outputPath, sandboxMode)
|
|
20
20
|
: this.buildExecArgs(request, outputPath, sandboxMode);
|
|
21
|
-
const { stdout, stderr, code, timedOut } = await this.runCodex(args, request.cwd, request.remoteSessionId, request.publicSessionId, request.message);
|
|
21
|
+
const { stdout, stderr, code, timedOut } = await this.runCodex(args, request.cwd, request.remoteSessionId, request.publicSessionId, request.message, request.onProgress);
|
|
22
22
|
try {
|
|
23
23
|
const sessionId = this.extractThreadId(stdout) ?? request.sessionId;
|
|
24
24
|
const output = await this.readOutput(outputPath) || this.extractAgentMessage(stdout);
|
|
@@ -102,12 +102,31 @@ export class CodexAdapter {
|
|
|
102
102
|
args.push("--dangerously-bypass-approvals-and-sandbox");
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
runCodex(args, cwd, remoteSessionId, publicSessionId, input) {
|
|
105
|
+
runCodex(args, cwd, remoteSessionId, publicSessionId, input, onProgress) {
|
|
106
106
|
return spawnWithPlatformShell(this.codexBin, args, cwd, this.currentTimeoutMs(), input, remoteSessionId, {
|
|
107
107
|
REMOTEAGENT_SESSION_ID: remoteSessionId,
|
|
108
108
|
REMOTEAGENT_PUBLIC_SESSION_ID: publicSessionId ?? "",
|
|
109
109
|
REMOTEAGENT_WORKSPACE: cwd,
|
|
110
|
-
});
|
|
110
|
+
}, (line) => this.handleProgressLine(line, onProgress));
|
|
111
|
+
}
|
|
112
|
+
async handleProgressLine(line, onProgress) {
|
|
113
|
+
if (!onProgress || !line.startsWith("{")) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
let text;
|
|
117
|
+
try {
|
|
118
|
+
const event = JSON.parse(line);
|
|
119
|
+
text = event.type === "item.completed" && event.item?.type === "agent_message"
|
|
120
|
+
? event.item.text?.trim()
|
|
121
|
+
: undefined;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Ignore non-JSON stdout and let the normal final-response parser handle it.
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (text && /^REPORT:progress(?:\r?\n|$)/i.test(text)) {
|
|
128
|
+
await onProgress(text);
|
|
129
|
+
}
|
|
111
130
|
}
|
|
112
131
|
extractThreadId(stdout) {
|
|
113
132
|
for (const line of stdout.split(/\r?\n/)) {
|
|
@@ -2,7 +2,7 @@ import process from "node:process";
|
|
|
2
2
|
import { execFile, spawn } from "node:child_process";
|
|
3
3
|
import { buildProviderEnv } from "./runtime-env.js";
|
|
4
4
|
const activeCommands = new Map();
|
|
5
|
-
export function spawnWithPlatformShell(bin, args, cwd, timeoutMs, input, executionKey, extraEnv) {
|
|
5
|
+
export function spawnWithPlatformShell(bin, args, cwd, timeoutMs, input, executionKey, extraEnv, onStdoutLine) {
|
|
6
6
|
return new Promise((resolve, reject) => {
|
|
7
7
|
const command = process.platform === "win32"
|
|
8
8
|
? spawn("cmd.exe", ["/d", "/c", "call", bin, ...args], {
|
|
@@ -19,13 +19,29 @@ export function spawnWithPlatformShell(bin, args, cwd, timeoutMs, input, executi
|
|
|
19
19
|
}
|
|
20
20
|
let stdout = "";
|
|
21
21
|
let stderr = "";
|
|
22
|
+
let stdoutRemainder = "";
|
|
23
|
+
let stdoutCallbackTail = Promise.resolve();
|
|
22
24
|
let timedOut = false;
|
|
23
25
|
const timer = setTimeout(() => {
|
|
24
26
|
timedOut = true;
|
|
25
27
|
terminateProcessTree(command);
|
|
26
28
|
}, timeoutMs);
|
|
27
29
|
command.stdout.on("data", (chunk) => {
|
|
28
|
-
|
|
30
|
+
const text = chunk.toString();
|
|
31
|
+
stdout += text;
|
|
32
|
+
if (!onStdoutLine) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
stdoutRemainder += text;
|
|
36
|
+
const lines = stdoutRemainder.split(/\r?\n/);
|
|
37
|
+
stdoutRemainder = lines.pop() ?? "";
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
stdoutCallbackTail = stdoutCallbackTail
|
|
40
|
+
.then(() => onStdoutLine(line))
|
|
41
|
+
.catch((error) => {
|
|
42
|
+
console.warn(`[provider-stream] stdout callback failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
29
45
|
});
|
|
30
46
|
command.stderr.on("data", (chunk) => {
|
|
31
47
|
stderr += chunk.toString();
|
|
@@ -41,11 +57,19 @@ export function spawnWithPlatformShell(bin, args, cwd, timeoutMs, input, executi
|
|
|
41
57
|
command.stdin.write(input);
|
|
42
58
|
}
|
|
43
59
|
command.stdin.end();
|
|
44
|
-
command.on("close", (code) => {
|
|
60
|
+
command.on("close", async (code) => {
|
|
45
61
|
clearTimeout(timer);
|
|
46
62
|
if (executionKey && activeCommands.get(executionKey) === command) {
|
|
47
63
|
activeCommands.delete(executionKey);
|
|
48
64
|
}
|
|
65
|
+
if (onStdoutLine && stdoutRemainder) {
|
|
66
|
+
stdoutCallbackTail = stdoutCallbackTail
|
|
67
|
+
.then(() => onStdoutLine(stdoutRemainder))
|
|
68
|
+
.catch((error) => {
|
|
69
|
+
console.warn(`[provider-stream] stdout callback failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
await stdoutCallbackTail;
|
|
49
73
|
resolve({ stdout, stderr, code, timedOut });
|
|
50
74
|
});
|
|
51
75
|
});
|
package/dist/bot.js
CHANGED
|
@@ -24,6 +24,7 @@ const HELP_TEXT = [
|
|
|
24
24
|
"/attach codex <thread_id>",
|
|
25
25
|
"/attach claude <session_id>",
|
|
26
26
|
"/model [name]",
|
|
27
|
+
"/queue [remove <id>|del]",
|
|
27
28
|
"/stop",
|
|
28
29
|
"/sandbox codex <read-only|workspace-write|danger-full-access>",
|
|
29
30
|
"/status",
|
|
@@ -80,6 +81,7 @@ const RECOGNIZED_COMMANDS = new Set([
|
|
|
80
81
|
"batch",
|
|
81
82
|
"attach",
|
|
82
83
|
"model",
|
|
84
|
+
"queue",
|
|
83
85
|
"stop",
|
|
84
86
|
"sandbox",
|
|
85
87
|
"status",
|
|
@@ -100,6 +102,8 @@ const TELEGRAM_STALE_UPDATE_GRACE_SECONDS = 10;
|
|
|
100
102
|
const TELEGRAM_PROCESS_STARTED_AT_SECONDS = Math.floor(Date.now() / 1000);
|
|
101
103
|
const workLoopTails = new Map();
|
|
102
104
|
const workLoopGenerations = new Map();
|
|
105
|
+
const queuedWorkLoops = new Map();
|
|
106
|
+
let nextQueuedWorkSequence = 1;
|
|
103
107
|
const REPORT_CONTINUE_PROMPT = [
|
|
104
108
|
"Continue the same task now.",
|
|
105
109
|
"Do more concrete work before replying again.",
|
|
@@ -225,6 +229,18 @@ export function createBot(token, bridge, botManagement, botInfo) {
|
|
|
225
229
|
throw error;
|
|
226
230
|
}
|
|
227
231
|
};
|
|
232
|
+
const removeQueuedInstruction = async (botId, chatId, selector) => {
|
|
233
|
+
const mapping = await bridge.status(botId, chatId);
|
|
234
|
+
const activeKey = workLoopKey(botId, chatId, mapping?.session.sessionId);
|
|
235
|
+
const removed = removeQueuedWorkLoop(activeKey, selector);
|
|
236
|
+
if (!removed) {
|
|
237
|
+
const target = selector ? normalizeQueueId(selector) : "the latest queued instruction";
|
|
238
|
+
return `Queued instruction was not found: ${target}`;
|
|
239
|
+
}
|
|
240
|
+
await bridge.logSystem(botId, chatId, `Removed queued instruction ${removed.id} for ${activeKey}.`);
|
|
241
|
+
return `Removed queued instruction ${removed.id} from ${removed.publicSessionId ?? "this session"}.\n`
|
|
242
|
+
+ `Remaining queued instructions: ${listQueuedWorkLoops(activeKey).length}`;
|
|
243
|
+
};
|
|
228
244
|
const runPlanDocumentReinforcement = async (ctx, count) => {
|
|
229
245
|
if (!ctx.chat) {
|
|
230
246
|
throw new Error("Telegram chat context is missing.");
|
|
@@ -453,17 +469,39 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
453
469
|
const mapping = await bridge.setModel(botId, chatId, model);
|
|
454
470
|
await reply(ctx, `Set ${mapping.session.mode} model to ${model}.\n\n${bridge.formatStatus(mapping)}`);
|
|
455
471
|
});
|
|
472
|
+
bot.command("queue", async (ctx) => {
|
|
473
|
+
const botId = getBotId();
|
|
474
|
+
const chatId = String(ctx.chat.id);
|
|
475
|
+
const mapping = await bridge.status(botId, chatId);
|
|
476
|
+
const activeKey = workLoopKey(botId, chatId, mapping?.session.sessionId);
|
|
477
|
+
const { args, rest } = parseCommand(ctx.message?.text, 2);
|
|
478
|
+
const action = args[0]?.toLowerCase();
|
|
479
|
+
const selector = args[1];
|
|
480
|
+
if (rest?.trim() || (action && action !== "list" && action !== "remove" && action !== "rm" && action !== "del")) {
|
|
481
|
+
await reply(ctx, "Usage: `/queue`, `/queue remove <id>`, or `/queue del`", { parse_mode: "Markdown" });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (!action || action === "list") {
|
|
485
|
+
await reply(ctx, formatQueuedWorkLoops(activeKey, mapping?.session.publicId));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if ((action === "remove" || action === "rm") && !selector) {
|
|
489
|
+
await reply(ctx, "Usage: `/queue remove <id>`", { parse_mode: "Markdown" });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
await reply(ctx, await removeQueuedInstruction(botId, chatId, selector));
|
|
493
|
+
});
|
|
456
494
|
bot.command("stop", async (ctx) => {
|
|
457
495
|
const botId = getBotId();
|
|
458
496
|
const chatId = String(ctx.chat.id);
|
|
459
497
|
const mapping = await bridge.status(botId, chatId);
|
|
460
498
|
const sessionId = mapping?.session.sessionId;
|
|
461
499
|
autoContinue.requestStop(botId, chatId, sessionId);
|
|
462
|
-
cancelQueuedWorkLoops(botId, chatId, sessionId);
|
|
500
|
+
const queuedWorkCount = cancelQueuedWorkLoops(botId, chatId, sessionId);
|
|
463
501
|
const pendingBatch = messageBatcher.cancelPending(botId, chatId);
|
|
464
502
|
const manualBatch = messageBatcher.cancelManual(botId, chatId);
|
|
465
503
|
if (!autoContinue.beginStop(botId, chatId, sessionId)) {
|
|
466
|
-
const batchCount = pendingBatch.count + manualBatch.count;
|
|
504
|
+
const batchCount = queuedWorkCount + pendingBatch.count + manualBatch.count;
|
|
467
505
|
if (batchCount > 0) {
|
|
468
506
|
await bridge.logSystem(botId, chatId, `Duplicate stop discarded ${batchCount} queued message(s).`);
|
|
469
507
|
}
|
|
@@ -472,7 +510,7 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
472
510
|
try {
|
|
473
511
|
const result = await bridge.stopActiveRun(botId, chatId);
|
|
474
512
|
await bridge.logSystem(botId, chatId, "Stop requested for auto-continue.");
|
|
475
|
-
const batchCount = pendingBatch.count + manualBatch.count;
|
|
513
|
+
const batchCount = queuedWorkCount + pendingBatch.count + manualBatch.count;
|
|
476
514
|
await reply(ctx, result.stopped
|
|
477
515
|
? `Stop requested. Active work for ${result.sessionPublicId ?? "this session"} was interrupted, further automatic continuation will stop, and ${batchCount} queued message(s) were discarded.`
|
|
478
516
|
: `Stop requested. No active provider process was running, but further automatic continuation will stop, and ${batchCount} queued message(s) were discarded.`);
|
|
@@ -866,6 +904,28 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
866
904
|
await bridge.reset(botId, chatId);
|
|
867
905
|
await reply(ctx, "Cleared all pairings for this chat.");
|
|
868
906
|
});
|
|
907
|
+
bot.on("callback_query:data", async (ctx) => {
|
|
908
|
+
const match = /^remoteagent:queue:(remove:(Q\d+)|del)$/i.exec(ctx.callbackQuery.data);
|
|
909
|
+
if (!match) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const callbackChat = ctx.callbackQuery.message?.chat;
|
|
913
|
+
if (!callbackChat) {
|
|
914
|
+
await callTelegramApi(token, "answerCallbackQuery", {
|
|
915
|
+
callback_query_id: ctx.callbackQuery.id,
|
|
916
|
+
text: "This queue action is no longer available.",
|
|
917
|
+
});
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const botId = getBotId();
|
|
921
|
+
const chatId = String(callbackChat.id);
|
|
922
|
+
const result = await removeQueuedInstruction(botId, chatId, match[2]);
|
|
923
|
+
await callTelegramApi(token, "answerCallbackQuery", {
|
|
924
|
+
callback_query_id: ctx.callbackQuery.id,
|
|
925
|
+
text: result.split("\n", 1)[0],
|
|
926
|
+
});
|
|
927
|
+
await sendTelegramMessage(token, callbackChat.id, result);
|
|
928
|
+
});
|
|
869
929
|
bot.on("message", async (ctx) => {
|
|
870
930
|
const botId = getBotId();
|
|
871
931
|
const chatId = String(ctx.chat.id);
|
|
@@ -1163,14 +1223,17 @@ async function runWithPendingAnimation(botToken, chatId, task) {
|
|
|
1163
1223
|
pulseTyping();
|
|
1164
1224
|
try {
|
|
1165
1225
|
const helpers = {
|
|
1166
|
-
reportProgress: async (chunks, parseMode) => {
|
|
1226
|
+
reportProgress: async (chunks, parseMode, messageOptions) => {
|
|
1167
1227
|
const normalized = await normalizeTelegramDelivery(chunks);
|
|
1168
1228
|
const progressChunks = flattenChunks(normalized.chunks, 3900);
|
|
1169
1229
|
if (progressChunks.length === 0 && normalized.documents.length === 0) {
|
|
1170
1230
|
return;
|
|
1171
1231
|
}
|
|
1172
1232
|
const rendered = formatProviderTelegramChunks(progressChunks, parseMode);
|
|
1173
|
-
const extra =
|
|
1233
|
+
const extra = {
|
|
1234
|
+
...messageOptions,
|
|
1235
|
+
...(rendered.parseMode ? { parse_mode: rendered.parseMode } : {}),
|
|
1236
|
+
};
|
|
1174
1237
|
for (const chunk of rendered.chunks) {
|
|
1175
1238
|
await sendTelegramMessage(botToken, chatId, chunk, extra).catch((error) => {
|
|
1176
1239
|
console.warn(`[telegram-progress-delivery] chat=${chatId} dropped progress message: ${formatTelegramDeliveryError(error)}`);
|
|
@@ -1228,12 +1291,50 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1228
1291
|
.catch(() => undefined)
|
|
1229
1292
|
.then(() => gate);
|
|
1230
1293
|
workLoopTails.set(activeKey, currentTail);
|
|
1294
|
+
let queuedEntry;
|
|
1231
1295
|
if (previousTail) {
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1296
|
+
queuedEntry = registerQueuedWorkLoop({
|
|
1297
|
+
activeKey,
|
|
1298
|
+
botId,
|
|
1299
|
+
chatId,
|
|
1300
|
+
sessionId,
|
|
1301
|
+
publicSessionId: currentSession?.session.publicId,
|
|
1302
|
+
message,
|
|
1303
|
+
release,
|
|
1304
|
+
});
|
|
1305
|
+
try {
|
|
1306
|
+
await bridge.logSystem(botId, chatId, `Queued overlapping Telegram work loop ${queuedEntry.id} for ${activeKey}.`);
|
|
1307
|
+
await helpers.reportProgress([[
|
|
1308
|
+
`Queued instruction ${queuedEntry.id} for ${currentSession?.session.publicId ?? "this session"}. It will run after the active work finishes.`,
|
|
1309
|
+
"",
|
|
1310
|
+
`Remove it with \`/queue remove ${queuedEntry.id}\`, or remove the latest queued instruction with \`/queue del\`.`,
|
|
1311
|
+
].join("\n")], undefined, {
|
|
1312
|
+
reply_markup: JSON.stringify({
|
|
1313
|
+
inline_keyboard: [[
|
|
1314
|
+
{
|
|
1315
|
+
text: `/queue remove ${queuedEntry.id}`,
|
|
1316
|
+
callback_data: `remoteagent:queue:remove:${queuedEntry.id}`,
|
|
1317
|
+
},
|
|
1318
|
+
{
|
|
1319
|
+
text: "/queue del",
|
|
1320
|
+
callback_data: "remoteagent:queue:del",
|
|
1321
|
+
},
|
|
1322
|
+
]],
|
|
1323
|
+
}),
|
|
1324
|
+
});
|
|
1325
|
+
await previousTail.catch(() => undefined);
|
|
1326
|
+
}
|
|
1327
|
+
catch (error) {
|
|
1328
|
+
queuedWorkLoops.delete(queuedEntry.id);
|
|
1329
|
+
release();
|
|
1330
|
+
if (workLoopTails.get(activeKey) === currentTail) {
|
|
1331
|
+
workLoopTails.delete(activeKey);
|
|
1332
|
+
}
|
|
1333
|
+
throw error;
|
|
1334
|
+
}
|
|
1335
|
+
queuedWorkLoops.delete(queuedEntry.id);
|
|
1336
|
+
if (queuedEntry.canceled || (workLoopGenerations.get(activeKey) ?? 0) !== generation || autoContinue.isStopRequested(botId, chatId, sessionId)) {
|
|
1337
|
+
await bridge.logSystem(botId, chatId, `Discarded queued Telegram work loop ${queuedEntry.id} for ${activeKey}.`);
|
|
1237
1338
|
release();
|
|
1238
1339
|
if (workLoopTails.get(activeKey) === currentTail) {
|
|
1239
1340
|
workLoopTails.delete(activeKey);
|
|
@@ -1270,6 +1371,7 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1270
1371
|
let missingEvidenceRetryCount = 0;
|
|
1271
1372
|
let deliveredProgressCount = 0;
|
|
1272
1373
|
let providerCompleted = false;
|
|
1374
|
+
const streamedProgressKeys = new Set();
|
|
1273
1375
|
const ensureStillBound = async (phase) => {
|
|
1274
1376
|
if (!sessionId) {
|
|
1275
1377
|
return;
|
|
@@ -1281,6 +1383,23 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1281
1383
|
await bridge.stopSessionRun(sessionId, botId, chatId, `Telegram work loop stopped during ${phase} because the chat is now bound to another session.`);
|
|
1282
1384
|
throw new SilentTelegramAbort(`Session ${currentSession?.session.publicId ?? sessionId} is no longer bound to this chat.`);
|
|
1283
1385
|
};
|
|
1386
|
+
const deliverStreamedProgress = async (response) => {
|
|
1387
|
+
const parsed = parseReportResponses(bridge.formatResponses([response]), transform);
|
|
1388
|
+
if (parsed.kind !== "progress" || parsed.chunks.length === 0) {
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const key = progressDeliveryKey(parsed.chunks);
|
|
1392
|
+
if (streamedProgressKeys.has(key)) {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
streamedProgressKeys.add(key);
|
|
1396
|
+
deliveredProgressCount += 1;
|
|
1397
|
+
await ensureStillBound(`${label} streamed progress delivery`);
|
|
1398
|
+
if (currentSession) {
|
|
1399
|
+
await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
|
|
1400
|
+
}
|
|
1401
|
+
await helpers.reportProgress(parsed.chunks);
|
|
1402
|
+
};
|
|
1284
1403
|
if (currentSession) {
|
|
1285
1404
|
await botManagement.markProviderRunning(botId, sessionId);
|
|
1286
1405
|
}
|
|
@@ -1303,8 +1422,8 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1303
1422
|
await bridge.logSystem(botId, chatId, `${turnLabel} started.`);
|
|
1304
1423
|
try {
|
|
1305
1424
|
const responses = sessionId
|
|
1306
|
-
? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt)
|
|
1307
|
-
: await bridge.routeMessage(botId, chatId, prompt);
|
|
1425
|
+
? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt, deliverStreamedProgress)
|
|
1426
|
+
: await bridge.routeMessage(botId, chatId, prompt, deliverStreamedProgress);
|
|
1308
1427
|
await ensureStillBound(`${turnLabel} response`);
|
|
1309
1428
|
const parsed = parseReportResponses(bridge.formatResponses(responses), transform);
|
|
1310
1429
|
await bridge.logSystem(botId, chatId, `${turnLabel} returned ${parsed.kind}.`);
|
|
@@ -1313,21 +1432,25 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1313
1432
|
if (parsed.kind === "progress") {
|
|
1314
1433
|
untaggedIntentRetryCount = 0;
|
|
1315
1434
|
missingEvidenceRetryCount = 0;
|
|
1316
|
-
|
|
1317
|
-
if (
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1435
|
+
const key = progressDeliveryKey(parsed.chunks);
|
|
1436
|
+
if (!streamedProgressKeys.has(key)) {
|
|
1437
|
+
streamedProgressKeys.add(key);
|
|
1438
|
+
deliveredProgressCount += 1;
|
|
1439
|
+
if (currentSession) {
|
|
1440
|
+
const progress = await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
|
|
1441
|
+
if (progress.repeated) {
|
|
1442
|
+
const repeatedMessage = [
|
|
1443
|
+
"Repeated progress detected. The same work pattern has appeared 3 or more times.",
|
|
1444
|
+
"Automatic continuation stopped so the task can be inspected instead of looping.",
|
|
1445
|
+
].join("\n");
|
|
1446
|
+
await bridge.logSystem(botId, chatId, repeatedMessage);
|
|
1447
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1448
|
+
return [repeatedMessage];
|
|
1449
|
+
}
|
|
1327
1450
|
}
|
|
1451
|
+
await ensureStillBound(`${turnLabel} progress delivery`);
|
|
1452
|
+
await helpers.reportProgress(parsed.chunks);
|
|
1328
1453
|
}
|
|
1329
|
-
await ensureStillBound(`${turnLabel} progress delivery`);
|
|
1330
|
-
await helpers.reportProgress(parsed.chunks);
|
|
1331
1454
|
if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
|
|
1332
1455
|
const stopMessage = "Automatic continuation stopped after the latest progress report.";
|
|
1333
1456
|
await bridge.logSystem(botId, chatId, stopMessage);
|
|
@@ -1460,9 +1583,82 @@ function cancelQueuedWorkLoops(botId, chatId, sessionId) {
|
|
|
1460
1583
|
if (sessionId) {
|
|
1461
1584
|
keys.push(workLoopKey(botId, chatId));
|
|
1462
1585
|
}
|
|
1586
|
+
const keySet = new Set(keys);
|
|
1587
|
+
let removed = 0;
|
|
1588
|
+
for (const entry of queuedWorkLoops.values()) {
|
|
1589
|
+
if (!keySet.has(entry.activeKey)) {
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
entry.canceled = true;
|
|
1593
|
+
entry.release();
|
|
1594
|
+
queuedWorkLoops.delete(entry.id);
|
|
1595
|
+
removed += 1;
|
|
1596
|
+
}
|
|
1463
1597
|
for (const key of keys) {
|
|
1464
1598
|
workLoopGenerations.set(key, (workLoopGenerations.get(key) ?? 0) + 1);
|
|
1465
1599
|
}
|
|
1600
|
+
return removed;
|
|
1601
|
+
}
|
|
1602
|
+
function registerQueuedWorkLoop(input) {
|
|
1603
|
+
const sequence = nextQueuedWorkSequence;
|
|
1604
|
+
nextQueuedWorkSequence += 1;
|
|
1605
|
+
const entry = {
|
|
1606
|
+
id: `Q${String(sequence).padStart(3, "0")}`,
|
|
1607
|
+
sequence,
|
|
1608
|
+
activeKey: input.activeKey,
|
|
1609
|
+
botId: input.botId,
|
|
1610
|
+
chatId: input.chatId,
|
|
1611
|
+
sessionId: input.sessionId,
|
|
1612
|
+
publicSessionId: input.publicSessionId,
|
|
1613
|
+
messagePreview: summarizeQueuedInstruction(input.message),
|
|
1614
|
+
createdAt: new Date().toISOString(),
|
|
1615
|
+
canceled: false,
|
|
1616
|
+
release: input.release,
|
|
1617
|
+
};
|
|
1618
|
+
queuedWorkLoops.set(entry.id, entry);
|
|
1619
|
+
return entry;
|
|
1620
|
+
}
|
|
1621
|
+
function listQueuedWorkLoops(activeKey) {
|
|
1622
|
+
return [...queuedWorkLoops.values()]
|
|
1623
|
+
.filter((entry) => entry.activeKey === activeKey && !entry.canceled)
|
|
1624
|
+
.sort((left, right) => left.sequence - right.sequence);
|
|
1625
|
+
}
|
|
1626
|
+
function removeQueuedWorkLoop(activeKey, selector) {
|
|
1627
|
+
const entries = listQueuedWorkLoops(activeKey);
|
|
1628
|
+
const target = selector
|
|
1629
|
+
? queuedWorkLoops.get(normalizeQueueId(selector))
|
|
1630
|
+
: entries.at(-1);
|
|
1631
|
+
if (!target || target.activeKey !== activeKey || target.canceled) {
|
|
1632
|
+
return undefined;
|
|
1633
|
+
}
|
|
1634
|
+
target.canceled = true;
|
|
1635
|
+
target.release();
|
|
1636
|
+
queuedWorkLoops.delete(target.id);
|
|
1637
|
+
return target;
|
|
1638
|
+
}
|
|
1639
|
+
function normalizeQueueId(value) {
|
|
1640
|
+
const normalized = value.trim().toUpperCase();
|
|
1641
|
+
if (/^\d+$/.test(normalized)) {
|
|
1642
|
+
return `Q${normalized.padStart(3, "0")}`;
|
|
1643
|
+
}
|
|
1644
|
+
return normalized;
|
|
1645
|
+
}
|
|
1646
|
+
function summarizeQueuedInstruction(message) {
|
|
1647
|
+
const normalized = message.replace(/\s+/g, " ").trim();
|
|
1648
|
+
return normalized.length > 100 ? `${normalized.slice(0, 97)}...` : normalized;
|
|
1649
|
+
}
|
|
1650
|
+
function formatQueuedWorkLoops(activeKey, publicSessionId) {
|
|
1651
|
+
const entries = listQueuedWorkLoops(activeKey);
|
|
1652
|
+
if (entries.length === 0) {
|
|
1653
|
+
return `No queued instructions for ${publicSessionId ?? "this chat"}.`;
|
|
1654
|
+
}
|
|
1655
|
+
return [
|
|
1656
|
+
`Queued instructions for ${publicSessionId ?? "this chat"} (${entries.length})`,
|
|
1657
|
+
...entries.map((entry) => `${entry.id}: ${entry.messagePreview || "(empty instruction)"}`),
|
|
1658
|
+
"",
|
|
1659
|
+
`Remove one: /queue remove ${entries[0].id}`,
|
|
1660
|
+
"Remove latest: /queue del",
|
|
1661
|
+
].join("\n");
|
|
1466
1662
|
}
|
|
1467
1663
|
class SilentTelegramAbort extends Error {
|
|
1468
1664
|
constructor(message) {
|
|
@@ -1509,6 +1705,9 @@ function parseReportResponses(formattedBlocks, transform) {
|
|
|
1509
1705
|
const chunks = transform(parsedBlocks.map((item) => item.text));
|
|
1510
1706
|
return { kind, chunks };
|
|
1511
1707
|
}
|
|
1708
|
+
function progressDeliveryKey(chunks) {
|
|
1709
|
+
return chunks.join("\n").replace(/\s+/g, " ").trim();
|
|
1710
|
+
}
|
|
1512
1711
|
function formatProviderTelegramChunks(chunks, explicitParseMode) {
|
|
1513
1712
|
if (explicitParseMode) {
|
|
1514
1713
|
return { chunks, parseMode: explicitParseMode };
|
|
@@ -2614,6 +2813,7 @@ async function sendTelegramMessage(botToken, chatId, text, extra) {
|
|
|
2614
2813
|
chat_id: String(chatId),
|
|
2615
2814
|
text,
|
|
2616
2815
|
parse_mode: extra?.parse_mode,
|
|
2816
|
+
reply_markup: extra?.reply_markup,
|
|
2617
2817
|
});
|
|
2618
2818
|
}
|
|
2619
2819
|
catch (error) {
|
|
@@ -2624,6 +2824,7 @@ async function sendTelegramMessage(botToken, chatId, text, extra) {
|
|
|
2624
2824
|
return await callTelegramApi(botToken, "sendMessage", {
|
|
2625
2825
|
chat_id: String(chatId),
|
|
2626
2826
|
text: stripTelegramHtml(text),
|
|
2827
|
+
reply_markup: extra?.reply_markup,
|
|
2627
2828
|
});
|
|
2628
2829
|
}
|
|
2629
2830
|
}
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { ProviderRecoveryService } from "./services/provider-recovery-service.js
|
|
|
20
20
|
import { computePolicyPollIntervalMs, computeRecentMessageRanks } from "./services/polling-policy.js";
|
|
21
21
|
import { terminateAllSpawnedExecutions } from "./adapters/windows-shell.js";
|
|
22
22
|
import { setTelegramCommandMenu } from "./telegram-command-menu.js";
|
|
23
|
+
import { buildBotInfoFromIdentity, buildFallbackBotInfo } from "./telegram-bot-identity.js";
|
|
23
24
|
const execFileAsync = promisify(execFile);
|
|
24
25
|
const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_SECONDS = 30;
|
|
25
26
|
const TELEGRAM_GET_UPDATES_CURL_TIMEOUT_SECONDS = 60;
|
|
@@ -68,7 +69,7 @@ async function main() {
|
|
|
68
69
|
console.error("Local UI failed to start:", error);
|
|
69
70
|
});
|
|
70
71
|
}
|
|
71
|
-
const botInfos = await Promise.all(config.telegramBotTokens.map((token, index) => resolveBotInfo(token, index)));
|
|
72
|
+
const botInfos = await Promise.all(config.telegramBotTokens.map((token, index) => resolveBotInfo(token, index, config.telegramBotUsernames[index])));
|
|
72
73
|
const bots = config.telegramBotTokens.map((token, index) => createBot(token, bridge, botManagement, botInfos[index]));
|
|
73
74
|
if (config.telegramCommandMenuEnabled) {
|
|
74
75
|
for (const bot of bots) {
|
|
@@ -484,7 +485,7 @@ class AsyncSemaphore {
|
|
|
484
485
|
}
|
|
485
486
|
}
|
|
486
487
|
}
|
|
487
|
-
async function resolveBotInfo(token, index) {
|
|
488
|
+
async function resolveBotInfo(token, index, configuredUsername) {
|
|
488
489
|
try {
|
|
489
490
|
const { stdout } = await execFileAsync("curl", [
|
|
490
491
|
"-sS",
|
|
@@ -502,32 +503,7 @@ async function resolveBotInfo(token, index) {
|
|
|
502
503
|
catch (error) {
|
|
503
504
|
console.warn(`Telegram getMe failed for bot ${tokenIdLabel(token)}: ${summarizeTelegramIdentityError(error)}`);
|
|
504
505
|
}
|
|
505
|
-
return buildFallbackBotInfo(token, index);
|
|
506
|
-
}
|
|
507
|
-
function buildBotInfoFromIdentity(id, username, firstName) {
|
|
508
|
-
return {
|
|
509
|
-
id,
|
|
510
|
-
is_bot: true,
|
|
511
|
-
first_name: firstName || username,
|
|
512
|
-
username,
|
|
513
|
-
can_join_groups: false,
|
|
514
|
-
can_read_all_group_messages: false,
|
|
515
|
-
supports_inline_queries: false,
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
function buildFallbackBotInfo(token, index) {
|
|
519
|
-
const id = Number.parseInt(token.split(":", 1)[0] ?? "", 10);
|
|
520
|
-
const fallbackUsername = knownBotUsername(id);
|
|
521
|
-
const username = fallbackUsername || `bot_${Number.isFinite(id) ? id : index + 1}`;
|
|
522
|
-
return {
|
|
523
|
-
id: Number.isFinite(id) ? id : index + 1,
|
|
524
|
-
is_bot: true,
|
|
525
|
-
first_name: username,
|
|
526
|
-
username,
|
|
527
|
-
can_join_groups: false,
|
|
528
|
-
can_read_all_group_messages: false,
|
|
529
|
-
supports_inline_queries: false,
|
|
530
|
-
};
|
|
506
|
+
return buildFallbackBotInfo(token, index, configuredUsername);
|
|
531
507
|
}
|
|
532
508
|
function tokenIdLabel(token) {
|
|
533
509
|
return token.split(":", 1)[0] || "unknown";
|
|
@@ -543,15 +519,6 @@ function summarizeTelegramIdentityError(error) {
|
|
|
543
519
|
.filter(Boolean)
|
|
544
520
|
.join(" ");
|
|
545
521
|
}
|
|
546
|
-
function knownBotUsername(id) {
|
|
547
|
-
if (id === 8369496408) {
|
|
548
|
-
return "codex_remoteagent_bot";
|
|
549
|
-
}
|
|
550
|
-
if (id === 8429712341) {
|
|
551
|
-
return "sqream_bot";
|
|
552
|
-
}
|
|
553
|
-
return undefined;
|
|
554
|
-
}
|
|
555
522
|
function commandExists(command) {
|
|
556
523
|
const trimmed = command.trim();
|
|
557
524
|
if (!trimmed) {
|
|
@@ -284,7 +284,7 @@ export class BridgeService {
|
|
|
284
284
|
}
|
|
285
285
|
return { stopped, sessionPublicId: session.publicId };
|
|
286
286
|
}
|
|
287
|
-
async routeMessage(botId, chatId, message) {
|
|
287
|
+
async routeMessage(botId, chatId, message, onProgress) {
|
|
288
288
|
const chatSession = await this.requireChat(botId, chatId);
|
|
289
289
|
await this.log({
|
|
290
290
|
timestamp: new Date().toISOString(),
|
|
@@ -295,7 +295,7 @@ export class BridgeService {
|
|
|
295
295
|
direction: "in",
|
|
296
296
|
text: message,
|
|
297
297
|
});
|
|
298
|
-
return this.withSessionLock(chatSession.session.sessionId, () => this.routeSession(chatSession.session, message, "telegram", botId, chatId));
|
|
298
|
+
return this.withSessionLock(chatSession.session.sessionId, () => this.routeSession(chatSession.session, message, "telegram", botId, chatId, onProgress));
|
|
299
299
|
}
|
|
300
300
|
async routeSessionMessage(sessionId, message) {
|
|
301
301
|
const session = await this.store.getSession(sessionId);
|
|
@@ -311,7 +311,7 @@ export class BridgeService {
|
|
|
311
311
|
});
|
|
312
312
|
return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "pc-ui"));
|
|
313
313
|
}
|
|
314
|
-
async routeSessionMessageForChat(sessionId, botId, chatId, message) {
|
|
314
|
+
async routeSessionMessageForChat(sessionId, botId, chatId, message, onProgress) {
|
|
315
315
|
const session = await this.store.getSession(sessionId);
|
|
316
316
|
if (!session) {
|
|
317
317
|
throw new Error(`Session was not found: ${sessionId}`);
|
|
@@ -325,7 +325,7 @@ export class BridgeService {
|
|
|
325
325
|
direction: "in",
|
|
326
326
|
text: message,
|
|
327
327
|
});
|
|
328
|
-
return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "telegram", botId, chatId));
|
|
328
|
+
return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "telegram", botId, chatId, onProgress));
|
|
329
329
|
}
|
|
330
330
|
formatStatus(chatSession) {
|
|
331
331
|
if (!chatSession) {
|
|
@@ -475,7 +475,7 @@ export class BridgeService {
|
|
|
475
475
|
}
|
|
476
476
|
}
|
|
477
477
|
}
|
|
478
|
-
async routeSession(session, message, requestSource, botId, chatId) {
|
|
478
|
+
async routeSession(session, message, requestSource, botId, chatId, onProgress) {
|
|
479
479
|
const responses = [];
|
|
480
480
|
const providers = this.resolveProviders(session.mode);
|
|
481
481
|
for (const provider of providers) {
|
|
@@ -491,6 +491,29 @@ export class BridgeService {
|
|
|
491
491
|
message,
|
|
492
492
|
model: providerSession.model,
|
|
493
493
|
sandboxMode: providerSession.sandboxMode,
|
|
494
|
+
onProgress: onProgress
|
|
495
|
+
? async (output) => {
|
|
496
|
+
const progressResponse = {
|
|
497
|
+
provider,
|
|
498
|
+
sessionId: providerSession.sessionId ?? session.sessionId,
|
|
499
|
+
publicSessionId: session.publicId,
|
|
500
|
+
model: providerSession.model ?? this.defaultModelFor(provider),
|
|
501
|
+
cwd: providerSession.cwd,
|
|
502
|
+
output,
|
|
503
|
+
};
|
|
504
|
+
await this.log({
|
|
505
|
+
timestamp: new Date().toISOString(),
|
|
506
|
+
remoteSessionId: session.sessionId,
|
|
507
|
+
botId,
|
|
508
|
+
chatId,
|
|
509
|
+
provider,
|
|
510
|
+
direction: "out",
|
|
511
|
+
sessionId: providerSession.sessionId,
|
|
512
|
+
text: output,
|
|
513
|
+
});
|
|
514
|
+
await onProgress(progressResponse);
|
|
515
|
+
}
|
|
516
|
+
: undefined,
|
|
494
517
|
});
|
|
495
518
|
const updatedProviderSession = {
|
|
496
519
|
...providerSession,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function buildBotInfoFromIdentity(id, username, firstName) {
|
|
2
|
+
return {
|
|
3
|
+
id,
|
|
4
|
+
is_bot: true,
|
|
5
|
+
first_name: firstName || username,
|
|
6
|
+
username,
|
|
7
|
+
can_join_groups: false,
|
|
8
|
+
can_read_all_group_messages: false,
|
|
9
|
+
supports_inline_queries: false,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function buildFallbackBotInfo(token, index, configuredUsername) {
|
|
13
|
+
const id = Number.parseInt(token.split(":", 1)[0] ?? "", 10);
|
|
14
|
+
const persistedUsername = configuredUsername?.trim().replace(/^@/, "");
|
|
15
|
+
const username = persistedUsername
|
|
16
|
+
|| knownBotUsername(id)
|
|
17
|
+
|| `bot_${Number.isFinite(id) ? id : index + 1}`;
|
|
18
|
+
return {
|
|
19
|
+
id: Number.isFinite(id) ? id : index + 1,
|
|
20
|
+
is_bot: true,
|
|
21
|
+
first_name: username,
|
|
22
|
+
username,
|
|
23
|
+
can_join_groups: false,
|
|
24
|
+
can_read_all_group_messages: false,
|
|
25
|
+
supports_inline_queries: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function knownBotUsername(id) {
|
|
29
|
+
if (id === 8369496408) {
|
|
30
|
+
return "codex_remoteagent_bot";
|
|
31
|
+
}
|
|
32
|
+
if (id === 8429712341) {
|
|
33
|
+
return "sqream_bot";
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
@@ -15,6 +15,7 @@ export const TELEGRAM_COMMAND_MENU = [
|
|
|
15
15
|
{ command: "docs", description: "Pin or find session documents" },
|
|
16
16
|
{ command: "macro", description: "Save or run reusable instructions" },
|
|
17
17
|
{ command: "model", description: "Show or change provider model" },
|
|
18
|
+
{ command: "queue", description: "List or remove queued instructions" },
|
|
18
19
|
{ command: "stop", description: "Stop active work and clear queued messages" },
|
|
19
20
|
{ command: "sandbox", description: "Set Codex sandbox mode" },
|
|
20
21
|
{ command: "batch", description: "Collect and send a multi-message batch" },
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -44,6 +44,10 @@ Current production bot ownership is intentionally split:
|
|
|
44
44
|
Assign each Telegram bot token to one runtime at a time.
|
|
45
45
|
Bot polling conflicts are treated as incidents, not harmless warnings.
|
|
46
46
|
|
|
47
|
+
`TELEGRAM_BOT_TOKENS` and `TELEGRAM_BOT_USERNAMES` are parallel persisted lists.
|
|
48
|
+
At startup, Telegram `getMe` is the preferred identity source, but a temporary DNS or Telegram failure must fall back to the persisted username at the same list position.
|
|
49
|
+
Using a generated `bot_<numeric-id>` identity when a persisted username exists breaks the existing `<username>:<chat-id>` session binding.
|
|
50
|
+
|
|
47
51
|
When a runtime has several configured Telegram bots, polling pressure can become operationally visible.
|
|
48
52
|
RemoteAgent reduces that pressure with rank-based polling intervals instead of deep sleep or a special main bot.
|
|
49
53
|
See [BOT_POLLING_POLICY.md](./BOT_POLLING_POLICY.md).
|
package/docs/RELEASING.md
CHANGED
|
@@ -174,3 +174,49 @@ Runtime targets:
|
|
|
174
174
|
server 30: 0.15.5 active
|
|
175
175
|
server 26: 0.15.5 running
|
|
176
176
|
```
|
|
177
|
+
|
|
178
|
+
## Release 0.17.0
|
|
179
|
+
|
|
180
|
+
Date: 2026-07-29
|
|
181
|
+
|
|
182
|
+
Changes:
|
|
183
|
+
|
|
184
|
+
- Queued instructions receive runtime-unique `Q001`-style ids.
|
|
185
|
+
- `/queue` lists instructions waiting behind the current session work.
|
|
186
|
+
- `/queue remove <id>` removes one selected waiting instruction.
|
|
187
|
+
- `/queue del` removes the most recently queued instruction.
|
|
188
|
+
- `/stop` reports and clears queued work-loop instructions as well as pending message batches.
|
|
189
|
+
- Telegram startup preserves the configured bot username when `getMe` temporarily fails, preventing existing chat/session bindings from being bypassed by a generated numeric bot identity.
|
|
190
|
+
|
|
191
|
+
Validated:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
npm run check
|
|
195
|
+
npm run build
|
|
196
|
+
npm run selftest:telegram
|
|
197
|
+
npm run release:publish
|
|
198
|
+
npm run release:deploy -- 0.17.0 all
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Release 0.17.1
|
|
202
|
+
|
|
203
|
+
Date: 2026-08-03
|
|
204
|
+
|
|
205
|
+
Changes:
|
|
206
|
+
|
|
207
|
+
- Codex JSON stdout is parsed while the provider process is running.
|
|
208
|
+
- `REPORT:progress` messages are delivered to Telegram immediately without starting another provider turn.
|
|
209
|
+
- A streamed final progress message is deduplicated from the normal post-process response path.
|
|
210
|
+
- Queue instructions are announced in one Telegram message instead of two.
|
|
211
|
+
- Queue notices include inline `/queue remove Qxxx` and `/queue del` buttons that execute the existing queue removal behavior.
|
|
212
|
+
|
|
213
|
+
Validated:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
npm run check
|
|
217
|
+
npm run build
|
|
218
|
+
npm run selftest:codex-stream
|
|
219
|
+
npm run selftest:telegram
|
|
220
|
+
npm run release:publish
|
|
221
|
+
npm run release:deploy -- 0.17.1 30
|
|
222
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "appback-remoteagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Personal installable session server for continuing local AI work across PC and Telegram",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"start": "node dist/index.js",
|
|
20
20
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
21
21
|
"selftest:telegram": "npm run build && node scripts/selftest-telegram-update.mjs",
|
|
22
|
+
"selftest:codex-stream": "npm run build && node scripts/selftest-codex-stream.mjs",
|
|
22
23
|
"prepare": "npm run build",
|
|
23
24
|
"prepublishOnly": "node scripts/prepublish-guard.mjs",
|
|
24
25
|
"release:version": "bash scripts/release-version.sh",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const root = path.resolve(new URL("..", import.meta.url).pathname);
|
|
7
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "remoteagent-codex-stream-"));
|
|
8
|
+
const fakeCodex = path.join(tmp, "codex");
|
|
9
|
+
|
|
10
|
+
await fs.writeFile(fakeCodex, `#!/usr/bin/env bash
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
output=""
|
|
13
|
+
while [ "$#" -gt 0 ]; do
|
|
14
|
+
if [ "$1" = "-o" ]; then
|
|
15
|
+
output="$2"
|
|
16
|
+
shift 2
|
|
17
|
+
continue
|
|
18
|
+
fi
|
|
19
|
+
shift
|
|
20
|
+
done
|
|
21
|
+
cat >/dev/null
|
|
22
|
+
printf '%s\\n' '{"type":"thread.started","thread_id":"stream-thread"}'
|
|
23
|
+
printf '%s' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:progress\\nphase one"}}'
|
|
24
|
+
printf '\\n'
|
|
25
|
+
sleep 0.05
|
|
26
|
+
printf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:progress\\nphase two"}}'
|
|
27
|
+
sleep 0.05
|
|
28
|
+
printf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:result\\nfinished"}}'
|
|
29
|
+
printf '%s\\n' 'REPORT:result' 'finished' > "$output"
|
|
30
|
+
`, "utf8");
|
|
31
|
+
await fs.chmod(fakeCodex, 0o755);
|
|
32
|
+
|
|
33
|
+
const { CodexAdapter } = await import(path.join(root, "dist", "adapters", "codex-adapter.js"));
|
|
34
|
+
const adapter = new CodexAdapter(fakeCodex, 5000, "read-only");
|
|
35
|
+
const progress = [];
|
|
36
|
+
let settled = false;
|
|
37
|
+
const responsePromise = adapter.send({
|
|
38
|
+
chatId: "selftest",
|
|
39
|
+
remoteSessionId: "remote-session",
|
|
40
|
+
publicSessionId: "S001",
|
|
41
|
+
message: "test",
|
|
42
|
+
cwd: tmp,
|
|
43
|
+
onProgress: async (output) => {
|
|
44
|
+
if (settled) {
|
|
45
|
+
throw new Error("Progress arrived after the provider response settled");
|
|
46
|
+
}
|
|
47
|
+
progress.push(output);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const response = await responsePromise;
|
|
51
|
+
settled = true;
|
|
52
|
+
|
|
53
|
+
if (progress.length !== 2 || !progress[0]?.includes("phase one") || !progress[1]?.includes("phase two")) {
|
|
54
|
+
throw new Error(`Unexpected streamed progress: ${JSON.stringify(progress)}`);
|
|
55
|
+
}
|
|
56
|
+
if (progress.some((item) => item.includes("REPORT:result"))) {
|
|
57
|
+
throw new Error(`Final result leaked through progress callback: ${JSON.stringify(progress)}`);
|
|
58
|
+
}
|
|
59
|
+
if (response.sessionId !== "stream-thread" || response.output !== "REPORT:result\nfinished") {
|
|
60
|
+
throw new Error(`Unexpected final response: ${JSON.stringify(response)}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(JSON.stringify({ ok: true, streamedProgress: progress.length, final: response.output }, null, 2));
|
|
@@ -21,23 +21,27 @@ set -euo pipefail
|
|
|
21
21
|
method="unknown"
|
|
22
22
|
text=""
|
|
23
23
|
chat_id=""
|
|
24
|
+
reply_markup=""
|
|
24
25
|
for arg in "$@"; do
|
|
25
26
|
case "$arg" in
|
|
26
27
|
https://api.telegram.org/bot*/sendMessage) method="sendMessage" ;;
|
|
27
28
|
https://api.telegram.org/bot*/editMessageText) method="editMessageText" ;;
|
|
28
29
|
https://api.telegram.org/bot*/deleteMessage) method="deleteMessage" ;;
|
|
29
30
|
https://api.telegram.org/bot*/sendDocument) method="sendDocument" ;;
|
|
31
|
+
https://api.telegram.org/bot*/answerCallbackQuery) method="answerCallbackQuery" ;;
|
|
30
32
|
chat_id=*) chat_id="\${arg#chat_id=}" ;;
|
|
31
33
|
text=*) text="\${arg#text=}" ;;
|
|
34
|
+
reply_markup=*) reply_markup="\${arg#reply_markup=}" ;;
|
|
32
35
|
esac
|
|
33
36
|
done
|
|
34
37
|
text_b64="$(printf '%s' "$text" | base64 -w 0)"
|
|
35
|
-
printf '%s
|
|
38
|
+
reply_markup_b64="$(printf '%s' "$reply_markup" | base64 -w 0)"
|
|
39
|
+
printf '%s\\t%s\\t%s\\t%s\\n' "$method" "$chat_id" "$text_b64" "$reply_markup_b64" >> ${JSON.stringify(telegramCalls)}
|
|
36
40
|
case "$method" in
|
|
37
41
|
sendMessage|editMessageText)
|
|
38
42
|
printf '{"ok":true,"result":{"message_id":1001}}'
|
|
39
43
|
;;
|
|
40
|
-
deleteMessage)
|
|
44
|
+
deleteMessage|answerCallbackQuery)
|
|
41
45
|
printf '{"ok":true,"result":true}'
|
|
42
46
|
;;
|
|
43
47
|
sendDocument)
|
|
@@ -69,6 +73,7 @@ const [
|
|
|
69
73
|
{ FileStore },
|
|
70
74
|
{ AgentMemoryService },
|
|
71
75
|
{ WorkspaceCleanupService },
|
|
76
|
+
{ buildFallbackBotInfo },
|
|
72
77
|
] = await Promise.all([
|
|
73
78
|
import(path.join(root, "dist", "bot.js")),
|
|
74
79
|
import(path.join(root, "dist", "services", "bridge-service.js")),
|
|
@@ -76,12 +81,27 @@ const [
|
|
|
76
81
|
import(path.join(root, "dist", "store", "file-store.js")),
|
|
77
82
|
import(path.join(root, "dist", "services", "agent-memory-service.js")),
|
|
78
83
|
import(path.join(root, "dist", "services", "workspace-cleanup-service.js")),
|
|
84
|
+
import(path.join(root, "dist", "telegram-bot-identity.js")),
|
|
79
85
|
]);
|
|
80
86
|
|
|
87
|
+
const persistedBotIdentity = buildFallbackBotInfo(
|
|
88
|
+
"8966593034:test-token",
|
|
89
|
+
0,
|
|
90
|
+
"@appbackadmin_bot",
|
|
91
|
+
);
|
|
92
|
+
if (persistedBotIdentity.username !== "appbackadmin_bot") {
|
|
93
|
+
throw new Error(`Configured bot username was not preserved during getMe fallback: ${persistedBotIdentity.username}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
const providerCalls = [];
|
|
82
97
|
let providerMode = "success";
|
|
83
98
|
let untaggedIntentCalls = 0;
|
|
84
99
|
let missingEvidenceCalls = 0;
|
|
100
|
+
let streamingFinalProgressCalls = 0;
|
|
101
|
+
let queueHoldStartedResolve;
|
|
102
|
+
let queueHoldReleaseResolve;
|
|
103
|
+
let queueHoldStartedPromise = Promise.resolve();
|
|
104
|
+
let queueHoldReleasePromise = Promise.resolve();
|
|
85
105
|
const provider = {
|
|
86
106
|
async send(request) {
|
|
87
107
|
providerCalls.push(request);
|
|
@@ -112,6 +132,49 @@ const provider = {
|
|
|
112
132
|
: "REPORT:result\n수정 완료했습니다.\n\n근거:\n- 변경 파일: `src/example.ts`\n- 검증: `npm run check` 통과",
|
|
113
133
|
};
|
|
114
134
|
}
|
|
135
|
+
if (providerMode === "queue-hold") {
|
|
136
|
+
queueHoldStartedResolve?.();
|
|
137
|
+
await queueHoldReleasePromise;
|
|
138
|
+
return {
|
|
139
|
+
provider: "codex",
|
|
140
|
+
sessionId: request.sessionId || "mock-thread",
|
|
141
|
+
publicSessionId: request.publicSessionId,
|
|
142
|
+
cwd: request.cwd,
|
|
143
|
+
output: "REPORT:result\nactive queue test completed",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (providerMode === "streaming-progress") {
|
|
147
|
+
await request.onProgress?.("REPORT:progress\nstreamed phase one completed");
|
|
148
|
+
await request.onProgress?.("REPORT:progress\nstreamed phase two completed");
|
|
149
|
+
return {
|
|
150
|
+
provider: "codex",
|
|
151
|
+
sessionId: request.sessionId || "mock-thread",
|
|
152
|
+
publicSessionId: request.publicSessionId,
|
|
153
|
+
cwd: request.cwd,
|
|
154
|
+
output: "REPORT:result\nstreamed provider completed with evidence: `stream-test.log`",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (providerMode === "streaming-final-progress") {
|
|
158
|
+
streamingFinalProgressCalls += 1;
|
|
159
|
+
if (streamingFinalProgressCalls === 1) {
|
|
160
|
+
const output = "REPORT:progress\nstreamed final progress completed";
|
|
161
|
+
await request.onProgress?.(output);
|
|
162
|
+
return {
|
|
163
|
+
provider: "codex",
|
|
164
|
+
sessionId: request.sessionId || "mock-thread",
|
|
165
|
+
publicSessionId: request.publicSessionId,
|
|
166
|
+
cwd: request.cwd,
|
|
167
|
+
output,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
provider: "codex",
|
|
172
|
+
sessionId: request.sessionId || "mock-thread",
|
|
173
|
+
publicSessionId: request.publicSessionId,
|
|
174
|
+
cwd: request.cwd,
|
|
175
|
+
output: "REPORT:result\nstreamed continuation completed with evidence: `stream-final.log`",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
115
178
|
return {
|
|
116
179
|
provider: "codex",
|
|
117
180
|
sessionId: request.sessionId || "mock-thread",
|
|
@@ -165,10 +228,61 @@ function update(text) {
|
|
|
165
228
|
};
|
|
166
229
|
}
|
|
167
230
|
|
|
231
|
+
function callbackUpdate(data, sourceMessageId = messageId++) {
|
|
232
|
+
return {
|
|
233
|
+
update_id: updateId++,
|
|
234
|
+
callback_query: {
|
|
235
|
+
id: `callback-${updateId}`,
|
|
236
|
+
from: { id: 111, is_bot: false, first_name: "Tester", username: "tester" },
|
|
237
|
+
message: {
|
|
238
|
+
message_id: sourceMessageId,
|
|
239
|
+
date: now(),
|
|
240
|
+
chat: { id: 111222333, type: "private", first_name: "Tester", username: "tester" },
|
|
241
|
+
text: "queue controls",
|
|
242
|
+
},
|
|
243
|
+
chat_instance: "selftest",
|
|
244
|
+
data,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
168
249
|
async function send(text) {
|
|
169
250
|
await injectedBot.handleUpdates([update(text)]);
|
|
170
251
|
}
|
|
171
252
|
|
|
253
|
+
async function click(data) {
|
|
254
|
+
await injectedBot.handleUpdates([callbackUpdate(data)]);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function readTelegramCalls() {
|
|
258
|
+
return (await fs.readFile(telegramCalls, "utf8"))
|
|
259
|
+
.trim()
|
|
260
|
+
.split("\n")
|
|
261
|
+
.filter(Boolean)
|
|
262
|
+
.map((line) => {
|
|
263
|
+
const [method, chatId, textB64 = "", replyMarkupB64 = ""] = line.split("\t");
|
|
264
|
+
return {
|
|
265
|
+
method,
|
|
266
|
+
chat_id: chatId,
|
|
267
|
+
text: Buffer.from(textB64, "base64").toString("utf8"),
|
|
268
|
+
reply_markup: Buffer.from(replyMarkupB64, "base64").toString("utf8"),
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function waitForTelegramCall(predicate, timeoutMs = 3000) {
|
|
274
|
+
const deadline = Date.now() + timeoutMs;
|
|
275
|
+
while (Date.now() < deadline) {
|
|
276
|
+
const calls = await readTelegramCalls();
|
|
277
|
+
const match = [...calls].reverse().find(predicate);
|
|
278
|
+
if (match) {
|
|
279
|
+
return match;
|
|
280
|
+
}
|
|
281
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
282
|
+
}
|
|
283
|
+
throw new Error("Timed out waiting for Telegram self-test call.");
|
|
284
|
+
}
|
|
285
|
+
|
|
172
286
|
await send("/start codex");
|
|
173
287
|
await send("/option retry 6");
|
|
174
288
|
await send("/option timeout 600");
|
|
@@ -407,6 +521,100 @@ if (evidenceCalls.some((call) => call.method === "sendMessage" && /^수정 완
|
|
|
407
521
|
throw new Error(`Evidence-free completion leaked as final Telegram message. Calls: ${JSON.stringify(evidenceCalls, null, 2)}`);
|
|
408
522
|
}
|
|
409
523
|
|
|
524
|
+
providerMode = "streaming-progress";
|
|
525
|
+
await send("/batch start");
|
|
526
|
+
await send("streaming progress regression test");
|
|
527
|
+
await send("/batch send");
|
|
528
|
+
|
|
529
|
+
const streamingCalls = await readTelegramCalls();
|
|
530
|
+
for (const phase of ["streamed phase one completed", "streamed phase two completed"]) {
|
|
531
|
+
const matches = streamingCalls.filter((call) => call.method === "sendMessage" && call.text.includes(phase));
|
|
532
|
+
if (matches.length !== 1) {
|
|
533
|
+
throw new Error(`Expected exactly one streamed Telegram progress message for ${phase}, got ${matches.length}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (!streamingCalls.some((call) => call.method === "sendMessage" && call.text.includes("streamed provider completed"))) {
|
|
537
|
+
throw new Error("Streaming provider final result was not delivered");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
providerMode = "streaming-final-progress";
|
|
541
|
+
await send("/batch start");
|
|
542
|
+
await send("streaming final progress deduplication test");
|
|
543
|
+
await send("/batch send");
|
|
544
|
+
const streamingFinalCalls = await readTelegramCalls();
|
|
545
|
+
const streamedFinalProgressMessages = streamingFinalCalls.filter((call) =>
|
|
546
|
+
call.method === "sendMessage" && call.text.includes("streamed final progress completed")
|
|
547
|
+
);
|
|
548
|
+
if (streamedFinalProgressMessages.length !== 1 || streamingFinalProgressCalls !== 2) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
`Final streamed progress was not deduplicated: messages=${streamedFinalProgressMessages.length} providerCalls=${streamingFinalProgressCalls}`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const queueProviderCallsBefore = providerCalls.length;
|
|
555
|
+
providerMode = "queue-hold";
|
|
556
|
+
queueHoldStartedPromise = new Promise((resolve) => {
|
|
557
|
+
queueHoldStartedResolve = resolve;
|
|
558
|
+
});
|
|
559
|
+
queueHoldReleasePromise = new Promise((resolve) => {
|
|
560
|
+
queueHoldReleaseResolve = resolve;
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
await send("/batch start");
|
|
564
|
+
await send("active queue regression test");
|
|
565
|
+
const activeQueueSend = send("/batch send");
|
|
566
|
+
await queueHoldStartedPromise;
|
|
567
|
+
|
|
568
|
+
await send("/batch start");
|
|
569
|
+
await send("first queued instruction");
|
|
570
|
+
const firstQueuedSend = send("/batch send");
|
|
571
|
+
const firstQueueNotice = await waitForTelegramCall((call) => /Queued instruction Q\d+/.test(call.text));
|
|
572
|
+
const firstQueueId = firstQueueNotice.text.match(/Queued instruction (Q\d+)/)?.[1];
|
|
573
|
+
if (!firstQueueId) {
|
|
574
|
+
throw new Error(`First queued instruction did not receive an id: ${firstQueueNotice.text}`);
|
|
575
|
+
}
|
|
576
|
+
const firstQueueNotices = (await readTelegramCalls()).filter((call) =>
|
|
577
|
+
call.method === "sendMessage" && call.text.includes(`Queued instruction ${firstQueueId} for`)
|
|
578
|
+
);
|
|
579
|
+
if (firstQueueNotices.length !== 1) {
|
|
580
|
+
throw new Error(`Queue notice should be one Telegram message, got ${firstQueueNotices.length}`);
|
|
581
|
+
}
|
|
582
|
+
if (!firstQueueNotice.reply_markup.includes(`remoteagent:queue:remove:${firstQueueId}`)
|
|
583
|
+
|| !firstQueueNotice.reply_markup.includes("remoteagent:queue:del")) {
|
|
584
|
+
throw new Error(`Queue notice buttons are missing: ${firstQueueNotice.reply_markup}`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
await send("/batch start");
|
|
588
|
+
await send("second queued instruction");
|
|
589
|
+
const secondQueuedSend = send("/batch send");
|
|
590
|
+
const secondQueueNotice = await waitForTelegramCall((call) => {
|
|
591
|
+
const queueId = call.text.match(/Queued instruction (Q\d+)/)?.[1];
|
|
592
|
+
return Boolean(queueId && queueId !== firstQueueId);
|
|
593
|
+
});
|
|
594
|
+
const secondQueueId = secondQueueNotice.text.match(/Queued instruction (Q\d+)/)?.[1];
|
|
595
|
+
if (!secondQueueId) {
|
|
596
|
+
throw new Error(`Second queued instruction did not receive an id: ${secondQueueNotice.text}`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
await send("/queue");
|
|
600
|
+
const queueListCall = await waitForTelegramCall((call) =>
|
|
601
|
+
call.text.includes(firstQueueId) && call.text.includes(secondQueueId)
|
|
602
|
+
);
|
|
603
|
+
if (!/Queued instructions for S001 \(2\)/.test(queueListCall.text)) {
|
|
604
|
+
throw new Error(`Queue list did not report both entries: ${queueListCall.text}`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
await click(`remoteagent:queue:remove:${firstQueueId}`);
|
|
608
|
+
await waitForTelegramCall((call) => call.text.includes(`Removed queued instruction ${firstQueueId}`));
|
|
609
|
+
await click("remoteagent:queue:del");
|
|
610
|
+
await waitForTelegramCall((call) => call.text.includes(`Removed queued instruction ${secondQueueId}`));
|
|
611
|
+
|
|
612
|
+
queueHoldReleaseResolve?.();
|
|
613
|
+
await Promise.all([activeQueueSend, firstQueuedSend, secondQueuedSend]);
|
|
614
|
+
if (providerCalls.length !== queueProviderCallsBefore + 1) {
|
|
615
|
+
throw new Error(`Removed queued instructions reached the provider: ${providerCalls.length - queueProviderCallsBefore} calls`);
|
|
616
|
+
}
|
|
617
|
+
|
|
410
618
|
console.log(JSON.stringify({
|
|
411
619
|
ok: true,
|
|
412
620
|
dataDir,
|
|
@@ -419,6 +627,10 @@ console.log(JSON.stringify({
|
|
|
419
627
|
providerCalls: providerCalls.length,
|
|
420
628
|
untaggedIntentCalls,
|
|
421
629
|
missingEvidenceCalls,
|
|
630
|
+
streamingProgress: true,
|
|
631
|
+
streamingFinalProgressDeduplicated: true,
|
|
632
|
+
queueRemoveById: firstQueueId,
|
|
633
|
+
queueRemoveLatest: secondQueueId,
|
|
422
634
|
timeoutFinalMessage: true,
|
|
423
635
|
telegramSendMessages: evidenceCalls.filter((call) => call.method === "sendMessage").length,
|
|
424
636
|
}, null, 2));
|