appback-remoteagent 0.15.6 → 0.16.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
@@ -182,6 +182,8 @@ Telegram attachments are written into the local runtime under `~/.remoteagent/up
182
182
 
183
183
  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
184
 
185
+ 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.
186
+
185
187
  Current supported attachment classes:
186
188
 
187
189
  - 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 = [
@@ -29,8 +30,11 @@ const HELP_TEXT = [
29
30
  "/option [retry <count>|timeout <seconds>|intent <count>|command-menu <on|off|refresh>]",
30
31
  "/state [clear|note <text>]",
31
32
  "/artifacts list|cleanup <days>",
33
+ "/cleanup",
32
34
  "/secret set|list|remove",
33
35
  "/docs pin|find|list|remove|reinforce",
36
+ "/macro set|list|remove|<alias|number>",
37
+ "/매크로 set|list|remove|<alias|number>",
34
38
  "/보강 <count>",
35
39
  "/bots",
36
40
  "/bot add <token>",
@@ -82,8 +86,10 @@ const RECOGNIZED_COMMANDS = new Set([
82
86
  "option",
83
87
  "state",
84
88
  "artifacts",
89
+ "cleanup",
85
90
  "secret",
86
91
  "docs",
92
+ "macro",
87
93
  "bots",
88
94
  "bot",
89
95
  "install",
@@ -92,7 +98,8 @@ const RECOGNIZED_COMMANDS = new Set([
92
98
  ]);
93
99
  const TELEGRAM_STALE_UPDATE_GRACE_SECONDS = 10;
94
100
  const TELEGRAM_PROCESS_STARTED_AT_SECONDS = Math.floor(Date.now() / 1000);
95
- const activeWorkLoopKeys = new Set();
101
+ const workLoopTails = new Map();
102
+ const workLoopGenerations = new Map();
96
103
  const REPORT_CONTINUE_PROMPT = [
97
104
  "Continue the same task now.",
98
105
  "Do more concrete work before replying again.",
@@ -177,6 +184,7 @@ export function createBot(token, bridge, botManagement, botInfo) {
177
184
  const autoContinue = new AutoContinueController(path.join(config.dataDir, "stop-gates.json"));
178
185
  const shellService = new RemoteShellService(config.commandTimeoutMs);
179
186
  const memoryService = new AgentMemoryService(config.dataDir);
187
+ const workspaceCleanupService = new WorkspaceCleanupService(config.dataDir, config.workspaceRoot);
180
188
  const sourceBotToken = token;
181
189
  const setupService = new ProviderSetupService(config.setupCommandTimeoutMs, (provider) => bridge.listAvailableProviders().includes(provider), {
182
190
  codex: config.codexInstallCommand,
@@ -196,6 +204,7 @@ export function createBot(token, bridge, botManagement, botInfo) {
196
204
  if (!previous) {
197
205
  return;
198
206
  }
207
+ cancelQueuedWorkLoops(botId, chatId, previous.session.sessionId);
199
208
  autoContinue.requestSessionStop(previous.session.sessionId);
200
209
  messageBatcher.cancelPending(botId, chatId);
201
210
  messageBatcher.cancelManual(botId, chatId);
@@ -350,6 +359,7 @@ ${bridge.formatStatus(mapping)}`);
350
359
  const mapping = await bridge.switchSession(botId, chatId, sessionId);
351
360
  if (previous && previous.session.sessionId !== mapping.session.sessionId) {
352
361
  autoContinue.requestSessionStop(previous.session.sessionId);
362
+ cancelQueuedWorkLoops(botId, chatId, previous.session.sessionId);
353
363
  messageBatcher.cancelPending(botId, chatId);
354
364
  messageBatcher.cancelManual(botId, chatId);
355
365
  await bridge.stopSessionRun(previous.session.sessionId, botId, chatId, "Chat switched to another session; previous session execution was stopped.");
@@ -449,6 +459,7 @@ ${bridge.formatStatus(mapping)}`);
449
459
  const mapping = await bridge.status(botId, chatId);
450
460
  const sessionId = mapping?.session.sessionId;
451
461
  autoContinue.requestStop(botId, chatId, sessionId);
462
+ cancelQueuedWorkLoops(botId, chatId, sessionId);
452
463
  const pendingBatch = messageBatcher.cancelPending(botId, chatId);
453
464
  const manualBatch = messageBatcher.cancelManual(botId, chatId);
454
465
  if (!autoContinue.beginStop(botId, chatId, sessionId)) {
@@ -620,6 +631,19 @@ ${bridge.formatStatus(mapping)}`);
620
631
  }
621
632
  await reply(ctx, "Usage: `/artifacts list` or `/artifacts cleanup <days>`", { parse_mode: "Markdown" });
622
633
  });
634
+ bot.command("cleanup", async (ctx) => {
635
+ await ensureOwnerControlAccess(ctx);
636
+ const botId = getBotId();
637
+ const chatId = String(ctx.chat.id);
638
+ const mapping = await bridge.status(botId, chatId);
639
+ if (!mapping) {
640
+ await reply(ctx, "No paired session for this chat yet.");
641
+ return;
642
+ }
643
+ const result = await workspaceCleanupService.cleanupSessionWorkspace(mapping.session);
644
+ await bridge.logSystem(botId, chatId, `Workspace cleanup requested for ${mapping.session.publicId}.`);
645
+ await reply(ctx, result);
646
+ });
623
647
  bot.command("secret", async (ctx) => {
624
648
  await ensureOwnerControlAccess(ctx);
625
649
  const { args, rest } = parseCommand(ctx.message?.text, 2);
@@ -700,6 +724,40 @@ ${bridge.formatStatus(mapping)}`);
700
724
  }
701
725
  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
726
  });
727
+ const handleMacroCommand = async (ctx, text) => {
728
+ if (!ctx.chat) {
729
+ throw new Error("Telegram chat context is missing.");
730
+ }
731
+ const botId = getBotId();
732
+ const chatId = String(ctx.chat.id);
733
+ const parsed = parseMacroCommandText(text, botId);
734
+ if (parsed.kind === "help") {
735
+ await reply(ctx, await formatMacroHelp(memoryService));
736
+ return;
737
+ }
738
+ if (parsed.kind === "set") {
739
+ await ensureOwnerControlAccess(ctx);
740
+ const macro = await memoryService.setMacro(parsed.alias, parsed.prompt);
741
+ await reply(ctx, `Saved macro '${macro.alias}'.`);
742
+ return;
743
+ }
744
+ if (parsed.kind === "remove") {
745
+ await ensureOwnerControlAccess(ctx);
746
+ const removed = await memoryService.removeMacro(parsed.alias);
747
+ await reply(ctx, removed ? `Removed macro '${parsed.alias}'.` : `Macro was not found: ${parsed.alias}`);
748
+ return;
749
+ }
750
+ const macro = await memoryService.getMacro(parsed.target);
751
+ if (!macro) {
752
+ await reply(ctx, `Macro was not found: ${parsed.target}\n\n${await memoryService.listMacros()}`);
753
+ return;
754
+ }
755
+ await bridge.logSystem(botId, chatId, `Macro executed: ${macro.alias}`);
756
+ await messageBatcher.enqueue({ botToken: token, telegramChatId: ctx.chat.id }, botId, chatId, macro.prompt);
757
+ };
758
+ bot.command("macro", async (ctx) => {
759
+ await handleMacroCommand(ctx, ctx.message?.text ?? "/macro");
760
+ });
703
761
  bot.command("bots", async (ctx) => {
704
762
  await ensureOwnerControlAccess(ctx);
705
763
  const pendingNotice = await botManagement.getPendingOperationNotice();
@@ -825,6 +883,11 @@ ${bridge.formatStatus(mapping)}`);
825
883
  await reply(ctx, "Usage: `/보강 <1-10>`", { parse_mode: "Markdown" });
826
884
  return;
827
885
  }
