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 +2 -0
- package/README.md +5 -0
- package/dist/bot.js +367 -19
- package/dist/config.js +2 -0
- package/dist/index.js +30 -37
- package/dist/services/agent-memory-service.js +66 -0
- package/dist/services/workspace-cleanup-service.js +150 -0
- package/dist/telegram-bot-identity.js +36 -0
- package/dist/telegram-command-menu.js +3 -0
- package/docs/OPERATIONS.md +73 -0
- package/docs/RELEASING.md +23 -0
- package/package.json +3 -2
- package/scripts/disk-maintenance.sh +216 -0
- package/scripts/selftest-telegram-update.mjs +183 -2
|
@@ -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" },
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -44,6 +44,10 @@ Current production bot ownership is intentionally split:
|
|
|
44
44
|
Assign each Telegram bot token to one runtime at a time.
|
|
45
45
|
Bot polling conflicts are treated as incidents, not harmless warnings.
|
|
46
46
|
|
|
47
|
+
`TELEGRAM_BOT_TOKENS` and `TELEGRAM_BOT_USERNAMES` are parallel persisted lists.
|
|
48
|
+
At startup, Telegram `getMe` is the preferred identity source, but a temporary DNS or Telegram failure must fall back to the persisted username at the same list position.
|
|
49
|
+
Using a generated `bot_<numeric-id>` identity when a persisted username exists breaks the existing `<username>:<chat-id>` session binding.
|
|
50
|
+
|
|
47
51
|
When a runtime has several configured Telegram bots, polling pressure can become operationally visible.
|
|
48
52
|
RemoteAgent reduces that pressure with rank-based polling intervals instead of deep sleep or a special main bot.
|
|
49
53
|
See [BOT_POLLING_POLICY.md](./BOT_POLLING_POLICY.md).
|
|
@@ -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.
|
|
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
|