appback-remoteagent 0.15.7 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -18,6 +18,8 @@ TELEGRAM_RECOVERY_CHECK_INTERVAL_MS=60000
18
18
  ARTIFACT_CLEANUP_ENABLED=true
19
19
  ARTIFACT_RETENTION_DAYS=30
20
20
  ARTIFACT_CLEANUP_INTERVAL_MS=86400000
21
+ WORKSPACE_CLEANUP_ENABLED=true
22
+ WORKSPACE_CLEANUP_INTERVAL_MS=86400000
21
23
  REMOTEAGENT_WATCHDOG_ENABLED=true
22
24
  REMOTEAGENT_WATCHDOG_INTERVAL_MS=300000
23
25
  REMOTEAGENT_WATCHDOG_CPU_PERCENT=90
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
 
@@ -182,6 +185,8 @@ Telegram attachments are written into the local runtime under `~/.remoteagent/up
182
185
 
183
186
  Attachments can be inspected with `/artifacts list` and cleaned manually with `/artifacts cleanup <days>`. RemoteAgent also runs periodic artifact cleanup when `ARTIFACT_CLEANUP_ENABLED=true`; by default it keeps 30 days and also removes old unindexed files under `uploads/telegram`.
184
187
 
188
+ Managed workspaces are cleaned separately. RemoteAgent periodically removes only orphan workspace directories that are no longer referenced by `state.json` when `WORKSPACE_CLEANUP_ENABLED=true`. Use `/cleanup` to clear the current session workspace; RemoteAgent keeps the session todo/state/history under `~/.remoteagent/managed/sessions/<session>` and refuses non-RemoteAgent-managed workspaces.
189
+
185
190
  Current supported attachment classes:
186
191
 
187
192
  - photos
package/dist/bot.js CHANGED
@@ -9,6 +9,7 @@ import { config } from "./config.js";
9
9
  import { ProviderSetupService } from "./services/provider-setup-service.js";
10
10
  import { RemoteShellService } from "./services/remote-shell-service.js";
11
11
  import { AgentMemoryService } from "./services/agent-memory-service.js";
12
+ import { WorkspaceCleanupService } from "./services/workspace-cleanup-service.js";
12
13
  import { deleteTelegramCommandMenu, setTelegramCommandMenu } from "./telegram-command-menu.js";
13
14
  const execFileAsync = promisify(execFile);
14
15
  const HELP_TEXT = [
@@ -23,14 +24,18 @@ const HELP_TEXT = [
23
24
  "/attach codex <thread_id>",
24
25
  "/attach claude <session_id>",
25
26
  "/model [name]",
27
+ "/queue [remove <id>|del]",
26
28
  "/stop",
27
29
  "/sandbox codex <read-only|workspace-write|danger-full-access>",
28
30
  "/status",
29
31
  "/option [retry <count>|timeout <seconds>|intent <count>|command-menu <on|off|refresh>]",
30
32
  "/state [clear|note <text>]",
31
33
  "/artifacts list|cleanup <days>",
34
+ "/cleanup",
32
35
  "/secret set|list|remove",
33
36
  "/docs pin|find|list|remove|reinforce",
37
+ "/macro set|list|remove|<alias|number>",
38
+ "/매크로 set|list|remove|<alias|number>",
34
39
  "/보강 <count>",
35
40
  "/bots",
36
41
  "/bot add <token>",
@@ -76,14 +81,17 @@ const RECOGNIZED_COMMANDS = new Set([
76
81
  "batch",
77
82
  "attach",
78
83
  "model",
84
+ "queue",
79
85
  "stop",
80
86
  "sandbox",
81
87
  "status",
82
88
  "option",
83
89
  "state",
84
90
  "artifacts",
91
+ "cleanup",
85
92
  "secret",
86
93
  "docs",
94
+ "macro",
87
95
  "bots",
88
96
  "bot",
89
97
  "install",
@@ -92,7 +100,10 @@ const RECOGNIZED_COMMANDS = new Set([
92
100
  ]);
93
101
  const TELEGRAM_STALE_UPDATE_GRACE_SECONDS = 10;
94
102
  const TELEGRAM_PROCESS_STARTED_AT_SECONDS = Math.floor(Date.now() / 1000);
95
- const activeWorkLoopKeys = new Set();
103
+ const workLoopTails = new Map();
104
+ const workLoopGenerations = new Map();
105
+ const queuedWorkLoops = new Map();
106
+ let nextQueuedWorkSequence = 1;
96
107
  const REPORT_CONTINUE_PROMPT = [
97
108
  "Continue the same task now.",
98
109
  "Do more concrete work before replying again.",
@@ -177,6 +188,7 @@ export function createBot(token, bridge, botManagement, botInfo) {
177
188
  const autoContinue = new AutoContinueController(path.join(config.dataDir, "stop-gates.json"));
178
189
  const shellService = new RemoteShellService(config.commandTimeoutMs);
179
190
  const memoryService = new AgentMemoryService(config.dataDir);
191
+ const workspaceCleanupService = new WorkspaceCleanupService(config.dataDir, config.workspaceRoot);
180
192
  const sourceBotToken = token;
181
193
  const setupService = new ProviderSetupService(config.setupCommandTimeoutMs, (provider) => bridge.listAvailableProviders().includes(provider), {
182
194
  codex: config.codexInstallCommand,
@@ -196,6 +208,7 @@ export function createBot(token, bridge, botManagement, botInfo) {
196
208
  if (!previous) {
197
209
  return;
198
210
  }
211
+ cancelQueuedWorkLoops(botId, chatId, previous.session.sessionId);
199
212
  autoContinue.requestSessionStop(previous.session.sessionId);
200
213
  messageBatcher.cancelPending(botId, chatId);
201
214
  messageBatcher.cancelManual(botId, chatId);
@@ -350,6 +363,7 @@ ${bridge.formatStatus(mapping)}`);
350
363
  const mapping = await bridge.switchSession(botId, chatId, sessionId);