886
+ const koreanMacro = parseKoreanMacroCommand(text, botId);
887
+ if (koreanMacro) {
888
+ await handleMacroCommand(ctx, text ?? "/매크로");
889
+ return;
890
+ }
828
891
  if (text && isRecognizedSlashCommand(text, botId)) {
829
892
  return;
830
893
  }
@@ -1155,22 +1218,46 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1155
1218
  const currentSession = await bridge.status(botId, chatId);
1156
1219
  const sessionId = currentSession?.session.sessionId;
1157
1220
  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);
1221
+ const previousTail = workLoopTails.get(activeKey);
1222
+ const generation = workLoopGenerations.get(activeKey) ?? 0;
1223
+ let release;
1224
+ const gate = new Promise((resolve) => {
1225
+ release = resolve;
1226
+ });
1227
+ const currentTail = (previousTail ?? Promise.resolve())
1228
+ .catch(() => undefined)
1229
+ .then(() => gate);
1230
+ workLoopTails.set(activeKey, currentTail);
1231
+ if (previousTail) {
1232
+ await bridge.logSystem(botId, chatId, `Queued overlapping Telegram work loop for ${activeKey}.`);
1233
+ await helpers.reportProgress([`Queued instruction for ${currentSession?.session.publicId ?? "this session"}. It will run after the active work finishes.`]);
1234
+ await previousTail.catch(() => undefined);
1235
+ if ((workLoopGenerations.get(activeKey) ?? 0) !== generation || autoContinue.isStopRequested(botId, chatId, sessionId)) {
1236
+ await bridge.logSystem(botId, chatId, `Discarded queued Telegram work loop for ${activeKey}.`);
1237
+ release();
1238
+ if (workLoopTails.get(activeKey) === currentTail) {
1239
+ workLoopTails.delete(activeKey);
1240
+ }
1241
+ throw new SilentTelegramAbort("Queued work was discarded.");
1242
+ }
1243
+ }
1244
+ let managedContext = "";
1245
+ try {
1246
+ if (currentSession) {
1247
+ await memoryService.recordInstruction(currentSession.session, message);
1248
+ }
1249
+ managedContext = currentSession
1250
+ ? await memoryService.formatProviderContext(currentSession.session)
1251
+ : "";
1252
+ autoContinue.clear(botId, chatId, sessionId);
1253
+ }
1254
+ catch (error) {
1255
+ release();
1256
+ if (workLoopTails.get(activeKey) === currentTail) {
1257
+ workLoopTails.delete(activeKey);
1258
+ }
1259
+ throw error;
1169
1260
  }
1170
- const managedContext = currentSession
1171
- ? await memoryService.formatProviderContext(currentSession.session)
1172
- : "";
1173
- autoContinue.clear(botId, chatId, sessionId);
1174
1261
  let prompt = appendManagedContext(appendReportProtocol(message), managedContext);
1175
1262
  const maxTurns = options.maxTurns ?? config.telegramAutoProgressMaxTurns;
1176
1263
  const emptyResponseRetries = config.telegramEmptyResponseRetries;
@@ -1351,7 +1438,10 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1351
1438
  }
1352
1439
  }
