alvin-bot 4.16.1 โ†’ 4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.17.0] โ€” 2026-04-20
6
+
7
+ ### ๐Ÿ›ก๏ธ Hardening: long-running stability audit + leak fixes
8
+
9
+ Ran a full audit of leak/stability hazards for 24/7 operation. Fixed the critical findings and added a disk-cleanup service so the bot stays lean over months of uptime.
10
+
11
+ **Fixes:**
12
+ - **WhatsApp event-listener leak on reconnect** (`src/platforms/whatsapp.ts`): Before every new socket, the previous socket's listeners are now removed and the old socket is ended. Without this, every reconnect stacked new listeners on top of old ones โ€” causing memory growth and duplicate message processing after long sessions.
13
+ - **CDP file-descriptor leak** (`src/services/cdp-bootstrap.ts`): The log-file fd passed to the detached Chromium spawn is now closed in the parent after the child inherits it. Previously leaked one fd per browser bootstrap.
14
+ - **Heartbeat + auto-update timers now `.unref()`'d** and explicitly stopped in the shutdown handler. Prevents timers from keeping the process alive during graceful exit.
15
+
16
+ ### ๐Ÿงน Feature: disk-cleanup service
17
+
18
+ New service (`src/services/disk-cleanup.ts`) that runs automatically once a day. Deletes transient files that grow without bound on long-running installs:
19
+ - Bot log rotation (>100 MB by default)
20
+ - Browser screenshots (>30 days)
21
+ - Subagent output streams (>30 days)
22
+ - `/tmp/alvin-bot/` media (>7 days)
23
+ - WhatsApp media cache (>30 days)
24
+ - CDP log file
25
+
26
+ **NEVER touched:** memory, assets, workspaces, cron-jobs, .env, session-store, delivery-queue. Memory is protected.
27
+
28
+ **Configuration via env:** `CLEANUP_LOG_MAX_MB`, `CLEANUP_SCREENSHOTS_DAYS`, `CLEANUP_SUBAGENTS_DAYS`, `CLEANUP_TMP_DAYS`, `CLEANUP_WA_MEDIA_DAYS`. Set any to `0` to disable that category.
29
+
30
+ **Telegram command:**
31
+ - `/cleanup` โ€” show current policy + protected paths
32
+ - `/cleanup run` โ€” trigger manual pass, get stats back
33
+
5
34
  ## [4.16.1] โ€” 2026-04-20
6
35
 
7
36
  ### ๐Ÿ†• Feature: /update shows release highlights
@@ -28,6 +28,7 @@ import { getWebPort } from "../web/server.js";
28
28
  import { getUsageSummary, getAllRateLimits, formatTokens } from "../services/usage-tracker.js";
29
29
  import { runUpdate, getAutoUpdate, setAutoUpdate, startAutoUpdateLoop } from "../services/updater.js";
30
30
  import { getReleaseHighlights } from "../services/release-highlights.js";
31
+ import { runCleanup, getCleanupPolicy } from "../services/disk-cleanup.js";
31
32
  import { getHealthStatus, isFailedOver } from "../services/heartbeat.js";
32
33
  import { t, LOCALE_NAMES, LOCALE_FLAGS } from "../i18n.js";
33
34
  // Kick off auto-update loop on module load if the persistent flag is set.
@@ -1919,6 +1920,36 @@ export function registerCommands(bot) {
1919
1920
  await ctx.reply(`${t("bot.autoupdate.statusLabel", lang)} *${status ? "ON" : "OFF"}*\n\n${t("bot.autoupdate.commandsLabel", lang)}\n\`/autoupdate on\`\n\`/autoupdate off\``, { parse_mode: "Markdown" });
1920
1921
  }
1921
1922
  });