351
364
  if (previous && previous.session.sessionId !== mapping.session.sessionId) {
352
365
  autoContinue.requestSessionStop(previous.session.sessionId);
366
+ cancelQueuedWorkLoops(botId, chatId, previous.session.sessionId);
353
367
  messageBatcher.cancelPending(botId, chatId);
354
368
  messageBatcher.cancelManual(botId, chatId);
355
369
  await bridge.stopSessionRun(previous.session.sessionId, botId, chatId, "Chat switched to another session; previous session execution was stopped.");
@@ -443,16 +457,47 @@ ${bridge.formatStatus(mapping)}`);
443
457
  const mapping = await bridge.setModel(botId, chatId, model);
444
458
  await reply(ctx, `Set ${mapping.session.mode} model to ${model}.\n\n${bridge.formatStatus(mapping)}`);
445
459
  });
460
+ bot.command("queue", async (ctx) => {
461
+ const botId = getBotId();
462
+ const chatId = String(ctx.chat.id);
463
+ const mapping = await bridge.status(botId, chatId);
464
+ const activeKey = workLoopKey(botId, chatId, mapping?.session.sessionId);
465
+ const { args, rest } = parseCommand(ctx.message?.text, 2);
466
+ const action = args[0]?.toLowerCase();
467
+ const selector = args[1];
468
+ if (rest?.trim() || (action && action !== "list" && action !== "remove" && action !== "rm" && action !== "del")) {
469
+ await reply(ctx, "Usage: `/queue`, `/queue remove <id>`, or `/queue del`", { parse_mode: "Markdown" });
470
+ return;
471
+ }
472
+ if (!action || action === "list") {
473
+ await reply(ctx, formatQueuedWorkLoops(activeKey, mapping?.session.publicId));
474
+ return;
475
+ }
476
+ if ((action === "remove" || action === "rm") && !selector) {
477
+ await reply(ctx, "Usage: `/queue remove <id>`", { parse_mode: "Markdown" });
478
+ return;
479
+ }
480
+ const removed = removeQueuedWorkLoop(activeKey, selector);
481
+ if (!removed) {
482
+ const target = selector ? normalizeQueueId(selector) : "the latest queued instruction";
483
+ await reply(ctx, `Queued instruction was not found: ${target}`);
484
+ return;
485
+ }
486
+ await bridge.logSystem(botId, chatId, `Removed queued instruction ${removed.id} for ${activeKey}.`);
487
+ await reply(ctx, `Removed queued instruction ${removed.id} from ${removed.publicSessionId ?? "this session"}.\n`
488
+ + `Remaining queued instructions: ${listQueuedWorkLoops(activeKey).length}`);
489
+ });
446
490
  bot.command("stop", async (ctx) => {
447
491
  const botId = getBotId();
448
492
  const chatId = String(ctx.chat.id);
449
493
  const mapping = await bridge.status(botId, chatId);
450
494
  const sessionId = mapping?.session.sessionId;
451
495
  autoContinue.requestStop(botId, chatId, sessionId);
496
+ const queuedWorkCount = cancelQueuedWorkLoops(botId, chatId, sessionId);
452
497
  const pendingBatch = messageBatcher.cancelPending(botId, chatId);
453
498
  const manualBatch = messageBatcher.cancelManual(botId, chatId);
454
499
  if (!autoContinue.beginStop(botId, chatId, sessionId)) {
455
- const batchCount = pendingBatch.count + manualBatch.count;
500
+ const batchCount = queuedWorkCount + pendingBatch.count + manualBatch.count;
456
501
  if (batchCount > 0) {
457
502
  await bridge.logSystem(botId, chatId, `Duplicate stop discarded ${batchCount} queued message(s).`);
458
503
  }
@@ -461,7 +506,7 @@ ${bridge.formatStatus(mapping)}`);
461
506
  try {
462
507
  const result = await bridge.stopActiveRun(botId, chatId);
463
508
  await bridge.logSystem(botId, chatId, "Stop requested for auto-continue.");
464
- const batchCount = pendingBatch.count + manualBatch.count;
509
+ const batchCount = queuedWorkCount + pendingBatch.count + manualBatch.count;
465
510
  await reply(ctx, result.stopped
466
511
  ? `Stop requested. Active work for ${result.sessionPublicId ?? "this session"} was interrupted, further automatic continuation will stop, and ${batchCount} queued message(s) were discarded.`
467
512
  : `Stop requested. No active provider process was running, but further automatic continuation will stop, and ${batchCount} queued message(s) were discarded.`);
@@ -620,6 +665,19 @@ ${bridge.formatStatus(mapping)}`);
620
665
  }
621
666
  await reply(ctx, "Usage: `/artifacts list` or `/artifacts cleanup <days>`", { parse_mode: "Markdown" });
622
667
  });
668
+ bot.command("cleanup", async (ctx) => {
669
+ await ensureOwnerControlAccess(ctx);
670
+ const botId = getBotId();
671
+ const chatId = String(ctx.chat.id);
672
+ const mapping = await bridge.status(botId, chatId);
673
+ if (!mapping) {
674
+ await reply(ctx, "No paired session for this chat yet.");
675
+ return;
676
+ }
677
+ const result = await workspaceCleanupService.cleanupSessionWorkspace(mapping.session);
678
+ await bridge.logSystem(botId, chatId, `Workspace cleanup requested for ${mapping.session.publicId}.`);
679
+ await reply(ctx, result);
680
+ });
623
681
  bot.command("secret", async (ctx) => {
624
682
  await ensureOwnerControlAccess(ctx);
625
683
  const { args, rest } = parseCommand(ctx.message?.text, 2);
@@ -700,6 +758,40 @@ ${bridge.formatStatus(mapping)}`);
700
758
  }