1353
1440
  finally {
1354
- activeWorkLoopKeys.delete(activeKey);
1441
+ release();
1442
+ if (workLoopTails.get(activeKey) === currentTail) {
1443
+ workLoopTails.delete(activeKey);
1444
+ }
1355
1445
  if (currentSession) {
1356
1446
  if (providerCompleted) {
1357
1447
  await botManagement.markProviderCompleted(botId, sessionId);
@@ -1365,6 +1455,15 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1365
1455
  function workLoopKey(botId, chatId, sessionId) {
1366
1456
  return sessionId ? `session:${sessionId}` : `chat:${botId}:${chatId}`;
1367
1457
  }
1458
+ function cancelQueuedWorkLoops(botId, chatId, sessionId) {
1459
+ const keys = [workLoopKey(botId, chatId, sessionId)];
1460
+ if (sessionId) {
1461
+ keys.push(workLoopKey(botId, chatId));
1462
+ }
1463
+ for (const key of keys) {
1464
+ workLoopGenerations.set(key, (workLoopGenerations.get(key) ?? 0) + 1);
1465
+ }
1466
+ }
1368
1467
  class SilentTelegramAbort extends Error {
1369
1468
  constructor(message) {
1370
1469
  super(message);
@@ -1711,6 +1810,124 @@ function parseCommand(text, headCount) {
1711
1810
  rest: remaining || undefined,
1712
1811
  };
1713
1812
  }
1813
+ function parseMacroCommandText(text, botId) {
1814
+ const body = stripSlashCommand(text, ["macro", "매크로"], botId).trim();
1815
+ if (!body) {
1816
+ return { kind: "help" };
1817
+ }
1818
+ const tokens = tokenizeCommandBody(body);
1819
+ const action = tokens[0]?.toLowerCase();
1820
+ if (action === "list") {
1821
+ return { kind: "help" };
1822
+ }
1823
+ if (action === "set") {
1824
+ const alias = tokens[1]?.trim();
1825
+ const prompt = tokens.length >= 3
1826
+ ? tokens.slice(2).join(" ").trim()
1827
+ : "";
1828
+ if (!alias || !prompt) {
1829
+ throw new Error("Usage: /macro set <alias> <instruction>");
1830
+ }
1831
+ return { kind: "set", alias, prompt };
1832
+ }
1833
+ if (action === "remove" || action === "rm" || action === "delete" || action === "del" || action === "clear") {
1834
+ const alias = tokens[1]?.trim();
1835
+ if (!alias || tokens.length > 2) {
1836
+ throw new Error("Usage: /macro remove <alias>");
1837
+ }
1838
+ return { kind: "remove", alias };
1839
+ }
1840
+ return { kind: "run", target: tokens.length === 1 ? tokens[0] : body };
1841
+ }
1842
+ function parseKoreanMacroCommand(text, botId) {
1843
+ return Boolean(text && stripSlashCommand(text, ["매크로"], botId) !== text);
1844
+ }
1845
+ function stripSlashCommand(text, names, botId) {
1846
+ const trimmed = text?.trim() ?? "";
1847
+ for (const name of names) {
1848
+ const escaped = escapeRegExp(name);
1849
+ const username = botId ? `(?:@${escapeRegExp(botId.replace(/^@/, ""))})?` : "(?:@\\w+)?";
1850
+ const pattern = new RegExp(`^/${escaped}${username}(?:\\s+|$)`, "i");
1851
+ if (pattern.test(trimmed)) {
1852
+ return trimmed.replace(pattern, "");
1853
+ }
1854
+ }
1855
+ return trimmed;
1856
+ }
1857
+ function tokenizeCommandBody(input) {
1858
+ const tokens = [];
1859
+ let current = "";
1860
+ let quote;
1861
+ let escaped = false;
1862
+ for (const char of input) {
1863
+ if (escaped) {
1864
+ current += char;
1865
+ escaped = false;
1866
+ continue;
1867
+ }
1868
+ if (char === "\\") {
1869
+ escaped = true;
1870
+ continue;
1871
+ }
1872
+ if (quote) {
1873
+ if (char === quote) {
1874
+ quote = undefined;
1875
+ }
1876
+ else {
1877
+ current += char;
1878
+ }
1879
+ continue;
1880
+ }
1881
+ if (char === "'" || char === "\"") {
1882
+ quote = char;
1883
+ continue;
1884
+ }
1885
+ if (/\s/.test(char)) {
1886
+ if (current) {
1887
+ tokens.push(current);
1888
+ current = "";
1889
+ }
1890
+ continue;
1891
+ }
1892
+ current += char;
1893
+ }
1894
+ if (escaped) {
1895
+ current += "\\";
1896
+ }
1897
+ if (quote) {
1898
+ throw new Error("Unclosed quote in command.");
1899
+ }
1900
+ if (current) {
1901
+ tokens.push(current);
1902
+ }
1903
+ return tokens;
1904
+ }
1905
+ function escapeRegExp(value) {
1906
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1907
+ }
1908
+ async function formatMacroHelp(memoryService) {
1909
+ return [
1910
+ "Macro command guide",
1911
+ "",
1912
+ "Commands:",
1913
+ "```text",
1914
+ "/macro",
1915
+ "/macro set 초기설정 너의 역할은 ...",
1916
+ "/macro set '초기설정' '너의 역할은 ...'",
1917
+ "/macro 초기설정",
1918
+ "/macro 1",
1919
+ "/macro remove 초기설정",
1920
+ "/매크로",
1921
+ "/매크로 초기설정",
1922
+ "```",
1923
+ "",
1924
+ "Rules:",
1925
+ "- Macro aliases can use Korean, letters, numbers, dot, underscore, and dash.",
1926
+ "- Numeric-only aliases are reserved for list selection and cannot be registered.",
1927
+ "",
1928
+ await memoryService.listMacros(),
1929
+ ].join("\n");
1930
+ }
1714
1931
  function parsePlanReinforcementCount(raw) {
1715
1932
  const value = raw?.trim();
1716
1933
  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,6 +14,7 @@ import { BridgeService } from "./services/bridge-service.js";
14
14
  import { BotManagementService } from "./services/bot-management-service.js";
15
15
  import { LocalUiService } from "./services/local-ui-service.js";
16
16
  import { AgentMemoryService } from "./services/agent-memory-service.js";
17
+ import { 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";
@@ -56,6 +57,7 @@ async function main() {
56
57
  const bridge = new BridgeService(store, adapters, config.defaultWorkspace, config.workspaceRoot, isProviderInstalled, config.defaultMode, config.codexSandboxMode);
57
58
  const botManagement = new BotManagementService(config.dataDir, config.botRestartServiceName, config.botRestartHelperPath, botPollingState);
58
59
  startArtifactCleanupSchedule(new AgentMemoryService(config.dataDir));
60
+ startWorkspaceCleanupSchedule(new WorkspaceCleanupService(config.dataDir, config.workspaceRoot));
59
61
  if (config.localUiEnabled) {
60
62
  const localUi = new LocalUiService(bridge, config.localUiHost, config.localUiPort);
61
63
  await localUi.start()
@@ -88,6 +90,30 @@ async function main() {
88
90
  });
89
91
  await startManualPollingScheduler(bots);
90
92
  }
93
+ function startWorkspaceCleanupSchedule(workspaceCleanupService) {
94
+ if (!config.workspaceCleanupEnabled) {
95
+ console.log("Workspace cleanup schedule is disabled.");
96
+ return;
97
+ }
98
+ const run = async () => {
99
+ try {
100
+ const result = await workspaceCleanupService.cleanupOrphanWorkspaces();
101
+ console.log(`[workspace-cleanup] ${result}`);
102
+ }
103
+ catch (error) {
104
+ console.error("[workspace-cleanup] failed:", error);
105
+ }
106
+ };
107
+ console.log(`Workspace cleanup schedule enabled: interval=${config.workspaceCleanupIntervalMs}ms`);
108
+ const initial = setTimeout(() => {
109
+ void run();
110
+ }, 120_000);
111
+ initial.unref();
112
+ const interval = setInterval(() => {
113
+ void run();
114
+ }, config.workspaceCleanupIntervalMs);
115
+ interval.unref();
116
+ }
91
117
  function startArtifactCleanupSchedule(memoryService) {
92
118
  if (!config.artifactCleanupEnabled) {
93
119
  console.log("Artifact cleanup schedule is disabled.");
@@ -9,6 +9,7 @@ export class AgentMemoryService {
9
9
  artifactsPath;
10
10
  secretsPath;
11
11
  docsPath;
12
+ macrosPath;
12
13
  telegramUploadsDir;
13
14
  constructor(dataDir) {
14
15
  this.dataDir = dataDir;
@@ -16,6 +17,7 @@ export class AgentMemoryService {
16
17
  this.artifactsPath = path.join(this.rootDir, "artifacts.json");
17
18
  this.secretsPath = path.join(this.rootDir, "secrets.json");
18
19
  this.docsPath = path.join(this.rootDir, "docs-index.json");
20
+ this.macrosPath = path.join(this.rootDir, "macros.json");
19
21
  this.telegramUploadsDir = path.join(dataDir, "uploads", "telegram");
20
22
  }
21
23
  async recordInstruction(session, instruction) {
@@ -238,6 +240,57 @@ export class AgentMemoryService {
238
240
  await this.writeSecrets(secrets);
239
241
  return existed;
240
242
  }
243
+ async setMacro(alias, prompt) {
244
+ const normalizedAlias = this.normalizeMacroAlias(alias);
245
+ const normalizedPrompt = prompt.trim();
246
+ if (!normalizedPrompt) {
247
+ throw new Error("Macro prompt must not be empty.");
248
+ }
249
+ const macros = await this.readMacros();
250
+ const now = new Date().toISOString();
251
+ const record = {
252
+ alias: normalizedAlias,
253
+ prompt: normalizedPrompt,
254
+ createdAt: macros[normalizedAlias]?.createdAt ?? now,
255
+ updatedAt: now,
256
+ };
257
+ macros[normalizedAlias] = record;
258
+ await this.writeJson(this.macrosPath, macros);
259
+ return record;
260
+ }
261
+ async removeMacro(alias) {
262
+ const normalizedAlias = this.normalizeMacroAlias(alias);
263
+ const macros = await this.readMacros();
264
+ const existed = Boolean(macros[normalizedAlias]);
265
+ delete macros[normalizedAlias];
266
+ await this.writeJson(this.macrosPath, macros);
267
+ return existed;
268
+ }
269
+ async getMacro(aliasOrIndex) {
270
+ const macros = Object.values(await this.readMacros())
271
+ .sort((left, right) => left.alias.localeCompare(right.alias, "ko"));
272
+ const trimmed = aliasOrIndex.trim();
273
+ if (/^[0-9]+$/.test(trimmed)) {
274
+ const index = Number.parseInt(trimmed, 10);
275
+ return index >= 1 && index <= macros.length ? macros[index - 1] : undefined;
276
+ }
277
+ const normalizedAlias = this.normalizeMacroAlias(trimmed);
278
+ return macros.find((macro) => macro.alias === normalizedAlias);
279
+ }
280
+ async listMacros() {
281
+ const macros = Object.values(await this.readMacros())
282
+ .sort((left, right) => left.alias.localeCompare(right.alias, "ko"));
283
+ if (macros.length === 0) {
284
+ return "No macros are stored.";
285
+ }
286
+ return [
287
+ `Macros (${macros.length})`,
288
+ ...macros.map((macro, index) => {
289
+ const preview = macro.prompt.replace(/\s+/g, " ").slice(0, 120);
290
+ return `${index + 1}. ${macro.alias}\n ${preview}${macro.prompt.length > preview.length ? "..." : ""}`;
291
+ }),
292
+ ].join("\n");
293
+ }
241
294
  async getSecret(key) {
242
295
  this.assertSecretKey(key);
243
296
  const secrets = await this.readSecrets();
@@ -646,6 +699,19 @@ export class AgentMemoryService {
646
699
  async readDocs() {
647
700
  return this.readJson(this.docsPath, {});
648
701
  }
702
+ async readMacros() {
703
+ return this.readJson(this.macrosPath, {});
704
+ }
705
+ normalizeMacroAlias(alias) {
706
+ const normalized = alias.trim();
707
+ if (/^[0-9]+$/.test(normalized)) {
708
+ throw new Error("Macro alias cannot be numeric. Numeric values are reserved for list selection.");
709
+ }
710
+ if (!/^[a-z0-9가-힣._-]{1,80}$/i.test(normalized)) {
711
+ throw new Error("Macro alias must be 1-80 chars and may contain letters, numbers, Korean, dot, underscore, or dash.");
712
+ }
713
+ return normalized;
714
+ }
649
715
  normalizeKeyword(keyword) {
650
716
  const normalized = keyword.trim().toLowerCase();
651
717
  if (!/^[a-z0-9가-힣._-]{1,80}$/i.test(normalized)) {
@@ -354,7 +354,8 @@ export class BridgeService {
354
354
  formatResponses(responses) {
355
355
  return responses.map((response) => {
356
356
  const sessionLabel = response.publicSessionId ?? response.sessionId;
357
- const header = `[${response.provider.toUpperCase()} | ${sessionLabel}]`;
357
+ const modelLabel = response.model?.trim() || this.defaultModelFor(response.provider);
358
+ const header = `[${response.provider.toUpperCase()} | ${modelLabel} | ${sessionLabel}]`;
358
359
  return `${header}\n${response.output}`;
359
360
  });
360
361
  }
@@ -520,6 +521,7 @@ export class BridgeService {
520
521
  responses.push({
521
522
  ...response,
522
523
  publicSessionId: session.publicId,
524
+ model: providerSession.model ?? this.defaultModelFor(provider),
523
525
  });
524
526
  }
525
527
  return responses;
@@ -0,0 +1,150 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const PRESERVED_WORKSPACE_TODO_FILES = new Set(["TODO.md", "todo.md", "todo.json"]);
4
+ export class WorkspaceCleanupService {
5
+ dataDir;
6
+ workspaceRoot;
7
+ constructor(dataDir, workspaceRoot) {
8
+ this.dataDir = dataDir;
9
+ this.workspaceRoot = workspaceRoot;
10
+ }
11
+ async cleanupOrphanWorkspaces() {
12
+ const referenced = await this.referencedWorkspaceNames();
13
+ const entries = await fs.readdir(this.workspaceRoot, { withFileTypes: true }).catch(() => []);
14
+ const result = { removedPaths: 0, removedBytes: 0, skipped: 0, messages: [] };
15
+ for (const entry of entries) {
16
+ if (!entry.isDirectory()) {
17
+ continue;
18
+ }
19
+ if (referenced.has(entry.name)) {
20
+ continue;
21
+ }
22
+ const target = path.join(this.workspaceRoot, entry.name);
23
+ const bytes = await this.directorySize(target);
24
+ await fs.rm(target, { recursive: true, force: true });
25
+ result.removedPaths += 1;
26
+ result.removedBytes += bytes;
27
+ result.messages.push(`${entry.name} (${formatBytes(bytes)})`);
28
+ }
29
+ return [
30
+ "Workspace orphan cleanup finished.",
31
+ `removed=${result.removedPaths}`,
32
+ `freed=${formatBytes(result.removedBytes)}`,
33
+ result.messages.length > 0 ? `items=${result.messages.join(", ")}` : "items=none",
34
+ ].join(" ");
35
+ }
36
+ async cleanupSessionWorkspace(session) {
37
+ if (!this.isManagedWorkspace(session.workspace)) {
38
+ return [
39
+ `Workspace cleanup skipped for ${session.publicId}.`,
40
+ "The current workspace is not managed by RemoteAgent.",
41
+ `workspace=${session.workspace}`,
42
+ ].join("\n");
43
+ }
44
+ const result = { removedPaths: 0, removedBytes: 0, skipped: 0, messages: [] };
45
+ await this.cleanupWorkspaceContents(session.workspace, result);
46
+ return [
47
+ `Workspace cleanup finished for ${session.publicId}.`,
48
+ `workspace=${session.workspace}`,
49
+ `preservedSessionMemory=${path.join(this.dataDir, "managed", "sessions", session.publicId)}`,
50
+ `removed=${result.removedPaths}`,
51
+ `freed=${formatBytes(result.removedBytes)}`,
52
+ `skipped=${result.skipped}`,
53
+ result.messages.length > 0
54
+ ? ["removedItems:", ...result.messages.slice(0, 30).map((item) => `- ${item}`)].join("\n")
55
+ : "removedItems: none",
56
+ ].join("\n");
57
+ }
58
+ async cleanupWorkspaceContents(root, result) {
59
+ const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
60
+ for (const entry of entries) {
61
+ const entryPath = path.join(root, entry.name);
62
+ if (entry.isFile() && PRESERVED_WORKSPACE_TODO_FILES.has(entry.name)) {
63
+ result.skipped += 1;
64
+ continue;
65
+ }
66
+ await this.removePath(entryPath, result);
67
+ }
68
+ }
69
+ async removePath(target, result) {
70
+ const bytes = await this.pathSize(target);
71
+ await fs.rm(target, { recursive: true, force: true });
72
+ result.removedPaths += 1;
73
+ result.removedBytes += bytes;
74
+ result.messages.push(`${path.relative(this.workspaceRoot, target)} (${formatBytes(bytes)})`);
75
+ }
76
+ async referencedWorkspaceNames() {
77
+ const state = await this.readState();
78
+ const referenced = new Set();
79
+ const root = path.resolve(this.workspaceRoot);
80
+ for (const session of Object.values(state.sessions ?? {})) {
81
+ const workspace = session.workspace ? path.resolve(session.workspace) : "";
82
+ if (!workspace || !this.isPathInside(root, workspace)) {
83
+ continue;
84
+ }
85
+ referenced.add(path.basename(workspace));
86
+ }
87
+ return referenced;
88
+ }
89
+ async readState() {
90
+ const statePath = path.join(this.dataDir, "state.json");
91
+ const raw = await fs.readFile(statePath, "utf8").catch((error) => {
92
+ throw new Error(`state unavailable, refusing workspace cleanup: ${error.message}`);
93
+ });
94
+ const parsed = JSON.parse(raw);
95
+ if (!parsed || typeof parsed !== "object" || !parsed.sessions || typeof parsed.sessions !== "object") {
96
+ throw new Error(`state unavailable, refusing workspace cleanup: invalid state at ${statePath}`);
97
+ }
98
+ return parsed;
99
+ }
100
+ isManagedWorkspace(workspace) {
101
+ const root = path.resolve(this.workspaceRoot);
102
+ const resolved = path.resolve(workspace);
103
+ return this.isPathInside(root, resolved) && resolved !== root;
104
+ }
105
+ isPathInside(parent, child) {
106
+ const relative = path.relative(parent, child);
107
+ return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
108
+ }
109
+ async pathSize(target) {
110
+ const stat = await fs.stat(target).catch(() => undefined);
111
+ if (!stat) {
112
+ return 0;
113
+ }
114
+ if (stat.isFile()) {
115
+ return stat.size;
116
+ }
117
+ if (stat.isDirectory()) {
118
+ return this.directorySize(target);
119
+ }
120
+ return 0;
121
+ }
122
+ async directorySize(directory) {
123
+ const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
124
+ let total = 0;
125
+ for (const entry of entries) {
126
+ const entryPath = path.join(directory, entry.name);
127
+ const stat = await fs.stat(entryPath).catch(() => undefined);
128
+ if (!stat) {
129
+ continue;
130
+ }
131
+ if (stat.isFile()) {
132
+ total += stat.size;
133
+ }
134
+ else if (stat.isDirectory()) {
135
+ total += await this.directorySize(entryPath);
136
+ }
137
+ }
138
+ return total;
139
+ }
140
+ }
141
+ function formatBytes(bytes) {
142
+ const units = ["B", "KB", "MB", "GB"];
143
+ let value = bytes;
144
+ let unitIndex = 0;
145
+ while (value >= 1024 && unitIndex < units.length - 1) {
146
+ value /= 1024;
147
+ unitIndex += 1;
148
+ }
149
+ return `${unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)}${units[unitIndex]}`;
150
+ }
@@ -13,11 +13,13 @@ export const TELEGRAM_COMMAND_MENU = [
13
13
  { command: "option", description: "Show or change runtime options" },
14
14
  { command: "secret", description: "Store or manage hidden secret values" },
15
15
  { command: "docs", description: "Pin or find session documents" },
16
+ { command: "macro", description: "Save or run reusable instructions" },
16
17
  { command: "model", description: "Show or change provider model" },
17
18
  { command: "stop", description: "Stop active work and clear queued messages" },
18
19
  { command: "sandbox", description: "Set Codex sandbox mode" },
19
20
  { command: "batch", description: "Collect and send a multi-message batch" },
20
21
  { command: "artifacts", description: "List or clean uploaded artifacts" },
22
+ { command: "cleanup", description: "Clean current session workspace" },
21
23
  { command: "bots", description: "List configured Telegram bots" },
22
24
  { command: "bot", description: "Manage Telegram bots" },
23
25
  { command: "install", description: "Install or update Codex or Claude" },
@@ -122,6 +122,75 @@ Check the lock owner:
122
122
  cat /home/au2223/.remoteagent/remoteagent.lock
123
123
  ```
124
124
 
125
+ ## Disk maintenance
126
+
127
+ RemoteAgent disk growth usually comes from Docker build cache, Docker volumes, Codex session logs, managed workspaces, Telegram uploads, and temporary build artifacts.
128
+
129
+ Use one script for repeatable checks and conservative cleanup:
130
+
131
+ ```bash
132
+ npm run maintenance:disk -- report
133
+ ```
134
+
135
+ Run the safe cleanup path:
136
+
137
+ ```bash
138
+ npm run maintenance:disk -- prune-safe
139
+ ```
140
+
141
+ `prune-safe` performs only these actions:
142
+
143
+ - `docker builder prune -f`
144
+ - remove old `/tmp/remoteagent-codex-*`, `/tmp/remoteagent-claude-*`, and `/tmp/appback-*` directories older than 2 days
145
+ - remove managed workspace directories under `WORKSPACE_ROOT` only when they are not referenced by RemoteAgent `state.json`
146
+
147
+ Clean only orphan managed workspaces:
148
+
149
+ ```bash
150
+ npm run maintenance:disk -- prune-workspaces
151
+ ```
152
+
153
+ RemoteAgent also runs conservative workspace cleanup on a schedule when enabled:
154
+
155
+ ```bash
156
+ WORKSPACE_CLEANUP_ENABLED=true
157
+ WORKSPACE_CLEANUP_INTERVAL_MS=86400000
158
+ ```
159
+
160
+ Scheduled workspace cleanup only removes managed workspace directories under `WORKSPACE_ROOT` when they are not referenced by RemoteAgent `state.json`.
161
+ If `state.json` is missing or invalid, workspace cleanup refuses to run.
162
+
163
+ Clean the current chat session workspace manually from Telegram:
164
+
165
+ ```text
166
+ /cleanup
167
+ ```
168
+
169
+ `/cleanup` does not delete the session workspace directory itself. It removes the contents of the current managed workspace while preserving RemoteAgent's session todo/state/history under `~/.remoteagent/managed/sessions/<session>`. If a top-level `TODO.md`, `todo.md`, or `todo.json` exists inside the workspace, it is also preserved. The command refuses non-RemoteAgent-managed workspaces.
170
+
171
+ Archive old Codex session logs explicitly:
172
+
173
+ ```bash
174
+ npm run maintenance:disk -- prune-codex-sessions 45
175
+ ```
176
+
177
+ This creates an archive under `~/.codex/session-archive/` and removes the archived jsonl files from `~/.codex/sessions`.
178
+ Use this only when old Codex resume history is no longer needed.
179
+
180
+ For server 30, run the installed package script through npm:
181
+
182
+ ```bash
183
+ ssh au2223@192.168.0.30 'export PATH="/home/au2223/.local/bin:/home/au2223/.nvm/versions/node/v22.22.0/bin:$PATH"; npm explore -g appback-remoteagent -- npm run maintenance:disk -- report'
184
+ ssh au2223@192.168.0.30 'export PATH="/home/au2223/.local/bin:/home/au2223/.nvm/versions/node/v22.22.0/bin:$PATH"; npm explore -g appback-remoteagent -- npm run maintenance:disk -- prune-safe'
185
+ ```
186
+
187
+ For server 26:
188
+
189
+ ```bash
190
+ ssh ospadmin@192.168.0.26 'export PATH="$HOME/.local/bin:$PATH"; npm explore -g appback-remoteagent -- npm run maintenance:disk -- report'
191
+ ssh ospadmin@192.168.0.26 'export PATH="$HOME/.local/bin:$PATH"; npm explore -g appback-remoteagent -- npm run maintenance:disk -- prune-safe'
192
+ ```
193
+
125
194
  ## Git workflow
126
195
 
127
196
  The intended workflow is:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.15.6",
3
+ "version": "0.16.0",
4
4
  "description": "Personal installable session server for continuing local AI work across PC and Telegram",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,8 @@
23
23
  "prepublishOnly": "node scripts/prepublish-guard.mjs",
24
24
  "release:version": "bash scripts/release-version.sh",
25
25
  "release:publish": "bash scripts/release-publish.sh",
26
- "release:deploy": "bash scripts/release-deploy.sh"
26
+ "release:deploy": "bash scripts/release-deploy.sh",
27
+ "maintenance:disk": "bash scripts/disk-maintenance.sh"
27
28
  },
28
29
  "dependencies": {
29
30
  "dotenv": "^16.6.1",
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ usage() {
5
+ cat >&2 <<'USAGE'
6
+ Usage: scripts/disk-maintenance.sh <report|prune-safe|prune-workspaces|prune-codex-sessions> [days]
7
+
8
+ Examples:
9
+ scripts/disk-maintenance.sh report
10
+ scripts/disk-maintenance.sh prune-safe
11
+ scripts/disk-maintenance.sh prune-workspaces
12
+ scripts/disk-maintenance.sh prune-codex-sessions 45
13
+
14
+ Notes:
15
+ report Prints disk, Docker, workspace, cache, and large-file usage.
16
+ prune-safe Removes Docker build cache, old RemoteAgent temp dirs, and orphan managed workspaces only.
17
+ prune-workspaces Removes only managed workspace directories not referenced by RemoteAgent state.
18
+ prune-codex-sessions <days>
19
+ Archives Codex session jsonl files older than <days> into ~/.codex/session-archive.
20
+ This can break resume for archived old sessions, so it must be explicit.
21
+ USAGE
22
+ }
23
+
24
+ ACTION="${1:-}"
25
+ RETENTION_DAYS="${2:-}"
26
+ DATA_DIR="${DATA_DIR:-$HOME/.remoteagent}"
27
+ WORKSPACE_ROOT="${WORKSPACE_ROOT:-$HOME/workspaces/remoteagent}"
28
+ CODEX_SESSIONS_DIR="${CODEX_SESSIONS_DIR:-$HOME/.codex/sessions}"
29
+ CODEX_ARCHIVE_DIR="${CODEX_ARCHIVE_DIR:-$HOME/.codex/session-archive}"
30
+
31
+ if [[ -z "$ACTION" ]]; then
32
+ usage
33
+ exit 1
34
+ fi
35
+
36
+ require_integer_days() {
37
+ local value="$1"
38
+ if [[ ! "$value" =~ ^[0-9]+$ ]] || [[ "$value" -lt 1 ]]; then
39
+ echo "Retention days must be a positive integer." >&2
40
+ exit 1
41
+ fi
42
+ }
43
+
44
+ print_section() {
45
+ printf '\n== %s ==\n' "$1"
46
+ }
47
+
48
+ docker_available() {
49
+ command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
50
+ }
51
+
52
+ report() {
53
+ print_section "filesystem"
54
+ df -hT /
55
+
56
+ print_section "top level"
57
+ sudo -n du -xhd1 / 2>/dev/null | sort -h | tail -30 || du -xhd1 "$HOME" 2>/dev/null | sort -h | tail -30 || true
58
+
59
+ print_section "home"
60
+ du -xhd1 "$HOME" 2>/dev/null | sort -h | tail -50 || true
61
+
62
+ if [[ -d "$WORKSPACE_ROOT" ]]; then
63
+ print_section "remoteagent workspaces"
64
+ du -xhd1 "$WORKSPACE_ROOT" 2>/dev/null | sort -h | tail -80 || true
65
+ print_section "orphan managed workspaces"
66
+ orphan_workspaces dry-run
67
+ fi
68
+
69
+ if [[ -d "$CODEX_SESSIONS_DIR" ]]; then
70
+ print_section "codex sessions"
71
+ du -xhd1 "$HOME/.codex" "$CODEX_SESSIONS_DIR" 2>/dev/null | sort -h | tail -40 || true
72
+ fi
73
+
74
+ print_section "tmp"
75
+ du -xhd1 /tmp 2>/dev/null | sort -h | tail -50 || true
76
+
77
+ if docker_available; then
78
+ print_section "docker system df"
79
+ docker system df || true
80
+ fi
81
+
82
+ print_section "largest files over 200M"
83
+ sudo -n find "$HOME" /var /tmp -xdev -type f -size +200M -printf '%s\t%p\n' 2>/dev/null \
84
+ | sort -n \
85
+ | tail -80 \
86
+ | awk '{size=$1/1024/1024/1024; $1=""; sub(/^\t/, ""); printf "%.2fG\t%s\n", size, $0}' || true
87
+ }
88
+
89
+ orphan_workspaces() {
90
+ local mode="${1:-dry-run}"
91
+ python3 - "$DATA_DIR" "$WORKSPACE_ROOT" "$mode" <<'PY'
92
+ import json
93
+ import os
94
+ import shutil
95
+ import subprocess
96
+ import sys
97
+
98
+ data_dir, workspace_root, mode = sys.argv[1:4]
99
+ state_path = os.path.join(data_dir, "state.json")
100
+
101
+ def size_label(path):
102
+ try:
103
+ return subprocess.check_output(["du", "-sh", path], text=True).split()[0]
104
+ except Exception:
105
+ return "?"
106
+
107
+ if not os.path.isdir(workspace_root):
108
+ print(f"workspace root not found: {workspace_root}")
109
+ sys.exit(0)
110
+
111
+ try:
112
+ with open(state_path, "r", encoding="utf-8") as handle:
113
+ state = json.load(handle)
114
+ except Exception as exc:
115
+ print(f"state unavailable, refusing workspace cleanup: {exc}")
116
+ sys.exit(0 if mode == "dry-run" else 1)
117
+
118
+ referenced = set()
119
+ for session in (state.get("sessions") or {}).values():
120
+ workspace = session.get("workspace") or session.get("workspacePath")
121
+ if isinstance(workspace, str):
122
+ normalized = os.path.abspath(workspace)
123
+ root = os.path.abspath(workspace_root)
124
+ if normalized == root or normalized.startswith(root + os.sep):
125
+ referenced.add(os.path.basename(normalized.rstrip(os.sep)))
126
+
127
+ all_dirs = {
128
+ name for name in os.listdir(workspace_root)
129
+ if os.path.isdir(os.path.join(workspace_root, name))
130
+ }
131
+ orphans = sorted(all_dirs - referenced)
132
+
133
+ print(f"referenced={len(referenced)} all={len(all_dirs)} orphan={len(orphans)}")
134
+ for name in orphans:
135
+ path = os.path.join(workspace_root, name)
136
+ print(f"{size_label(path)}\t{name}")
137
+ if mode == "delete":
138
+ shutil.rmtree(path, ignore_errors=True)
139
+ PY
140
+ }
141
+
142
+ prune_safe() {
143
+ print_section "before"
144
+ df -hT /
145
+
146
+ if docker_available; then
147
+ print_section "docker builder prune"
148
+ docker builder prune -f
149
+ else
150
+ print_section "docker builder prune skipped"
151
+ echo "docker is unavailable or current user cannot access it"
152
+ fi
153
+
154
+ print_section "old temp directories"
155
+ find /tmp -maxdepth 1 -mindepth 1 -type d \
156
+ \( -name 'remoteagent-codex-*' -o -name 'remoteagent-claude-*' -o -name 'appback-*' \) \
157
+ -mtime +2 -print -exec rm -rf {} +
158
+
159
+ print_section "orphan managed workspaces"
160
+ orphan_workspaces delete
161
+
162
+ print_section "after"
163
+ df -hT /
164
+ }
165
+
166
+ prune_codex_sessions() {
167
+ local days="$1"
168
+ require_integer_days "$days"
169
+
170
+ if [[ ! -d "$CODEX_SESSIONS_DIR" ]]; then
171
+ echo "Codex sessions dir not found: $CODEX_SESSIONS_DIR"
172
+ exit 0
173
+ fi
174
+
175
+ mkdir -p "$CODEX_ARCHIVE_DIR"
176
+ local stamp
177
+ stamp="$(date +%Y%m%d-%H%M%S)"
178
+ local list_file archive_file
179
+ list_file="$(mktemp)"
180
+ archive_file="$CODEX_ARCHIVE_DIR/codex-sessions-older-than-${days}d-$stamp.tar.gz"
181
+ find "$CODEX_SESSIONS_DIR" -type f -name '*.jsonl' -mtime +"$days" -print > "$list_file"
182
+
183
+ if [[ ! -s "$list_file" ]]; then
184
+ rm -f "$list_file"
185
+ echo "No Codex session files older than ${days}d."
186
+ exit 0
187
+ fi
188
+
189
+ tar -czf "$archive_file" --files-from "$list_file"
190
+ while IFS= read -r file; do
191
+ rm -f "$file"
192
+ done < "$list_file"
193
+ rm -f "$list_file"
194
+
195
+ find "$CODEX_SESSIONS_DIR" -type d -empty -delete
196
+ echo "Archived old Codex session files to $archive_file"
197
+ }
198
+
199
+ case "$ACTION" in
200
+ report)
201
+ report
202
+ ;;
203
+ prune-safe)
204
+ prune_safe
205
+ ;;
206
+ prune-workspaces)
207
+ orphan_workspaces delete
208
+ ;;
209
+ prune-codex-sessions)
210
+ prune_codex_sessions "${RETENTION_DAYS:-}"
211
+ ;;
212
+ *)
213
+ usage
214
+ exit 1
215
+ ;;
216
+ esac
@@ -39,6 +39,19 @@ deploy_30() {
39
39
  ssh au2223@192.168.0.30 "VERSION=$VERSION bash -s" <<'REMOTE'
40
40
  set -euo pipefail
41
41
  export PATH="/home/au2223/.local/bin:/home/au2223/.nvm/versions/node/v22.22.0/bin:$PATH"
42
+ node - <<'NODE'
43
+ const fs = require("fs");
44
+ const path = "/home/au2223/.remoteagent/bot-polling-state.json";
45
+ const state = JSON.parse(fs.readFileSync(path, "utf8"));
46
+ const running = Object.values(state.bots || {})
47
+ .filter((bot) => Array.isArray(bot.runningSessionIds) && bot.runningSessionIds.length > 0)
48
+ .map((bot) => `${bot.username || bot.botId}: ${bot.runningSessionIds.join(", ")}`);
49
+ if (running.length > 0) {
50
+ console.error("RemoteAgent has active provider work. Retry deploy after it finishes:");
51
+ for (const item of running) console.error(`- ${item}`);
52
+ process.exit(2);
53
+ }
54
+ NODE
42
55
  npm install -g "appback-remoteagent@$VERSION"
43
56
  remoteagent-install
44
57
  sudo -n systemctl restart remoteagent
@@ -53,6 +66,21 @@ deploy_26() {
53
66
  ssh ospadmin@192.168.0.26 "VERSION=$VERSION bash -s" <<'REMOTE'
54
67
  set -euo pipefail
55
68
  export PATH="$HOME/.local/bin:$PATH"
69
+ node - <<'NODE'
70
+ const fs = require("fs");
71
+ const path = `${process.env.HOME}/.remoteagent/bot-polling-state.json`;
72
+ if (fs.existsSync(path)) {
73
+ const state = JSON.parse(fs.readFileSync(path, "utf8"));
74
+ const running = Object.values(state.bots || {})
75
+ .filter((bot) => Array.isArray(bot.runningSessionIds) && bot.runningSessionIds.length > 0)
76
+ .map((bot) => `${bot.username || bot.botId}: ${bot.runningSessionIds.join(", ")}`);
77
+ if (running.length > 0) {
78
+ console.error("RemoteAgent has active provider work. Retry deploy after it finishes:");
79
+ for (const item of running) console.error(`- ${item}`);
80
+ process.exit(2);
81
+ }
82
+ }
83
+ NODE
56
84
  npm install -g "appback-remoteagent@$VERSION"
57
85
  remoteagent-install
58
86
  ~/.remoteagent/stop-remoteagent.sh || true
@@ -62,12 +62,20 @@ process.env.TELEGRAM_EMPTY_RESPONSE_RETRIES = "0";
62
62
  process.env.TELEGRAM_RETRYABLE_ERROR_RETRIES = "0";
63
63
  process.env.LOCAL_UI_ENABLED = "false";
64
64
 
65
- const [{ createBot }, { BridgeService }, { BotManagementService }, { FileStore }, { AgentMemoryService }] = await Promise.all([
65
+ const [
66
+ { createBot },
67
+ { BridgeService },
68
+ { BotManagementService },
69
+ { FileStore },
70
+ { AgentMemoryService },
71
+ { WorkspaceCleanupService },
72
+ ] = await Promise.all([
66
73
  import(path.join(root, "dist", "bot.js")),
67
74
  import(path.join(root, "dist", "services", "bridge-service.js")),
68
75
  import(path.join(root, "dist", "services", "bot-management-service.js")),
69
76
  import(path.join(root, "dist", "store", "file-store.js")),
70
77
  import(path.join(root, "dist", "services", "agent-memory-service.js")),
78
+ import(path.join(root, "dist", "services", "workspace-cleanup-service.js")),
71
79
  ]);
72
80
 
73
81
  const providerCalls = [];
@@ -188,6 +196,59 @@ if (!/^TELEGRAM_UNTAGGED_INTENT_RETRIES=4$/m.test(envText)) {
188
196
  throw new Error(`Option command did not persist untagged intent retry limit to .env: ${envText}`);
189
197
  }
190
198
 
199
+ const sessionWorkspace = session.workspace;
200
+ await fs.mkdir(path.join(sessionWorkspace, "node_modules", "left-pad"), { recursive: true });
201
+ await fs.mkdir(path.join(sessionWorkspace, "src"), { recursive: true });
202
+ await fs.writeFile(path.join(sessionWorkspace, "node_modules", "left-pad", "index.js"), "module.exports = 1;\n", "utf8");
203
+ await fs.writeFile(path.join(sessionWorkspace, "src", "keep.ts"), "export const keep = true;\n", "utf8");
204
+ await fs.writeFile(path.join(sessionWorkspace, "debug.log"), "temporary log\n", "utf8");
205
+ await fs.writeFile(path.join(sessionWorkspace, "TODO.md"), "- keep cleanup notes\n", "utf8");
206
+ await send("/cleanup");
207
+ await fs.access(path.join(sessionWorkspace, "TODO.md"));
208
+ if (await pathExists(path.join(sessionWorkspace, "node_modules"))) {
209
+ throw new Error("/cleanup did not remove node_modules");
210
+ }
211
+ if (await pathExists(path.join(sessionWorkspace, "debug.log"))) {
212
+ throw new Error("/cleanup did not remove log file");
213
+ }
214
+ if (await pathExists(path.join(sessionWorkspace, "src"))) {
215
+ throw new Error("/cleanup did not remove regular workspace contents");
216
+ }
217
+
218
+ const orphanWorkspace = path.join(workspaceRoot, "orphan123");
219
+ const referencedWorkspace = sessionWorkspace;
220
+ await fs.mkdir(orphanWorkspace, { recursive: true });
221
+ await fs.writeFile(path.join(orphanWorkspace, "artifact.tmp"), "orphan\n", "utf8");
222
+ const workspaceCleanup = new WorkspaceCleanupService(dataDir, workspaceRoot);
223
+ const orphanResult = await workspaceCleanup.cleanupOrphanWorkspaces();
224
+ if (!/removed=1/.test(orphanResult)) {
225
+ throw new Error(`Expected one orphan workspace removal, got: ${orphanResult}`);
226
+ }
227
+ if (await pathExists(orphanWorkspace)) {
228
+ throw new Error("Orphan workspace was not removed");
229
+ }
230
+ if (!(await pathExists(referencedWorkspace))) {
231
+ throw new Error("Referenced workspace was removed unexpectedly");
232
+ }
233
+
234
+ const missingStateDataDir = path.join(tmp, "missing-state-data");
235
+ const missingStateWorkspaceRoot = path.join(tmp, "missing-state-workspaces");
236
+ const shouldRemain = path.join(missingStateWorkspaceRoot, "should-remain");
237
+ await fs.mkdir(shouldRemain, { recursive: true });
238
+ const missingStateCleanup = new WorkspaceCleanupService(missingStateDataDir, missingStateWorkspaceRoot);
239
+ let refusedMissingState = false;
240
+ try {
241
+ await missingStateCleanup.cleanupOrphanWorkspaces();
242
+ } catch {
243
+ refusedMissingState = true;
244
+ }
245
+ if (!refusedMissingState) {
246
+ throw new Error("Workspace cleanup should refuse to run when state.json is missing");
247
+ }
248
+ if (!(await pathExists(shouldRemain))) {
249
+ throw new Error("Workspace cleanup removed a workspace when state.json was missing");
250
+ }
251
+
191
252
  const memory = new AgentMemoryService(dataDir);
192
253
  const developmentSession = {
193
254
  ...session,
@@ -258,6 +319,9 @@ if (!calls.some((call) => call.method === "sendMessage" && /Set provider executi
258
319
  if (!calls.some((call) => call.method === "sendMessage" && /Set untagged intent retry limit to 4/.test(call.text))) {
259
320
  throw new Error(`Did not see option intent acknowledgement. Calls: ${JSON.stringify(calls, null, 2)}`);
260
321
  }
322
+ if (!calls.some((call) => call.method === "sendMessage" && /Workspace cleanup finished for S001/.test(call.text))) {
323
+ throw new Error(`Did not see workspace cleanup acknowledgement. Calls: ${JSON.stringify(calls, null, 2)}`);
324
+ }
261
325
  if (calls.some((call) => /미완료 TODO|\/task|새 작업으로 접수/.test(call.text))) {
262
326
  throw new Error(`Task gate language leaked to Telegram replies. Calls: ${JSON.stringify(calls, null, 2)}`);
263
327
  }
@@ -333,7 +397,10 @@ const evidenceCalls = (await fs.readFile(telegramCalls, "utf8"))
333
397
  if (missingEvidenceCalls !== 2) {
334
398
  throw new Error(`Expected missing evidence result to be retried once, got ${missingEvidenceCalls}`);
335
399
  }
336
- if (!evidenceCalls.some((call) => /변경 파일: `src\/example\.ts`/.test(call.text) && /`npm run check` 통과/.test(call.text))) {
400
+ if (!evidenceCalls.some((call) =>
401
+ /변경 파일: (?:`|<code>)src\/example\.ts(?:`|<\/code>)/.test(call.text)
402
+ && /(?:`|<code>)npm run check(?:`|<\/code>) 통과/.test(call.text)
403
+ )) {
337
404
  throw new Error(`Did not see recovered result with concrete evidence. Calls: ${JSON.stringify(evidenceCalls, null, 2)}`);
338
405
  }
339
406
  if (evidenceCalls.some((call) => call.method === "sendMessage" && /^수정 완료했습니다\.$/.test(call.text.trim()))) {
@@ -357,3 +424,7 @@ console.log(JSON.stringify({
357
424
  }, null, 2));
358
425
 
359
426
  process.exit(0);
427
+
428
+ async function pathExists(target) {
429
+ return fs.access(target).then(() => true, () => false);
430
+ }