alvin-bot 4.8.8 โ†’ 4.9.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,78 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.9.0] โ€” 2026-04-11
6
+
7
+ ### ๐Ÿ›ก Stability batch: crash-loop eliminated, cron jobs restart-resistant, cleaner logs
8
+
9
+ Production users reported a daily-job-alert that "kept crashing" โ€” the cron job triggered at 08:00, died mid-execution, and the next scheduled run silently disappeared until the next day. Root cause was not a single bug but a chain of four: the HTTP Web UI never released its port on shutdown โ†’ `EADDRINUSE :::3100` uncaught crash-loop โ†’ the cron scheduler persisted `nextRunAt = null` pre-execution โ†’ restart rewrote it to "tomorrow 08:00" โ†’ the run was lost. In parallel, sub-agents that ended on a tool call reported "completed" with only the pre-tool text as output, and grammy's "message is not modified" races leaked into Telegram replies as `Fehler: Call to 'editMessageText' failed!`.
10
+
11
+ This release closes the whole chain, adds the Tier 0 of the browser fallback, and installs timestamped logs so future forensics don't need timestamp-free grep archaeology.
12
+
13
+ **Pure functions extracted for isolated testing** (36 new tests, 154 total):
14
+
15
+ - `src/services/cron-scheduling.ts` โ€” `prepareForExecution(job, now)` and `handleStartupCatchup(jobs, now, graceMs)`. The old scheduler set `nextRunAt = null` before `await executeJob(job)` and only recomputed after completion. A crash mid-execution left `nextRunAt = null`; the next boot recomputed it from the current time โ†’ always landed on tomorrow's trigger. Now `prepareForExecution` persists the NEXT regular trigger BEFORE running, and stamps `lastAttemptAt`. If `lastAttemptAt > lastRunAt` at boot and the attempt is โ‰ค 6 h old, `handleStartupCatchup` rewinds `nextRunAt` to `now` so the next tick picks it up. New `CronJob.lastAttemptAt` field (`number | null`).
16
+ - `src/services/watchdog-brake.ts` โ€” `decideBrakeAction(prev, now, opts)` and `shouldResetCrashCounter(uptimeMs, opts)`. The old brake reset `crashCount` after 5 minutes of clean uptime, which was shorter than the typical sub-agent lifetime โ€” chronic crashes with 5โ€“10 min gaps passed the brake indefinitely. New policy: **1 h clean uptime required for reset**, plus a hard **20 crashes / 24 h** daily cap alongside the existing 10 crashes / 10 min short-window cap. Both counters persist in the beacon.
17
+ - `src/util/debounce.ts` โ€” trailing-edge debounce for fs.watch coalescing.
18
+ - `src/util/console-formatter.ts` โ€” `installConsoleFormatter()`: monkey-patches `console.log/warn/info/error` to prefix every line with an ISO timestamp, and drops libsignal "Closing session" multi-line SessionEntry dumps + `[claude] Native binary` spam that were pushing tens of KB per day into `alvin-bot.out.log` / `alvin-bot.err.log`.
19
+ - `src/util/telegram-error-filter.ts` โ€” `isHarmlessTelegramError(err)`: single source of truth for benign grammy races (`message is not modified`, `query is too old`, `message to edit not found`, `MESSAGE_ID_INVALID`, โ€ฆ).
20
+ - `src/services/browser-webfetch.ts` โ€” `webfetchNavigate(url, opts)` + `parseTitle(html)` + `WebfetchFailed`: Tier 0 of the browser fallback chain. Plain `fetch()` instead of Playwright for static pages.
21
+ - `src/platforms/whatsapp-auth-helpers.ts` โ€” `makeResilientSaveCreds(authDir, inner)`: wraps baileys' `saveCreds` so an ENOENT from a vanished auth dir transparently recreates the directory and retries once.
22
+
23
+ **Fixes wired into the existing modules:**
24
+
25
+ - **`src/web/server.ts` โ€” new `stopWebServer(server)`.** Closes WebSocket clients, calls `closeIdleConnections()` + `closeAllConnections()` (Node 18.2+) so long-poll clients can't stall the shutdown, then awaits `server.close()`. Called from `shutdown()` in `src/index.ts`. Before this fix, launchd restarted the bot โ†’ new process tried `server.listen(3100)` โ†’ `EADDRINUSE` โ†’ uncaught exception โ†’ exit โ†’ launchd again. Classic crash-loop. **This single fix stops the chain.**
26
+ - **`src/services/cron.ts`** โ€” scheduler rewired to call `prepareForExecution` pre-execution and `handleStartupCatchup` at boot. `lastResult` truncation bumped from 500 โ†’ 4000 chars so post-mortem is possible without running the job again.
27
+ - **`src/services/watchdog.ts`** โ€” beacon schema extended with `dailyCrashCount` + `dailyCrashWindowStart`; `startWatchdog` now delegates the brake decision to the pure `decideBrakeAction`. Recovery timer still fires, but only resets the counter if `shouldResetCrashCounter` agrees (โ‰ฅ 1 h uptime).
28
+ - **`src/services/subagents.ts`** โ€” `runSubAgent` now reads `finalText` from the `done` chunk as the authoritative final output (was ignored before), preserves buffered text when the stream emits an `error` chunk, and โ€” most importantly โ€” keeps `finalText` when the catch handler fires (was `output: ""`, throwing away multi-minute runs). Variable scope moved outside the try block. New `error` status branch for mid-stream provider failures.
29
+ - **`src/services/subagent-delivery.ts`** โ€” `buildBanner` now renders `โš ๏ธ completed ยท empty output` for the "successful run with zero text" case so truncated runs are immediately visible instead of hiding behind a green tick.
30
+ - **`src/services/skills.ts`** โ€” `fs.watch` callbacks wrapped in `debounce(โ€ฆ, 300)` so macOS FSEvents duplicates coalesce into one reload.
31
+ - **`src/services/browser-manager.ts`** โ€” new `webfetch` tier added as default for non-interactive tasks. `resolveStrategy` cascade is now `webfetch โ†’ hub-stealth โ†’ cdp โ†’ gateway โ†’ cli`. `navigate()` has an error-based fallback: if `webfetch` throws (403, 5xx, content-type mismatch), it transparently upgrades to `hub-stealth` then `cli` before giving up.
32
+ - **`src/platforms/whatsapp.ts`** โ€” `saveCreds` wrapped in `makeResilientSaveCreds` so a vanished auth dir self-heals instead of becoming an unhandled rejection.
33
+ - **`src/handlers/message.ts`, `src/services/telegram.ts`, `src/index.ts` (bot.catch + streaming finalize)** โ€” all three call sites that used to ship the raw grammy error to users now route through `isHarmlessTelegramError`. The `Fehler: Call to 'editMessageText' failed!` noise that 2-3 users per day were seeing is gone.
34
+
35
+ **What is NOT changed:**
36
+
37
+ - **Timeouts.** The v4.8.8 `defaultTimeoutMs = -1` (unlimited) behavior is preserved. Sub-agents and cron jobs can still run as long as they need.
38
+ - **The cron job `payload.prompt`s.** Users' existing cron definitions keep working unchanged.
39
+ - **The beacon file format back-compat.** Old beacons without the daily counters are read correctly; the new fields are seeded to 0/now on first boot.
40
+
41
+ **How to verify after update:**
42
+
43
+ 1. `launchctl unload ~/Library/LaunchAgents/com.alvinbot.app.plist && launchctl load ~/Library/LaunchAgents/com.alvinbot.app.plist`
44
+ 2. Tail `~/.alvin-bot/logs/alvin-bot.out.log` โ€” every line should now carry an ISO timestamp and libsignal SessionEntry dumps should be gone.
45
+ 3. Check `~/.alvin-bot/state/watchdog.json` โ€” should contain `dailyCrashCount` / `dailyCrashWindowStart` within a minute.
46
+ 4. Send `/cron run Daily Job Alert` โ€” subagent-delivery banner should render fully, `~/.alvin-bot/cron-jobs.json` should show `lastAttemptAt` and a post-execution `lastRunAt`.
47
+ 5. Trigger a deliberate edit race (double-tap an inline button quickly) โ€” no `Fehler: Call to 'editMessageText' failed!` reply should land in the chat.
48
+
49
+ ## [4.8.9] โ€” 2026-04-11
50
+
51
+ ### ๐Ÿ› Browser automation: dead `browse-server.cjs` path removed, 3-tier router now the source of truth
52
+
53
+ The `browse` skill used to instruct the agent to start `node scripts/browse-server.cjs` on port 3800 for every browser task. That file was deleted in an earlier cleanup (see `20283c9` for the original 577-line version โ€” now gone), but `skills/browse/SKILL.md` was never updated. Result: any browser-related user message on Telegram โ€” or any cron job that hit the skill โ€” got a system-prompt injection telling it to call a gateway that didn't exist, producing half-failed runs like the "Daily Job Alert" cron that couldn't load LinkedIn or StepStone.
54
+
55
+ **What changed:**
56
+
57
+ - **`skills/browse/SKILL.md` โ€” full rewrite.** Now documents the hub 3-tier router at `~/.claude/hub/SCRIPTS/browser.sh`:
58
+ - **Tier 0** โ€” WebFetch / `curl` for static pages and APIs
59
+ - **Tier 1** โ€” `browser.sh stealth <url>` (Playwright + stealth plugin, headless, Cloudflare-masking)
60
+ - **Tier 2** โ€” `browser.sh cdp {start|goto|shot|tabs|stop}` (real Chrome with persistent profile at `~/.claude/hub/BROWSER/profile/`, login cookies survive restarts)
61
+ - **Tier 3** โ€” Claude-in-Chrome extension via MCP tools (interactive CLI only)
62
+ - Explicit escalation ladder (WebFetch โ†’ stealth โ†’ CDP โ†’ ask Ali to log in) and a `NIEMALS browse-server.cjs nutzen` anti-rule.
63
+ - Concrete working targets (StepStone โœ…, Michael Page โœ…, LinkedIn โœ… with login, Indeed โŒ) so the agent knows what to try where.
64
+
65
+ - **`src/services/browser-manager.ts` โ€” hardened fallback chain.** The multi-strategy manager already had the right *shape* (`gateway โ†’ cdp โ†’ hub-stealth โ†’ cli`) but several ops silently broke or hung:
66
+ - **`gatewayRequest` now has a 15 s timeout** (`req.destroy` on elapse). Previously a hung gateway would wedge the caller forever.
67
+ - **CDP fallback for interactive ops.** `click`, `fill`, `type`, `press`, `scroll`, `evaluate`, `info`, and `getTree` used to hard-throw `"requires gateway"` when `browse-server.cjs` wasn't running. They now try the gateway first, then a short-lived `chromium.connectOverCDP()` via a new `withCdpPage()` helper that reuses Ali's live Chrome on port 9222. Refs are interpreted as CSS selectors when gateway is absent.
68
+ - **Explicit PNG extension** on auto-generated screenshot filenames (`shot_<ts>.png`) so Playwright's format inference is unambiguous.
69
+ - **Better error messages** โ€” every "needs interactive" throw now includes the exact command to start CDP Chrome (`~/.claude/hub/SCRIPTS/browser.sh cdp start headless`).
70
+
71
+ - **`src/paths.ts` โ€” `HUB_BROWSER_SH` constant.** New absolute path to `~/.claude/hub/SCRIPTS/browser.sh` so the manager can shell out without hard-coding `os.homedir()` inline.
72
+
73
+ **Why this matters:** `browser-manager.ts` is still not wired into any bot code path (it's future-proofing), so the production fix for user-interactive flows is `SKILL.md`. The manager hardening ensures that when it does eventually get wired into a sub-agent tool, it won't hang on missing gateways or lose all interactive capability when only CDP is available.
74
+
75
+ **Testing:** Tier 1 stealth end-to-end against `stepstone.de/jobs/it-delivery-director` โ†’ 1.2 MB HTML, title parsed. Module-level integration test: `navigate('https://example.com')` via auto-selected hub-stealth โ†’ correct title/URL. `resolveStrategy('gateway')` โ†’ cascades to CDP with visible warning. `info()` via CDP fallback โ†’ returns live Chrome state without throwing. Skills reload picks up the new SKILL.md (5977 chars), `matchSkills("browse linkedin")` hits the browse skill, `buildSkillContext("open stepstone.de")` injects the 3-tier guidance block.
76
+
5
77
  ## [4.8.8] โ€” 2026-04-11
6
78
 
7
79
  ### โœจ Unlimited sub-agent & cron timeouts (user-configurable)
@@ -14,6 +14,7 @@ import { emit } from "../services/hooks.js";
14
14
  import { trackUsage } from "../services/usage-tracker.js";
15
15
  import { emitUserMessage as broadcastUserMessage, emitResponseStart as broadcastResponseStart, emitResponseDelta as broadcastResponseDelta, emitResponseDone as broadcastResponseDone, } from "../services/broadcast.js";
16
16
  import { t } from "../i18n.js";
17
+ import { isHarmlessTelegramError } from "../util/telegram-error-filter.js";
17
18
  /**
18
19
  * Stuck-only timeout โ€” NO absolute cap.
19
20
  *
@@ -367,7 +368,7 @@ export async function handleMessage(ctx) {
367
368
  if (timedOut) {
368
369
  await ctx.reply(t("bot.error.timeoutStuck", session.language, { min: STUCK_TIMEOUT_MINUTES }));
369
370
  }
370
- else {
371
+ else if (!isHarmlessTelegramError(chunk.error)) {
371
372
  await ctx.reply(`${t("bot.error.prefix", session.language)} ${chunk.error}`);
372
373
  }
373
374
  break;
@@ -419,7 +420,9 @@ export async function handleMessage(ctx) {
419
420
  else if (errorMsg.includes("abort")) {
420
421
  await ctx.reply(t("bot.error.requestCancelled", lang));
421
422
  }
422
- else {
423
+ else if (!isHarmlessTelegramError(err)) {
424
+ // Drop benign grammy races ("message is not modified", etc.)
425
+ // instead of surfacing them as "Fehler: ..." replies.
423
426
  await ctx.reply(`${t("bot.error.prefix", lang)} ${errorMsg}`);
424
427
  }
425
428
  }
package/dist/index.js CHANGED
@@ -1,6 +1,12 @@
1
1
  // โ”€โ”€ Bootstrap: ensure ~/.alvin-bot/ exists + migrate legacy data โ”€โ”€โ”€โ”€
2
2
  import { ensureDataDirs, seedDefaults } from "./init-data-dir.js";
3
3
  import { hasLegacyData, migrateFromLegacy } from "./migrate.js";
4
+ import { installConsoleFormatter } from "./util/console-formatter.js";
5
+ import { isHarmlessTelegramError } from "./util/telegram-error-filter.js";
6
+ // 0. Install timestamp + noise-filter formatters on console.* so every
7
+ // line in out.log / err.log carries an ISO timestamp and libsignal's
8
+ // SessionEntry dumps stop burying the signal.
9
+ installConsoleFormatter();
4
10
  // 1. Create directory structure (no files yet)
5
11
  ensureDataDirs();
6
12
  // 2. Migrate legacy data BEFORE seeding defaults (so real data wins over templates)
@@ -70,7 +76,7 @@ import { handleVideo } from "./handlers/video.js";
70
76
  import { initEngine } from "./engine.js";
71
77
  import { loadPlugins, registerPluginCommands, unloadPlugins } from "./services/plugins.js";
72
78
  import { initMCP, disconnectMCP, hasMCPConfig } from "./services/mcp.js";
73
- import { startWebServer } from "./web/server.js";
79
+ import { startWebServer, stopWebServer } from "./web/server.js";
74
80
  import { startScheduler, stopScheduler, setNotifyCallback } from "./services/cron.js";
75
81
  import { startSessionCleanup, stopSessionCleanup } from "./services/session.js";
76
82
  import { processQueue, cleanupQueue, setSenders, enqueue } from "./services/delivery-queue.js";
@@ -220,16 +226,11 @@ if (hasTelegram) {
220
226
  bot.catch((err) => {
221
227
  const ctx = err.ctx;
222
228
  const e = err.error;
223
- // Telegram's "message is not modified" (400) is harmless โ€” it fires
224
- // when a callback handler re-renders an inline keyboard / edited
225
- // message with content that happens to match the current message
226
- // exactly (e.g. double-tapped toggle button, identical list after
227
- // re-render). Swallow it silently so it neither pollutes the logs
228
- // nor bubbles up to the user as "internal error".
229
- const msg = e instanceof Error ? e.message : String(e);
230
- if (/message is not modified/i.test(msg) || /specified new message content.*exactly the same/i.test(msg)) {
229
+ // Swallow the well-known harmless grammy races (message is not
230
+ // modified, query too old, message to edit not found โ€ฆ) silently.
231
+ // See src/util/telegram-error-filter.ts for the exhaustive list.
232
+ if (isHarmlessTelegramError(e))
231
233
  return;
232
- }
233
234
  console.error(`Error handling update ${ctx?.update?.update_id}:`, e);
234
235
  // Try to notify the user
235
236
  if (ctx?.chat?.id) {
@@ -260,6 +261,9 @@ const shutdown = async () => {
260
261
  clearInterval(queueCleanupInterval);
261
262
  if (bot)
262
263
  bot.stop();
264
+ // Release :3100 so the next launchd boot doesn't hit EADDRINUSE.
265
+ // Must happen before exit โ€” see src/web/server.ts stopWebServer() comment.
266
+ await stopWebServer(webServer).catch((err) => console.warn("[shutdown] stopWebServer failed:", err));
263
267
  await unloadPlugins().catch(() => { });
264
268
  await disconnectMCP().catch(() => { });
265
269
  // Tear down any bot-managed local runners (Ollama, LM Studio, โ€ฆ) so VRAM
package/dist/paths.js CHANGED
@@ -86,6 +86,8 @@ export const AGENTS_FILE = resolve(DATA_DIR, "AGENTS.md");
86
86
  export const HOOKS_DIR = resolve(DATA_DIR, "hooks");
87
87
  /** scripts/browse-server.cjs โ€” HTTP gateway for persistent browser sessions */
88
88
  export const BROWSE_SERVER_SCRIPT = resolve(BOT_ROOT, "scripts", "browse-server.cjs");
89
+ /** ~/.claude/hub/SCRIPTS/browser.sh โ€” Hub 3-tier browser router (stealth, CDP, ext) */
90
+ export const HUB_BROWSER_SH = resolve(os.homedir(), ".claude", "hub", "SCRIPTS", "browser.sh");
89
91
  /** data/exec-allowlist.json โ€” User-defined exec allowlist */
90
92
  export const EXEC_ALLOWLIST_FILE = resolve(DATA_DIR, "exec-allowlist.json");
91
93
  /** assets/ โ€” User asset files (CVs, cover letters, legal docs, photos) */
@@ -0,0 +1,53 @@
1
+ /**
2
+ * WhatsApp auth helpers โ€” tiny resilience wrappers around baileys'
3
+ * use-multi-file-auth-state output.
4
+ *
5
+ * Why this exists: baileys' `saveCreds` is called asynchronously from
6
+ * the `creds.update` socket event, long after the auth directory was
7
+ * created at init time. If anything wipes the directory between init
8
+ * and the first save โ€” a crash mid-init, a manual rm -rf, a stale
9
+ * worker on a different code path โ€” the write throws ENOENT and becomes
10
+ * an `unhandledRejection`, which node 15+ default-reports as a crash.
11
+ *
12
+ * This module keeps the wrapper separate from `whatsapp.ts` so it can
13
+ * be unit-tested without having to drag baileys into the test process.
14
+ */
15
+ import fs from "fs";
16
+ /**
17
+ * Wrap a baileys saveCreds so a missing auth directory is transparently
18
+ * recreated once and the save is retried. Any other error, and any
19
+ * second ENOENT in a row, surfaces unchanged.
20
+ */
21
+ export function makeResilientSaveCreds(authDir, innerSaveCreds) {
22
+ return async function resilientSaveCreds() {
23
+ try {
24
+ await innerSaveCreds();
25
+ return;
26
+ }
27
+ catch (err) {
28
+ if (!isEnoent(err))
29
+ throw err;
30
+ // baileys-auth dir vanished between init and now โ€” rebuild and retry once.
31
+ try {
32
+ fs.mkdirSync(authDir, { recursive: true });
33
+ }
34
+ catch {
35
+ // If mkdir itself fails, fall through to the retry โ€” it will surface
36
+ // the real error below with its original stack.
37
+ }
38
+ await innerSaveCreds();
39
+ }
40
+ };
41
+ }
42
+ function isEnoent(err) {
43
+ if (!err || typeof err !== "object")
44
+ return false;
45
+ const code = err.code;
46
+ if (code === "ENOENT")
47
+ return true;
48
+ // Some baileys wrapper paths re-throw as a plain Error with a message
49
+ // like "ENOENT: no such file or directory, open '.../creds.json'" but
50
+ // without .code โ€” match the message as a fallback.
51
+ const msg = err.message || "";
52
+ return /ENOENT/.test(msg);
53
+ }
@@ -19,6 +19,7 @@
19
19
  import fs from "fs";
20
20
  import { dirname, join } from "path";
21
21
  import { WHATSAPP_AUTH as AUTH_DIR, WA_GROUPS as GROUP_CONFIG_FILE, WA_MEDIA_DIR } from "../paths.js";
22
+ import { makeResilientSaveCreds } from "./whatsapp-auth-helpers.js";
22
23
  function loadGroupConfig() {
23
24
  try {
24
25
  return JSON.parse(fs.readFileSync(GROUP_CONFIG_FILE, "utf-8"));
@@ -250,8 +251,11 @@ export class WhatsAppAdapter {
250
251
  generateHighQualityLinkPreview: false,
251
252
  });
252
253
  this.sock = sock;
253
- // Save credentials on update
254
- sock.ev.on("creds.update", saveCreds);
254
+ // Save credentials on update. Wrapped so a vanished auth dir (crash
255
+ // mid-init, manual cleanup, etc.) doesn't turn the next creds.update
256
+ // into an unhandled ENOENT rejection.
257
+ const resilientSaveCreds = makeResilientSaveCreds(authDir, saveCreds);
258
+ sock.ev.on("creds.update", resilientSaveCreds);
255
259
  // Connection state
256
260
  sock.ev.on("connection.update", (update) => {
257
261
  const { connection, lastDisconnect, qr } = update;