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.
@@ -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)) {
@@ -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
+ }
@@ -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
+ }
@@ -13,11 +13,14 @@ 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" },
18
+ { command: "queue", description: "List or remove queued instructions" },
17
19
  { command: "stop", description: "Stop active work and clear queued messages" },
18
20
  { command: "sandbox", description: "Set Codex sandbox mode" },
19
21
  { command: "batch", description: "Collect and send a multi-message batch" },
20
22
  { command: "artifacts", description: "List or clean uploaded artifacts" },
23
+ { command: "cleanup", description: "Clean current session workspace" },
21
24
  { command: "bots", description: "List configured Telegram bots" },
22
25
  { command: "bot", description: "Manage Telegram bots" },
23
26
  { command: "install", description: "Install or update Codex or Claude" },
@@ -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).
@@ -122,6 +126,75 @@ Check the lock owner:
122
126
  cat /home/au2223/.remoteagent/remoteagent.lock
123
127
  ```
124
128
 
129
+ ## Disk maintenance
130
+
131
+ RemoteAgent disk growth usually comes from Docker build cache, Docker volumes, Codex session logs, managed workspaces, Telegram uploads, and temporary build artifacts.
132
+
133
+ Use one script for repeatable checks and conservative cleanup:
134
+
135
+ ```bash
136
+ npm run maintenance:disk -- report
137
+ ```
138
+
139
+ Run the safe cleanup path:
140
+
141
+ ```bash
142
+ npm run maintenance:disk -- prune-safe
143
+ ```
144
+
145
+ `prune-safe` performs only these actions:
146
+
147
+ - `docker builder prune -f`
148
+ - remove old `/tmp/remoteagent-codex-*`, `/tmp/remoteagent-claude-*`, and `/tmp/appback-*` directories older than 2 days
149
+ - remove managed workspace directories under `WORKSPACE_ROOT` only when they are not referenced by RemoteAgent `state.json`
150
+
151
+ Clean only orphan managed workspaces:
152
+
153
+ ```bash
154
+ npm run maintenance:disk -- prune-workspaces
155
+ ```
156
+
157
+ RemoteAgent also runs conservative workspace cleanup on a schedule when enabled:
158
+
159
+ ```bash
160
+ WORKSPACE_CLEANUP_ENABLED=true
161
+ WORKSPACE_CLEANUP_INTERVAL_MS=86400000
162
+ ```
163
+
164
+ Scheduled workspace cleanup only removes managed workspace directories under `WORKSPACE_ROOT` when they are not referenced by RemoteAgent `state.json`.
165
+ If `state.json` is missing or invalid, workspace cleanup refuses to run.
166
+
167
+ Clean the current chat session workspace manually from Telegram:
168
+
169
+ ```text
170
+ /cleanup
171
+ ```
172
+
173
+ `/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.
174
+
175
+ Archive old Codex session logs explicitly:
176
+
177
+ ```bash
178
+ npm run maintenance:disk -- prune-codex-sessions 45
179
+ ```
180
+
181
+ This creates an archive under `~/.codex/session-archive/` and removes the archived jsonl files from `~/.codex/sessions`.
182
+ Use this only when old Codex resume history is no longer needed.
183
+
184
+ For server 30, run the installed package script through npm:
185
+
186
+ ```bash
187
+ 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'
188
+ 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'
189
+ ```
190
+
191
+ For server 26:
192
+
193
+ ```bash
194
+ ssh ospadmin@192.168.0.26 'export PATH="$HOME/.local/bin:$PATH"; npm explore -g appback-remoteagent -- npm run maintenance:disk -- report'
195
+ ssh ospadmin@192.168.0.26 'export PATH="$HOME/.local/bin:$PATH"; npm explore -g appback-remoteagent -- npm run maintenance:disk -- prune-safe'
196
+ ```
197
+
125
198
  ## Git workflow
126
199
 
127
200
  The intended workflow is:
package/docs/RELEASING.md CHANGED
@@ -174,3 +174,26 @@ 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
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.15.7",
3
+ "version": "0.17.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