1923
+ // /cleanup โ€” trigger disk cleanup manually, or show current policy.
1924
+ // /cleanup โ†’ show policy
1925
+ // /cleanup run โ†’ run a cleanup pass and report what was deleted
1926
+ bot.command("cleanup", async (ctx) => {
1927
+ const arg = (ctx.match || "").trim().toLowerCase();
1928
+ if (arg === "run" || arg === "now") {
1929
+ await ctx.reply("๐Ÿงน Running disk cleanup...");
1930
+ const r = await runCleanup();
1931
+ const bytes = r.bytesReclaimed;
1932
+ const human = bytes < 1024 * 1024
1933
+ ? `${(bytes / 1024).toFixed(1)} KB`
1934
+ : bytes < 1024 * 1024 * 1024
1935
+ ? `${(bytes / 1024 / 1024).toFixed(1)} MB`
1936
+ : `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
1937
+ const errLine = r.errors.length > 0 ? `\nโš ๏ธ ${r.errors.length} error(s)` : "";
1938
+ await ctx.reply(`โœ… Cleanup done\nโ€ข Files deleted: ${r.filesDeleted}\nโ€ข Logs rotated: ${r.logsRotated}\nโ€ข Reclaimed: ${human}${errLine}`);
1939
+ }
1940
+ else {
1941
+ const p = getCleanupPolicy();
1942
+ await ctx.reply(`๐Ÿงน *Cleanup policy*\n` +
1943
+ `โ€ข Log rotation: >${p.logMaxSizeMb} MB\n` +
1944
+ `โ€ข Screenshots: >${p.screenshotsMaxAgeDays} days\n` +
1945
+ `โ€ข Subagent outputs: >${p.subagentsMaxAgeDays} days\n` +
1946
+ `โ€ข /tmp/alvin-bot: >${p.tmpMaxAgeDays} days\n` +
1947
+ `โ€ข WhatsApp media: >${p.waMediaMaxAgeDays} days\n\n` +
1948
+ `Memory, assets, workspaces, cron jobs are NEVER touched.\n\n` +
1949
+ `Configure via env: \`CLEANUP_LOG_MAX_MB\`, \`CLEANUP_SCREENSHOTS_DAYS\`, \`CLEANUP_SUBAGENTS_DAYS\`, \`CLEANUP_TMP_DAYS\`, \`CLEANUP_WA_MEDIA_DAYS\`\n\n` +
1950
+ `Run manually: \`/cleanup run\``, { parse_mode: "Markdown" });
1951
+ }
1952
+ });
1922
1953
  // โ”€โ”€ /sub-agents โ€” manage background subagents (cron jobs + manual spawns) โ”€โ”€
1923
1954
  //
1924
1955
  // /sub-agents โ†’ show current config + running agents
package/dist/index.js CHANGED
@@ -155,7 +155,9 @@ import { startSessionCleanup, stopSessionCleanup, attachPersistHook } from "./se
155
155
  import { loadPersistedSessions, flushSessions, schedulePersist, } from "./services/session-persistence.js";
156
156
  import { processQueue, cleanupQueue, setSenders, enqueue } from "./services/delivery-queue.js";
157
157
  import { discoverTools } from "./services/tool-discovery.js";
158
- import { startHeartbeat } from "./services/heartbeat.js";
158
+ import { startHeartbeat, stopHeartbeat } from "./services/heartbeat.js";
159
+ import { stopAutoUpdateLoop } from "./services/updater.js";
160
+ import { startCleanupLoop, stopCleanupLoop } from "./services/disk-cleanup.js";
159
161
  import { initEmbeddings } from "./services/embeddings.js";
160
162
  import { loadSkills } from "./services/skills.js";
161
163
  import { loadHooks } from "./services/hooks.js";