701
759
  await reply(ctx, "Usage: `/docs list`, `/docs find <keyword>`, `/docs pin <keyword> <path>`, `/docs remove <keyword>`, or `/docs reinforce <1-10>`", { parse_mode: "Markdown" });
702
760
  });
761
+ const handleMacroCommand = async (ctx, text) => {
762
+ if (!ctx.chat) {
763
+ throw new Error("Telegram chat context is missing.");
764
+ }
765
+ const botId = getBotId();
766
+ const chatId = String(ctx.chat.id);
767
+ const parsed = parseMacroCommandText(text, botId);
768
+ if (parsed.kind === "help") {
769
+ await reply(ctx, await formatMacroHelp(memoryService));
770
+ return;
771
+ }
772
+ if (parsed.kind === "set") {
773
+ await ensureOwnerControlAccess(ctx);
774
+ const macro = await memoryService.setMacro(parsed.alias, parsed.prompt);
775
+ await reply(ctx, `Saved macro '${macro.alias}'.`);
776
+ return;
777
+ }
778
+ if (parsed.kind === "remove") {
779
+ await ensureOwnerControlAccess(ctx);
780
+ const removed = await memoryService.removeMacro(parsed.alias);
781
+ await reply(ctx, removed ? `Removed macro '${parsed.alias}'.` : `Macro was not found: ${parsed.alias}`);
782
+ return;
783
+ }
784
+ const macro = await memoryService.getMacro(parsed.target);
785
+ if (!macro) {
786
+ await reply(ctx, `Macro was not found: ${parsed.target}\n\n${await memoryService.listMacros()}`);
787
+ return;
788
+ }
789
+ await bridge.logSystem(botId, chatId, `Macro executed: ${macro.alias}`);
790
+ await messageBatcher.enqueue({ botToken: token, telegramChatId: ctx.chat.id }, botId, chatId, macro.prompt);
791
+ };
792
+ bot.command("macro", async (ctx) => {
793
+ await handleMacroCommand(ctx, ctx.message?.text ?? "/macro");
794
+ });
703
795
  bot.command("bots", async (ctx) => {
704
796
  await ensureOwnerControlAccess(ctx);
705
797
  const pendingNotice = await botManagement.getPendingOperationNotice();
@@ -825,6 +917,11 @@ ${bridge.formatStatus(mapping)}`);
825
917
  await reply(ctx, "Usage: `/보강 <1-10>`", { parse_mode: "Markdown" });
826
918
  return;
827
919
  }
920
+ const koreanMacro = parseKoreanMacroCommand(text, botId);
921
+ if (koreanMacro) {
922
+ await handleMacroCommand(ctx, text ?? "/매크로");
923
+ return;
924
+ }
828
925
  if (text && isRecognizedSlashCommand(text, botId)) {
829
926
  return;
830
927
  }
@@ -1155,22 +1252,70 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1155
1252
  const currentSession = await bridge.status(botId, chatId);
1156
1253
  const sessionId = currentSession?.session.sessionId;
1157
1254
  const activeKey = workLoopKey(botId, chatId, sessionId);
1158
- if (activeWorkLoopKeys.has(activeKey)) {
1159
- const message = [
1160
- `Session ${currentSession?.session.publicId ?? sessionId ?? `${botId}:${chatId}`} is already running.`,
1161
- "Send /stop to interrupt the active work, then send the instruction again.",
1162
- ].join("\n");
1163
- await bridge.logSystem(botId, chatId, `Rejected overlapping Telegram work loop for ${activeKey}.`);
1164
- return [message];
1165
- }
1166
- activeWorkLoopKeys.add(activeKey);
1167
- if (currentSession) {
1168
- await memoryService.recordInstruction(currentSession.session, message);
1255
+ const previousTail = workLoopTails.get(activeKey);
1256
+ const generation = workLoopGenerations.get(activeKey) ?? 0;
1257
+ let release;
1258
+ const gate = new Promise((resolve) => {
1259
+ release = resolve;
1260
+ });
1261
+ const currentTail = (previousTail ?? Promise.resolve())
1262
+ .catch(() => undefined)
1263
+ .then(() => gate);
1264
+ workLoopTails.set(activeKey, currentTail);
1265
+ let queuedEntry;
1266
+ if (previousTail) {
1267
+ queuedEntry = registerQueuedWorkLoop({
1268
+ activeKey,
1269
+ botId,
1270
+ chatId,
1271
+ sessionId,
1272
+ publicSessionId: currentSession?.session.publicId,
1273
+ message,
1274
+ release,
1275
+ });
1276
+ try {
1277
+ await bridge.logSystem(botId, chatId, `Queued overlapping Telegram work loop ${queuedEntry.id} for ${activeKey}.`);
1278
+ await helpers.reportProgress([
1279
+ `Queued instruction ${queuedEntry.id} for ${currentSession?.session.publicId ?? "this session"}. It will run after the active work finishes.`,
1280
+ `Remove it with \`/queue remove ${queuedEntry.id}\`, or remove the latest queued instruction with \`/queue del\`.`,
1281
+ ]);
1282
+ await previousTail.catch(() => undefined);
1283
+ }
1284
+ catch (error) {
1285
+ queuedWorkLoops.delete(queuedEntry.id);
1286
+ release();
1287
+ if (workLoopTails.get(activeKey) === currentTail) {
1288
+ workLoopTails.delete(activeKey);
1289
+ }
1290
+ throw error;
1291
+ }
1292
+ queuedWorkLoops.delete(queuedEntry.id);
1293
+ if (queuedEntry.canceled || (workLoopGenerations.get(activeKey) ?? 0) !== generation || autoContinue.isStopRequested(botId, chatId, sessionId)) {
1294
+ await bridge.logSystem(botId, chatId, `Discarded queued Telegram work loop ${queuedEntry.id} for ${activeKey}.`);
1295
+ release();
1296
+ if (workLoopTails.get(activeKey) === currentTail) {
1297
+ workLoopTails.delete(activeKey);
1298
+ }
1299
+ throw new SilentTelegramAbort("Queued work was discarded.");
1300
+ }
1301
+ }
1302
+ let managedContext = "";
1303
+ try {
1304
+ if (currentSession) {
1305
+ await memoryService.recordInstruction(currentSession.session, message);
1306
+ }
1307
+ managedContext = currentSession
1308
+ ? await memoryService.formatProviderContext(currentSession.session)
1309
+ : "";
1310
+ autoContinue.clear(botId, chatId, sessionId);
1311
+ }
1312
+ catch (error) {
1313
+ release();
1314
+ if (workLoopTails.get(activeKey) === currentTail) {
1315
+ workLoopTails.delete(activeKey);
1316
+ }
1317
+ throw error;
1169
1318
  }