@@ -335,6 +337,9 @@ const shutdown = async () => {
335
337
  stopAsyncAgentWatcher();
336
338
  stopSessionCleanup();
337
339
  stopWorkspaceWatcher();
340
+ stopHeartbeat();
341
+ stopAutoUpdateLoop();
342
+ stopCleanupLoop();
338
343
  // v4.11.0 โ€” Final immediate flush of in-memory sessions to disk before exit.
339
344
  // The debounced timer might be pending; flushSessions() cancels it and writes
340
345
  // synchronously so the next boot can rehydrate the latest state.
@@ -612,5 +617,6 @@ else {
612
617
  // Start heartbeat monitor even without Telegram
613
618
  startHeartbeat();
614
619
  startWatchdog();
620
+ startCleanupLoop();
615
621
  initEmbeddings().catch(() => { });
616
622
  }
@@ -252,6 +252,19 @@ export class WhatsAppAdapter {
252
252
  fs.mkdirSync(authDir, { recursive: true });
253
253
  const { state, saveCreds } = await useMultiFileAuthState(authDir);
254
254
  const { version } = await fetchLatestBaileysVersion();
255
+ // Cleanup previous socket (reconnect path) โ€” without this, every reconnect
256
+ // stacks a new set of listeners on baileys' EventEmitter, so messages get
257
+ // processed N times after N reconnects and closures leak.
258
+ if (this.sock) {
259
+ try {
260
+ this.sock.ev?.removeAllListeners?.();
261
+ this.sock.end?.(new Error("reconnect"));
262
+ }
263
+ catch {
264
+ // best-effort cleanup โ€” ignore failures from already-dead socket
265
+ }
266
+ this.sock = null;
267
+ }
255
268
  const sock = makeWASocket({
256
269
  version,
257
270
  auth: {
@@ -196,6 +196,12 @@ export async function ensureRunning(opts = {}) {
196
196
  detached: true,
197
197
  });
198
198
  child.unref();
199
+ // The child inherits its own copy of the fd. Close our copy so the parent
200
+ // process doesn't leak a file descriptor per Chromium bootstrap.
201
+ try {
202
+ fs.closeSync(logStream);
203
+ }
204
+ catch { /* already closed โ€” fine */ }
199
205
  if (!child.pid) {
200
206
  throw new Error("Failed to spawn Chromium (no PID)");
201
207
  }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Disk Cleanup Service โ€” periodic cleanup of transient bot files.
3
+ *
4
+ * Targets files that are SAFE to delete (logs, temp screenshots, browser
5
+ * artifacts, old subagent streams) and leaves critical data alone
6
+ * (memory, assets, workspaces, cron-jobs, .env, session-store).
7
+ *
8
+ * Strategy:
9
+ * - Each path has a max age (days) OR a max size (MB, with rotation)
10
+ * - Defaults are conservative: keep 30 days of artifacts, rotate logs >100MB
11
+ * - All knobs overridable via env (CLEANUP_* vars) and via /cleanup set <key>
12
+ * - Runs once at boot + every 24h thereafter, unref'd so it doesn't
13
+ * prevent shutdown
14
+ *
15
+ * NEVER cleaned:
16
+ * ~/.alvin-bot/memory/ (daily logs, long-term memory)
17
+ * ~/.alvin-bot/assets/ (user-supplied files)
18
+ * ~/.alvin-bot/workspaces/ (user configuration)
19
+ * ~/.alvin-bot/cron-jobs.json (scheduled tasks)
20
+ * ~/.alvin-bot/.env (secrets)
21
+ * ~/.alvin-bot/session-store.json (resume tokens)
22
+ * ~/.alvin-bot/delivery-queue.json
23
+ * ~/.alvin-bot/standing-orders
24
+ * ~/.alvin-bot/auto-update.flag
25
+ */
26
+ import fs from "fs";
27
+ import path from "path";
28
+ import os from "os";
29
+ import { DATA_DIR } from "../paths.js";
30
+ const DEFAULT_POLICY = {
31
+ logMaxSizeMb: parseInt(process.env.CLEANUP_LOG_MAX_MB || "100", 10),
32
+ screenshotsMaxAgeDays: parseInt(process.env.CLEANUP_SCREENSHOTS_DAYS || "30", 10),
33
+ subagentsMaxAgeDays: parseInt(process.env.CLEANUP_SUBAGENTS_DAYS || "30", 10),
34
+ tmpMaxAgeDays: parseInt(process.env.CLEANUP_TMP_DAYS || "7", 10),
35
+ waMediaMaxAgeDays: parseInt(process.env.CLEANUP_WA_MEDIA_DAYS || "30", 10),
36
+ };
37
+ const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // once a day
38
+ let cleanupTimer = null;
39
+ /**
40
+ * Return the current effective policy (env-overridden defaults).
41
+ */
42
+ export function getCleanupPolicy() {
43
+ return { ...DEFAULT_POLICY };
44
+ }
45
+ /**
46
+ * Run a cleanup pass once. Safe to call manually (e.g. /cleanup command).
47
+ */
48
+ export async function runCleanup(policyOverride) {
49
+ const policy = { ...DEFAULT_POLICY, ...policyOverride };
50
+ const result = {
51
+ filesDeleted: 0,
52
+ bytesReclaimed: 0,
53
+ logsRotated: 0,
54
+ errors: [],
55
+ details: [],
56
+ };
57
+ // 1. Rotate large log files (launchd stdout/stderr)
58
+ if (policy.logMaxSizeMb > 0) {
59
+ const logsDir = path.join(DATA_DIR, "logs");
60
+ try {
61
+ if (fs.existsSync(logsDir)) {
62
+ for (const name of fs.readdirSync(logsDir)) {
63
+ if (!name.endsWith(".log"))
64
+ continue;
65
+ const full = path.join(logsDir, name);
66
+ try {
67
+ const st = fs.statSync(full);
68
+ if (st.size > policy.logMaxSizeMb * 1024 * 1024) {
69
+ // Rotate: keep a .old, overwrite current. Launchd will reopen on next write.
70
+ const oldPath = full + ".old";
71
+ try {
72
+ fs.rmSync(oldPath, { force: true });
73
+ }
74
+ catch { }
75
+ fs.renameSync(full, oldPath);
76
+ fs.writeFileSync(full, "");
77
+ result.logsRotated++;
78
+ result.bytesReclaimed += st.size;
79
+ result.details.push({ path: full, action: "rotated", size: st.size });
80
+ }
81
+ }
82
+ catch (err) {
83
+ result.errors.push(`log-rotate ${full}: ${err.message}`);
84
+ }
85
+ }
86
+ }
87
+ }
88
+ catch (err) {
89
+ result.errors.push(`logs scan: ${err.message}`);
90
+ }
91
+ }
92
+ // 2. Browser screenshots (bot-owned CDP)
93
+ if (policy.screenshotsMaxAgeDays > 0) {
94
+ const dir = path.join(DATA_DIR, "browser", "screenshots");
95
+ cleanupOldFiles(dir, policy.screenshotsMaxAgeDays, result);
96
+ }
97
+ // 3. Subagent streaming outputs โ€” only delete FINISHED ones (older than N days).
98
+ // We trust that the async-agent-watcher has already marked them done โ€” files
99
+ // older than a few days are either delivered or definitively abandoned.
100
+ if (policy.subagentsMaxAgeDays > 0) {
101
+ const dir = path.join(DATA_DIR, "subagents");
102
+ cleanupOldFiles(dir, policy.subagentsMaxAgeDays, result, [".jsonl", ".err"]);
103
+ }
104
+ // 4. /tmp/alvin-bot/* (media, temp scrapes)
105
+ if (policy.tmpMaxAgeDays > 0) {
106
+ cleanupOldFiles("/tmp/alvin-bot", policy.tmpMaxAgeDays, result);
107
+ }
108
+ // 5. WhatsApp media cache
109
+ if (policy.waMediaMaxAgeDays > 0) {
110
+ const dir = path.join(DATA_DIR, "data", "wa-media");
111
+ cleanupOldFiles(dir, policy.waMediaMaxAgeDays, result);
112
+ }
113
+ // 6. CDP log (/tmp/chrome-cdp.log) โ€” always keep just the latest boot
114
+ const cdpLog = path.join(os.tmpdir(), "chrome-cdp.log");
115
+ try {
116
+ if (fs.existsSync(cdpLog)) {
117
+ const st = fs.statSync(cdpLog);
118
+ const ageDays = (Date.now() - st.mtimeMs) / (24 * 60 * 60 * 1000);
119
+ if (ageDays > 7) {
120
+ fs.unlinkSync(cdpLog);
121
+ result.filesDeleted++;
122
+ result.bytesReclaimed += st.size;
123
+ result.details.push({ path: cdpLog, action: "deleted", size: st.size });
124
+ }
125
+ }
126
+ }
127
+ catch {
128
+ // Not critical
129
+ }
130
+ return result;
131
+ }
132
+ /**
133
+ * Delete files in `dir` older than `maxAgeDays`. Safe if `dir` doesn't exist.
134
+ * Optional extension filter โ€” e.g. [".jsonl", ".err"] restricts to those types.
135
+ */
136
+ function cleanupOldFiles(dir, maxAgeDays, result, extensions) {
137
+ if (!fs.existsSync(dir))
138
+ return;
139
+ const cutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
140
+ try {
141
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
142
+ const full = path.join(dir, entry.name);
143
+ if (!entry.isFile())
144
+ continue;
145
+ if (extensions && !extensions.some((ext) => entry.name.endsWith(ext)))
146
+ continue;
147
+ try {
148
+ const st = fs.statSync(full);
149
+ if (st.mtimeMs < cutoffMs) {
150
+ fs.unlinkSync(full);
151
+ result.filesDeleted++;
152
+ result.bytesReclaimed += st.size;
153
+ result.details.push({ path: full, action: "deleted", size: st.size });
154
+ }
155
+ }
156
+ catch (err) {
157
+ result.errors.push(`${full}: ${err.message}`);
158
+ }
159
+ }
160
+ }
161
+ catch (err) {
162
+ result.errors.push(`scan ${dir}: ${err.message}`);
163
+ }
164
+ }
165
+ /**
166
+ * Start the periodic cleanup loop. Runs first pass after 5 minutes (let the
167
+ * bot fully boot and avoid competing with startup I/O), then every 24h.
168
+ */
169
+ export function startCleanupLoop() {
170
+ if (cleanupTimer)
171
+ return;
172
+ // First run delayed so we don't step on a restart that's still writing logs
173
+ setTimeout(() => {
174
+ void runCleanup().then((r) => {
175
+ if (r.filesDeleted > 0 || r.logsRotated > 0) {
176
+ console.log(`[cleanup] ${r.filesDeleted} files deleted, ${r.logsRotated} logs rotated, ${formatBytes(r.bytesReclaimed)} reclaimed`);
177
+ }
178
+ });
179
+ }, 5 * 60 * 1000);
180
+ cleanupTimer = setInterval(() => {
181
+ void runCleanup().then((r) => {
182
+ if (r.filesDeleted > 0 || r.logsRotated > 0) {
183
+ console.log(`[cleanup] ${r.filesDeleted} files deleted, ${r.logsRotated} logs rotated, ${formatBytes(r.bytesReclaimed)} reclaimed`);
184
+ }
185
+ });
186
+ }, CLEANUP_INTERVAL_MS);
187
+ cleanupTimer.unref?.();
188
+ }
189
+ export function stopCleanupLoop() {
190
+ if (cleanupTimer) {
191
+ clearInterval(cleanupTimer);
192
+ cleanupTimer = null;
193
+ }
194
+ }
195
+ function formatBytes(n) {
196
+ if (n < 1024)
197
+ return `${n} B`;
198
+ if (n < 1024 * 1024)
199
+ return `${(n / 1024).toFixed(1)} KB`;
200
+ if (n < 1024 * 1024 * 1024)
201
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
202
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
203
+ }
@@ -72,6 +72,10 @@ export function startHeartbeat() {
72
72
  setTimeout(() => {
73
73
  runHeartbeat();
74
74
  state.intervalId = setInterval(runHeartbeat, HEARTBEAT_INTERVAL_MS);
75
+ // .unref() so this interval alone doesn't keep the process alive during
76
+ // graceful shutdown โ€” the bot's main loop (grammy, platforms) keeps it
77
+ // running, and once those stop we want the process to exit cleanly.
78
+ state.intervalId?.unref?.();
75
79
  }, 30_000);
76
80
  }
77
81
  /**
@@ -272,6 +272,7 @@ export function startAutoUpdateLoop() {
272
272
  console.log(`[auto-update] check failed: ${result.message}`);
273
273
  }
274
274
  }, AUTO_CHECK_INTERVAL_MS);
275
+ autoTimer.unref?.();
275
276
  console.log(`[auto-update] loop started (interval: 6h)`);
276
277
  }
277
278
  export function stopAutoUpdateLoop() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.16.1",
3
+ "version": "4.17.0",
4
4
  "description": "Alvin Bot \u2014 Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",