1170
- const managedContext = currentSession
1171
- ? await memoryService.formatProviderContext(currentSession.session)
1172
- : "";
1173
- autoContinue.clear(botId, chatId, sessionId);
1174
1319
  let prompt = appendManagedContext(appendReportProtocol(message), managedContext);
1175
1320
  const maxTurns = options.maxTurns ?? config.telegramAutoProgressMaxTurns;
1176
1321
  const emptyResponseRetries = config.telegramEmptyResponseRetries;
@@ -1351,7 +1496,10 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1351
1496
  }
1352
1497
  }
1353
1498
  finally {
1354
- activeWorkLoopKeys.delete(activeKey);
1499
+ release();
1500
+ if (workLoopTails.get(activeKey) === currentTail) {
1501
+ workLoopTails.delete(activeKey);
1502
+ }
1355
1503
  if (currentSession) {
1356
1504
  if (providerCompleted) {
1357
1505
  await botManagement.markProviderCompleted(botId, sessionId);
@@ -1365,6 +1513,88 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1365
1513
  function workLoopKey(botId, chatId, sessionId) {
1366
1514
  return sessionId ? `session:${sessionId}` : `chat:${botId}:${chatId}`;
1367
1515
  }
1516
+ function cancelQueuedWorkLoops(botId, chatId, sessionId) {
1517
+ const keys = [workLoopKey(botId, chatId, sessionId)];
1518
+ if (sessionId) {
1519
+ keys.push(workLoopKey(botId, chatId));
1520
+ }
1521
+ const keySet = new Set(keys);
1522
+ let removed = 0;
1523
+ for (const entry of queuedWorkLoops.values()) {
1524
+ if (!keySet.has(entry.activeKey)) {
1525
+ continue;
1526
+ }
1527
+ entry.canceled = true;
1528
+ entry.release();
1529
+ queuedWorkLoops.delete(entry.id);
1530
+ removed += 1;
1531
+ }
1532
+ for (const key of keys) {
1533
+ workLoopGenerations.set(key, (workLoopGenerations.get(key) ?? 0) + 1);
1534
+ }
1535
+ return removed;
1536
+ }
1537
+ function registerQueuedWorkLoop(input) {
1538
+ const sequence = nextQueuedWorkSequence;
1539
+ nextQueuedWorkSequence += 1;
1540
+ const entry = {
1541
+ id: `Q${String(sequence).padStart(3, "0")}`,
1542
+ sequence,
1543
+ activeKey: input.activeKey,
1544
+ botId: input.botId,
1545
+ chatId: input.chatId,
1546
+ sessionId: input.sessionId,
1547
+ publicSessionId: input.publicSessionId,
1548
+ messagePreview: summarizeQueuedInstruction(input.message),
1549
+ createdAt: new Date().toISOString(),
1550
+ canceled: false,
1551
+ release: input.release,
1552
+ };
1553
+ queuedWorkLoops.set(entry.id, entry);
1554
+ return entry;
1555
+ }
1556
+ function listQueuedWorkLoops(activeKey) {
1557
+ return [...queuedWorkLoops.values()]
1558
+ .filter((entry) => entry.activeKey === activeKey && !entry.canceled)
1559
+ .sort((left, right) => left.sequence - right.sequence);
1560
+ }
1561
+ function removeQueuedWorkLoop(activeKey, selector) {
1562
+ const entries = listQueuedWorkLoops(activeKey);
1563
+ const target = selector
1564
+ ? queuedWorkLoops.get(normalizeQueueId(selector))
1565
+ : entries.at(-1);
1566
+ if (!target || target.activeKey !== activeKey || target.canceled) {
1567
+ return undefined;
1568
+ }
1569
+ target.canceled = true;
1570
+ target.release();
1571
+ queuedWorkLoops.delete(target.id);
1572
+ return target;
1573
+ }
1574
+ function normalizeQueueId(value) {
1575
+ const normalized = value.trim().toUpperCase();
1576
+ if (/^\d+$/.test(normalized)) {
1577
+ return `Q${normalized.padStart(3, "0")}`;
1578
+ }
1579
+ return normalized;
1580
+ }
1581
+ function summarizeQueuedInstruction(message) {
1582
+ const normalized = message.replace(/\s+/g, " ").trim();
1583
+ return normalized.length > 100 ? `${normalized.slice(0, 97)}...` : normalized;
1584
+ }
1585
+ function formatQueuedWorkLoops(activeKey, publicSessionId) {
1586
+ const entries = listQueuedWorkLoops(activeKey);
1587
+ if (entries.length === 0) {
1588
+ return `No queued instructions for ${publicSessionId ?? "this chat"}.`;
1589
+ }
1590
+ return [
1591
+ `Queued instructions for ${publicSessionId ?? "this chat"} (${entries.length})`,
1592
+ ...entries.map((entry) => `${entry.id}: ${entry.messagePreview || "(empty instruction)"}`),
1593
+ "",
1594
+ `Remove one: /queue remove ${entries[0].id}`,
1595
+ "Remove latest: /queue del",
1596
+ ].join("\n");
1597
+ }
1368
1598
  class SilentTelegramAbort extends Error {
1369
1599
  constructor(message) {
1370
1600
  super(message);
@@ -1711,6 +1941,124 @@ function parseCommand(text, headCount) {
1711
1941
  rest: remaining || undefined,
1712
1942
  };
1713
1943
  }
1944
+ function parseMacroCommandText(text, botId) {
1945
+ const body = stripSlashCommand(text, ["macro", "매크로"], botId).trim();
1946
+ if (!body) {
1947
+ return { kind: "help" };
1948
+ }
1949
+ const tokens = tokenizeCommandBody(body);
1950
+ const action = tokens[0]?.toLowerCase();
1951
+ if (action === "list") {
1952
+ return { kind: "help" };
1953
+ }
1954
+ if (action === "set") {
1955
+ const alias = tokens[1]?.trim();
1956
+ const prompt = tokens.length >= 3
1957
+ ? tokens.slice(2).join(" ").trim()
1958
+ : "";
1959
+ if (!alias || !prompt) {
1960
+ throw new Error("Usage: /macro set <alias> <instruction>");
1961
+ }
1962
+ return { kind: "set", alias, prompt };
1963
+ }
1964
+ if (action === "remove" || action === "rm" || action === "delete" || action === "del" || action === "clear") {
1965
+ const alias = tokens[1]?.trim();
1966
+ if (!alias || tokens.length > 2) {
1967
+ throw new Error("Usage: /macro remove <alias>");
1968
+ }
1969
+ return { kind: "remove", alias };
1970
+ }
1971
+ return { kind: "run", target: tokens.length === 1 ? tokens[0] : body };
1972
+ }
1973
+ function parseKoreanMacroCommand(text, botId) {
1974
+ return Boolean(text && stripSlashCommand(text, ["매크로"], botId) !== text);
1975
+ }
1976
+ function stripSlashCommand(text, names, botId) {
1977
+ const trimmed = text?.trim() ?? "";
1978
+ for (const name of names) {
1979
+ const escaped = escapeRegExp(name);
1980
+ const username = botId ? `(?:@${escapeRegExp(botId.replace(/^@/, ""))})?` : "(?:@\\w+)?";
1981
+ const pattern = new RegExp(`^/${escaped}${username}(?:\\s+|$)`, "i");
1982
+ if (pattern.test(trimmed)) {
1983
+ return trimmed.replace(pattern, "");
1984
+ }
1985
+ }
1986
+ return trimmed;
1987
+ }
1988
+ function tokenizeCommandBody(input) {
1989
+ const tokens = [];
1990
+ let current = "";
1991
+ let quote;
1992
+ let escaped = false;
1993
+ for (const char of input) {
1994
+ if (escaped) {
1995
+ current += char;
1996
+ escaped = false;
1997
+ continue;
1998
+ }
1999
+ if (char === "\\") {
2000
+ escaped = true;
2001
+ continue;
2002
+ }
2003
+ if (quote) {
2004
+ if (char === quote) {
2005
+ quote = undefined;
2006
+ }
2007
+ else {
2008
+ current += char;
2009
+ }
2010
+ continue;
2011
+ }
2012
+ if (char === "'" || char === "\"") {
2013
+ quote = char;
2014
+ continue;
2015
+ }
2016
+ if (/\s/.test(char)) {
2017
+ if (current) {
2018
+ tokens.push(current);
2019
+ current = "";
2020
+ }
2021
+ continue;
2022
+ }
2023
+ current += char;
2024
+ }
2025
+ if (escaped) {
2026
+ current += "\\";
2027
+ }
2028
+ if (quote) {
2029
+ throw new Error("Unclosed quote in command.");
2030
+ }
2031
+ if (current) {
2032
+ tokens.push(current);
2033
+ }
2034
+ return tokens;
2035
+ }
2036
+ function escapeRegExp(value) {
2037
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2038
+ }
2039
+ async function formatMacroHelp(memoryService) {
2040
+ return [
2041
+ "Macro command guide",
2042
+ "",
2043
+ "Commands:",
2044
+ "```text",
2045
+ "/macro",
2046
+ "/macro set 초기설정 너의 역할은 ...",
2047
+ "/macro set '초기설정' '너의 역할은 ...'",
2048
+ "/macro 초기설정",
2049
+ "/macro 1",
2050
+ "/macro remove 초기설정",
2051
+ "/매크로",
2052
+ "/매크로 초기설정",
2053
+ "```",
2054
+ "",
2055
+ "Rules:",
2056
+ "- Macro aliases can use Korean, letters, numbers, dot, underscore, and dash.",
2057
+ "- Numeric-only aliases are reserved for list selection and cannot be registered.",
2058
+ "",
2059
+ await memoryService.listMacros(),
2060
+ ].join("\n");
2061
+ }
1714
2062
  function parsePlanReinforcementCount(raw) {
1715
2063
  const value = raw?.trim();
1716
2064
  if (!value) {
package/dist/config.js CHANGED
@@ -152,6 +152,8 @@ export const config = {
152
152
  artifactCleanupEnabled: readBoolean("ARTIFACT_CLEANUP_ENABLED", true),
153
153
  artifactRetentionDays: readTimeout("ARTIFACT_RETENTION_DAYS", 30),
154
154
  artifactCleanupIntervalMs: readTimeout("ARTIFACT_CLEANUP_INTERVAL_MS", 86_400_000),
155
+ workspaceCleanupEnabled: readBoolean("WORKSPACE_CLEANUP_ENABLED", true),
156
+ workspaceCleanupIntervalMs: readTimeout("WORKSPACE_CLEANUP_INTERVAL_MS", 86_400_000),
155
157
  dataDir: defaultDataDir,
156
158
  defaultMode: readMode("DEFAULT_MODE", "codex"),
157
159
  defaultWorkspace: path.resolve(process.env.DEFAULT_WORKSPACE?.trim() || os.homedir()),
package/dist/index.js CHANGED
@@ -14,11 +14,13 @@ import { BridgeService } from "./services/bridge-service.js";
14
14
  import { BotManagementService } from "./services/bot-management-service.js";
15
15
  import { LocalUiService } from "./services/local-ui-service.js";
16
16
  import { AgentMemoryService } from "./services/agent-memory-service.js";
17
+ import { WorkspaceCleanupService } from "./services/workspace-cleanup-service.js";
17
18
  import { BotPollingStateService } from "./services/bot-polling-state-service.js";
18
19
  import { ProviderRecoveryService } from "./services/provider-recovery-service.js";
19
20
  import { computePolicyPollIntervalMs, computeRecentMessageRanks } from "./services/polling-policy.js";
20
21
  import { terminateAllSpawnedExecutions } from "./adapters/windows-shell.js";
21
22
  import { setTelegramCommandMenu } from "./telegram-command-menu.js";
23
+ import { buildBotInfoFromIdentity, buildFallbackBotInfo } from "./telegram-bot-identity.js";
22
24
  const execFileAsync = promisify(execFile);
23
25
  const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_SECONDS = 30;
24
26
  const TELEGRAM_GET_UPDATES_CURL_TIMEOUT_SECONDS = 60;
@@ -56,6 +58,7 @@ async function main() {
56
58
  const bridge = new BridgeService(store, adapters, config.defaultWorkspace, config.workspaceRoot, isProviderInstalled, config.defaultMode, config.codexSandboxMode);
57
59
  const botManagement = new BotManagementService(config.dataDir, config.botRestartServiceName, config.botRestartHelperPath, botPollingState);
58
60
  startArtifactCleanupSchedule(new AgentMemoryService(config.dataDir));
61
+ startWorkspaceCleanupSchedule(new WorkspaceCleanupService(config.dataDir, config.workspaceRoot));
59
62
  if (config.localUiEnabled) {
60
63
  const localUi = new LocalUiService(bridge, config.localUiHost, config.localUiPort);
61
64
  await localUi.start()
@@ -66,7 +69,7 @@ async function main() {
66
69
  console.error("Local UI failed to start:", error);
67
70
  });
68
71
  }
69
- 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])));
70
73
  const bots = config.telegramBotTokens.map((token, index) => createBot(token, bridge, botManagement, botInfos[index]));
71
74
  if (config.telegramCommandMenuEnabled) {
72
75
  for (const bot of bots) {
@@ -88,6 +91,30 @@ async function main() {
88
91
  });
89
92
  await startManualPollingScheduler(bots);
90
93
  }
94
+ function startWorkspaceCleanupSchedule(workspaceCleanupService) {
95
+ if (!config.workspaceCleanupEnabled) {
96
+ console.log("Workspace cleanup schedule is disabled.");
97
+ return;
98
+ }
99
+ const run = async () => {
100
+ try {
101
+ const result = await workspaceCleanupService.cleanupOrphanWorkspaces();
102
+ console.log(`[workspace-cleanup] ${result}`);
103
+ }
104
+ catch (error) {
105
+ console.error("[workspace-cleanup] failed:", error);
106
+ }
107
+ };
108
+ console.log(`Workspace cleanup schedule enabled: interval=${config.workspaceCleanupIntervalMs}ms`);
109
+ const initial = setTimeout(() => {
110
+ void run();
111
+ }, 120_000);
112
+ initial.unref();
113
+ const interval = setInterval(() => {
114
+ void run();
115
+ }, config.workspaceCleanupIntervalMs);
116
+ interval.unref();
117
+ }
91
118
  function startArtifactCleanupSchedule(memoryService) {
92
119
  if (!config.artifactCleanupEnabled) {
93
120
  console.log("Artifact cleanup schedule is disabled.");
@@ -458,7 +485,7 @@ class AsyncSemaphore {
458
485
  }
459
486
  }
460
487
  }
461
- async function resolveBotInfo(token, index) {
488
+ async function resolveBotInfo(token, index, configuredUsername) {
462
489
  try {
463
490
  const { stdout } = await execFileAsync("curl", [
464
491
  "-sS",
@@ -476,32 +503,7 @@ async function resolveBotInfo(token, index) {
476
503
  catch (error) {
477
504
  console.warn(`Telegram getMe failed for bot ${tokenIdLabel(token)}: ${summarizeTelegramIdentityError(error)}`);
478
505
  }
479
- return buildFallbackBotInfo(token, index);
480
- }
481
- function buildBotInfoFromIdentity(id, username, firstName) {
482
- return {
483
- id,
484
- is_bot: true,
485
- first_name: firstName || username,
486
- username,
487
- can_join_groups: false,
488
- can_read_all_group_messages: false,
489
- supports_inline_queries: false,
490
- };
491
- }
492
- function buildFallbackBotInfo(token, index) {
493
- const id = Number.parseInt(token.split(":", 1)[0] ?? "", 10);
494
- const fallbackUsername = knownBotUsername(id);
495
- const username = fallbackUsername || `bot_${Number.isFinite(id) ? id : index + 1}`;
496
- return {
497
- id: Number.isFinite(id) ? id : index + 1,
498
- is_bot: true,
499
- first_name: username,
500
- username,
501
- can_join_groups: false,
502
- can_read_all_group_messages: false,
503
- supports_inline_queries: false,
504
- };
506
+ return buildFallbackBotInfo(token, index, configuredUsername);
505
507
  }
506
508
  function tokenIdLabel(token) {
507
509
  return token.split(":", 1)[0] || "unknown";
@@ -517,15 +519,6 @@ function summarizeTelegramIdentityError(error) {
517
519
  .filter(Boolean)
518
520
  .join(" ");
519
521
  }
520
- function knownBotUsername(id) {
521
- if (id === 8369496408) {
522
- return "codex_remoteagent_bot";
523
- }
524
- if (id === 8429712341) {
525
- return "sqream_bot";
526
- }
527
- return undefined;
528
- }
529
522
  function commandExists(command) {
530
523
  const trimmed = command.trim();
531
524
  if (!trimmed) {