alvin-bot 4.8.2 โ†’ 4.8.4

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,110 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.8.4] โ€” 2026-04-11
6
+
7
+ ### ๐Ÿ› WhatsApp self-chat detection for the new `@lid` identity format
8
+
9
+ Ali reported that the WhatsApp bot wasn't responding to "Hi" in his self-chat even after enabling both `Self-chat only` and `Reply to private messages` in the Web UI. Debug logging showed the bot receiving the message correctly and detecting `fromMe=true`, but then hitting the "skip: own message in group/DM" branch because `isSelfChat()` was returning `false`.
10
+
11
+ **Root cause**: WhatsApp has rolled out a new privacy feature that replaces phone-number JIDs in self-chats (and some groups) with a **LID โ€” Linked Identity**. Instead of `4917661236656@s.whatsapp.net`, messages in a self-chat now arrive with `jid = "162805718225143@lid"` โ€” a completely opaque identifier that looks nothing like the phone number.
12
+
13
+ Our `isSelfChat(jid)` compared the incoming JID against `sock.user.id` (the traditional phone-number format `4917661236656:22@s.whatsapp.net`), stripped the device suffix, and compared the bare numbers. But the LID has a completely different number (`162805718225143`), so the match failed and every self-chat message fell through to the "own message in DM" skip branch.
14
+
15
+ **Fix**: `isSelfChat()` now checks **both** identity formats:
16
+
17
+ - **Traditional phone JID** via `sock.user.id` (legacy path, still matches on older WhatsApp clients)
18
+ - **LID** via `sock.user.lid` (baileys โ‰ฅ 6.7 exposes this) with `@lid` suffix matching
19
+
20
+ Either match wins. The check short-circuits on groups (`@g.us`) so the new code never misclassifies a group as self-chat.
21
+
22
+ Caught on the Mac mini production bot after midnight โ€” WhatsApp connected, QR scanned, user sending "Hi", bot silent. Debug logging revealed the actual incoming JID (`162805718225143@lid`) which immediately pointed at the LID format as the culprit.
23
+
24
+ ### ๐Ÿงน Dual-bot session collision (root cause of WhatsApp reconnect flapping)
25
+
26
+ While debugging the `@lid` issue above, the test revealed a deeper problem: two `node dist/index.js` processes were running simultaneously on the Mac mini (PID 47744 from an earlier `launchctl kickstart` that didn't cleanly kill the old instance, plus PID 49153 from a new `launchd install`). Both processes were trying to hold the same WhatsApp Multi-Device session at the same time, causing:
27
+
28
+ - WhatsApp `Reconnecting in 3s` every few seconds (each process would claim the session, the other would be kicked)
29
+ - Baileys `Closing session` dumps to the log
30
+ - Signal session state corruption โ†’ "Warte auf diese Nachricht" (waiting-to-decrypt) messages appearing spontaneously in the self-chat
31
+
32
+ **Short-term workaround**: explicit `pkill -9 -f 'node.*alvin-bot/dist/index'` before `launchctl kickstart` to ensure only one process is running.
33
+
34
+ **Session wipe procedure** (when the corruption is already baked in):
35
+
36
+ 1. `launchctl unload -w ~/Library/LaunchAgents/com.alvinbot.app.plist`
37
+ 2. `pkill -9 -f "node.*alvin-bot/dist/index"`
38
+ 3. `rm -rf ~/.alvin-bot/data/whatsapp-auth`
39
+ 4. Remove the zombie linked-device from your phone (iPhone Settings โ†’ Linked Devices โ†’ remove all "Alvin Bot" entries)
40
+ 5. `launchctl load -w ~/Library/LaunchAgents/com.alvinbot.app.plist`
41
+ 6. Re-scan the QR code
42
+
43
+ A future release should add a proper `alvin-bot wa reset` command to automate this and a startup check that refuses to boot if another instance is already running.
44
+
45
+ ## [4.8.3] โ€” 2026-04-11
46
+
47
+ ### ๐Ÿ› Critical: Claude SDK heartbeat false-positive "unavailable"
48
+
49
+ Caught in production on the Mac mini: the heartbeat monitor was marking `claude-sdk` as unhealthy every 5 minutes, triggering failover to Ollama, even though `claude -p "ping"` from the same user's terminal worked perfectly. After 9 consecutive heartbeat failures, the main Telegram bot was stuck serving responses via Gemma 4 instead of Claude Max.
50
+
51
+ **Root cause**: `isAvailable()` in the Claude SDK provider used `claude -p "ping" --output-format text` as an auth probe. That command spawns a full SDK query, takes **6-10 seconds warm** (longer on cold starts), and my timeout was only **10 seconds**. Under load or on cold starts it crossed the timeout threshold, was killed by Node, and execFileAsync rejected โ†’ caught by the outer try/catch โ†’ cached as "unavailable" for 60 seconds โ†’ next heartbeat re-probed and failed the same way.
52
+
53
+ **Fix**: Replaced the `-p "ping"` probe with `claude auth status`. This is a purpose-built Claude CLI command that:
54
+
55
+ - Completes in ~150 ms (vs 6-10 s)
56
+ - Returns structured JSON with an explicit `loggedIn` boolean
57
+ - Consumes zero tokens
58
+ - Doesn't touch the SDK or model init path
59
+
60
+ The new code parses the JSON and returns `true` only when `loggedIn === true`. A fallback path keeps the old `-p "ping"` sniff for older Claude CLI versions that don't support `auth status` as JSON.
61
+
62
+ Before/after the fix:
63
+
64
+ ```
65
+ Before: 6800ms warm probe, 10s timeout, consumed tokens,
66
+ failed under load โ†’ 9 consecutive false-positive "unavailable"
67
+ After: 150ms probe, 5s timeout, no tokens, structured JSON check
68
+ ```
69
+
70
+ ### โœจ New CLI command: `alvin-bot status`
71
+
72
+ Offline-friendly status command โ€” no running bot required. Prints:
73
+
74
+ - **Version**: `Alvin Bot vX.Y.Z` + Node version + platform/arch
75
+ - **Data dir**: path + whether `.env` exists + configured `PRIMARY_PROVIDER`
76
+ - **Runtime state**:
77
+ - On macOS: LaunchAgent plist installed? PID from `launchctl list`?
78
+ - On Linux/Windows: `pm2 jlist` check for the `alvin-bot` process
79
+ - **Live info** (when the bot is running with the web UI on :3100): Uptime, active model
80
+
81
+ Answers Ali's request: *"alvin-bot status im Terminal soll auch die Version anzeigen"*. The command prominently features the version at the top so it's the first thing you see.
82
+
83
+ Example:
84
+
85
+ ```
86
+ ๐Ÿค– Alvin Bot v4.8.3
87
+ Node v25.9.0 ยท darwin/arm64
88
+
89
+ ๐Ÿ“ Data dir: /Users/alvin_de/.alvin-bot
90
+ .env: โœ… present
91
+ Provider: claude-sdk
92
+
93
+ ๐Ÿš€ LaunchAgent: installed
94
+ Running: โœ… yes (PID 43589)
95
+ Uptime: 0h 55m
96
+ Model: Gemma 4 E4B (Ollama)
97
+ ```
98
+
99
+ ### Tests
100
+
101
+ 2 new test cases in `test/claude-sdk-provider.test.ts` cover the new flow:
102
+
103
+ - `claude auth status` returning `{loggedIn: true}` โ†’ `isAvailable()` returns `true`
104
+ - `claude auth status` returning `{loggedIn: false}` โ†’ `isAvailable()` returns `false`
105
+ - Older CLI where `auth status` throws โ†’ fall back to `-p "ping"` path (preserves old behavior)
106
+
107
+ 87 tests passing (up from 85).
108
+
5
109
  ## [4.8.2] โ€” 2026-04-11
6
110
 
7
111
  ### ๐Ÿ› Offline setup: wait long enough for Ollama's first-run init
package/bin/cli.js CHANGED
@@ -1847,6 +1847,86 @@ switch (cmd) {
1847
1847
  case "-v":
1848
1848
  version();
1849
1849
  break;
1850
+ case "status": {
1851
+ // CLI `alvin-bot status` โ€” quick, offline-friendly status without
1852
+ // requiring a running bot. Prints version, node info, data dir,
1853
+ // configured provider, and โ€” on macOS โ€” LaunchAgent state.
1854
+ try {
1855
+ const pkg = JSON.parse(
1856
+ readFileSync(resolve(import.meta.dirname || ".", "../package.json"), "utf-8"),
1857
+ );
1858
+ console.log(`\n๐Ÿค– Alvin Bot v${pkg.version}`);
1859
+ } catch {
1860
+ console.log("\n๐Ÿค– Alvin Bot (version unknown)");
1861
+ }
1862
+ console.log(` Node ${process.version} ยท ${process.platform}/${process.arch}`);
1863
+ console.log("");
1864
+
1865
+ // Data dir + .env
1866
+ const envPath = join(DATA_DIR, ".env");
1867
+ console.log(`๐Ÿ“ Data dir: ${DATA_DIR}`);
1868
+ console.log(` .env: ${existsSync(envPath) ? "โœ… present" : "โŒ missing"}`);
1869
+
1870
+ // Primary provider from .env
1871
+ if (existsSync(envPath)) {
1872
+ try {
1873
+ const env = readFileSync(envPath, "utf-8");
1874
+ const match = env.match(/^PRIMARY_PROVIDER=(.+)$/m);
1875
+ if (match) console.log(` Provider: ${match[1].trim()}`);
1876
+ } catch { /* ignore */ }
1877
+ }
1878
+ console.log("");
1879
+
1880
+ // Runtime state: LaunchAgent (macOS) or pm2 (Linux/Windows)
1881
+ if (process.platform === "darwin") {
1882
+ const { plistPath, label } = launchdPaths();
1883
+ const plistExists = existsSync(plistPath);
1884
+ console.log(`๐Ÿš€ LaunchAgent: ${plistExists ? "installed" : "not installed"}`);
1885
+ if (plistExists) {
1886
+ try {
1887
+ const out = execSync(`launchctl list | grep ${label} || true`, { encoding: "utf-8" });
1888
+ if (out.trim()) {
1889
+ const parts = out.trim().split(/\s+/);
1890
+ const pid = parts[0];
1891
+ const isRunning = pid !== "-" && pid !== "0";
1892
+ console.log(` Running: ${isRunning ? `โœ… yes (PID ${pid})` : "โŒ no"}`);
1893
+ } else {
1894
+ console.log(` Running: โŒ not loaded`);
1895
+ }
1896
+ } catch {
1897
+ console.log(` Running: โŒ unknown`);
1898
+ }
1899
+ }
1900
+ } else {
1901
+ // Linux/Windows: check pm2
1902
+ try {
1903
+ const out = execSync("pm2 jlist 2>/dev/null || echo '[]'", { encoding: "utf-8" });
1904
+ const procs = JSON.parse(out);
1905
+ const alvin = procs.find?.((p) => p && p.name === "alvin-bot");
1906
+ if (alvin) {
1907
+ console.log(`๐Ÿš€ pm2: ${alvin.pm2_env?.status || "unknown"} (PID ${alvin.pid || "?"})`);
1908
+ } else {
1909
+ console.log(`๐Ÿš€ pm2: alvin-bot not managed`);
1910
+ }
1911
+ } catch {
1912
+ console.log(`๐Ÿš€ pm2: not installed`);
1913
+ }
1914
+ }
1915
+
1916
+ // Try to reach the running web API for live info
1917
+ try {
1918
+ const apiRes = execSync("curl -fsS -m 2 http://localhost:3100/api/status 2>/dev/null", { encoding: "utf-8" });
1919
+ const parsed = JSON.parse(apiRes);
1920
+ const uptimeSec = Math.floor(parsed.bot?.uptime || 0);
1921
+ const h = Math.floor(uptimeSec / 3600);
1922
+ const m = Math.floor((uptimeSec % 3600) / 60);
1923
+ console.log(` Uptime: ${h}h ${m}m`);
1924
+ if (parsed.model?.name) console.log(` Model: ${parsed.model.name}`);
1925
+ } catch { /* bot not running or web ui off โ€” skip */ }
1926
+
1927
+ console.log("");
1928
+ process.exit(0);
1929
+ }
1850
1930
  default:
1851
1931
  console.log(`
1852
1932
  ${t("cli.title")}
@@ -1862,6 +1942,7 @@ ${t("cli.commands")}
1862
1942
  start ${t("cli.startDesc")} (background via PM2)
1863
1943
  start -f Start in foreground (for debugging)
1864
1944
  stop Stop the bot
1945
+ status Show bot version + LaunchAgent/pm2 state (offline)
1865
1946
  launchd macOS only: install/uninstall/status as launchd user agent
1866
1947
  version ${t("cli.versionDesc")}
1867
1948
 
@@ -536,10 +536,38 @@ export class WhatsAppAdapter {
536
536
  await this.handler(incoming);
537
537
  }
538
538
  isSelfChat(jid) {
539
- const myJid = this.sock?.user?.id;
540
- if (!myJid)
539
+ if (!this.sock?.user)
541
540
  return false;
542
- return normalizeJid(jid) === normalizeJid(myJid);
541
+ // Groups are never self-chat regardless of which identity format
542
+ // the group uses.
543
+ if (jid.endsWith("@g.us"))
544
+ return false;
545
+ // WhatsApp has two identity formats that can appear in self-chat:
546
+ // 1. Traditional phone-number JID: 49176...:22@s.whatsapp.net
547
+ // 2. LID (linked identity): 162805718...@lid โ€” privacy feature
548
+ // added in 2024 that hides the real phone number in self-chats
549
+ // and some groups. Baileys exposes this as sock.user.lid.
550
+ //
551
+ // Check both so self-chat detection works regardless of which
552
+ // format WhatsApp chose to tag the chat with today.
553
+ const user = this.sock.user;
554
+ const myId = user.id;
555
+ const myLid = user.lid;
556
+ // Match against phone-number JID (traditional path)
557
+ if (myId) {
558
+ const myNumber = jidToNumber(myId);
559
+ const jidNumber = jidToNumber(jid);
560
+ if (myNumber && jidNumber && myNumber === jidNumber)
561
+ return true;
562
+ }
563
+ // Match against LID (new privacy format)
564
+ if (myLid && jid.endsWith("@lid")) {
565
+ const myLidNum = jidToNumber(myLid);
566
+ const jidLidNum = jidToNumber(jid);
567
+ if (myLidNum && jidLidNum && myLidNum === jidLidNum)
568
+ return true;
569
+ }
570
+ return false;
543
571
  }
544
572
  // โ”€โ”€ Public API: Groups โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
545
573
  async getGroups() {
@@ -237,19 +237,42 @@ export class ClaudeSDKProvider {
237
237
  if (!claudePath)
238
238
  return cache(false);
239
239
  // Step 1: binary exists?
240
- // Async execFile doesn't block the event loop. 5s timeout kills
241
- // runaway probes without hanging the bot.
242
240
  await execFileAsync(claudePath, ["--version"], { timeout: 5000 });
243
- // Step 2: actually authenticated? The Claude Agent SDK shares the
244
- // same OAuth token as the CLI โ€” if `claude -p` says "Not logged in",
245
- // the SDK will fail too. Probe with a trivial -p call and surface
246
- // the failure before the registry hands a request to a broken
247
- // provider.
248
- const { stdout } = await execFileAsync(claudePath, ["-p", "ping", "--output-format", "text"], { timeout: 10000 });
249
- if (isAuthErrorOutput(stdout)) {
241
+ // Step 2: actually authenticated?
242
+ //
243
+ // We used to use `claude -p "ping" --output-format text` and sniff
244
+ // the stdout for "Not logged in". That spawned a full SDK query,
245
+ // consumed tokens, and took 5-10 seconds warm โ€” occasionally
246
+ // crossing our timeout on cold starts or under load, leading to
247
+ // false-positive "unavailable" reports that cascaded into heartbeat
248
+ // failures and unnecessary fallback to Ollama.
249
+ //
250
+ // `claude auth status` is the purpose-built command: fast (~150ms),
251
+ // no token cost, no SDK init, returns structured JSON with an
252
+ // explicit `loggedIn` boolean. Much cleaner.
253
+ try {
254
+ const { stdout } = await execFileAsync(claudePath, ["auth", "status"], { timeout: 5000 });
255
+ const parsed = JSON.parse(stdout);
256
+ if (parsed.loggedIn === true) {
257
+ return cache(true);
258
+ }
259
+ // loggedIn === false (or missing) โ€” not authenticated
250
260
  return cache(false);
251
261
  }
252
- return cache(true);
262
+ catch (authErr) {
263
+ // Older claude CLI versions may not expose `auth status` as JSON,
264
+ // or may exit non-zero when not logged in. Fall back to the
265
+ // sniff-stdout approach for backward compat.
266
+ try {
267
+ const { stdout: probeOut } = await execFileAsync(claudePath, ["-p", "ping", "--output-format", "text"], { timeout: 15000 });
268
+ return cache(!isAuthErrorOutput(probeOut));
269
+ }
270
+ catch {
271
+ // Both checks failed โ€” treat as unavailable
272
+ void authErr;
273
+ return cache(false);
274
+ }
275
+ }
253
276
  }
254
277
  catch {
255
278
  return cache(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.8.2",
3
+ "version": "4.8.4",
4
4
  "description": "Alvin Bot โ€” Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,15 +25,39 @@ describe("ClaudeSDKProvider.isAvailable", () => {
25
25
  vi.resetModules();
26
26
  });
27
27
 
28
- it("returns false when `claude -p` returns 'Not logged in'", async () => {
29
- // First call: --version succeeds
30
- // Second call: -p 'ping' returns "Not logged in ยท Please run /login"
28
+ it("returns true when `claude auth status` reports loggedIn: true", async () => {
29
+ // Sequence: --version then auth status (JSON)
31
30
  execFileMock
32
31
  .mockImplementationOnce((_p, _a, _o, cb) =>
33
32
  cb(null, { stdout: "1.0.0\n", stderr: "" }),
34
33
  )
35
34
  .mockImplementationOnce((_p, _a, _o, cb) =>
36
- cb(null, { stdout: "Not logged in ยท Please run /login", stderr: "" }),
35
+ cb(null, {
36
+ stdout: JSON.stringify({
37
+ loggedIn: true,
38
+ authMethod: "claude.ai",
39
+ subscriptionType: "max",
40
+ }),
41
+ stderr: "",
42
+ }),
43
+ );
44
+
45
+ const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
46
+ const p = new ClaudeSDKProvider();
47
+ const result = await p.isAvailable();
48
+ expect(result).toBe(true);
49
+ });
50
+
51
+ it("returns false when `claude auth status` reports loggedIn: false", async () => {
52
+ execFileMock
53
+ .mockImplementationOnce((_p, _a, _o, cb) =>
54
+ cb(null, { stdout: "1.0.0\n", stderr: "" }),
55
+ )
56
+ .mockImplementationOnce((_p, _a, _o, cb) =>
57
+ cb(null, {
58
+ stdout: JSON.stringify({ loggedIn: false }),
59
+ stderr: "",
60
+ }),
37
61
  );
38
62
 
39
63
  const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
@@ -42,11 +66,15 @@ describe("ClaudeSDKProvider.isAvailable", () => {
42
66
  expect(result).toBe(false);
43
67
  });
44
68
 
45
- it("returns true when `claude -p` returns a normal response", async () => {
69
+ it("falls back to `claude -p` probe when `auth status` fails (older CLI)", async () => {
70
+ // Sequence: --version โ†’ auth status rejects โ†’ -p ping succeeds
46
71
  execFileMock
47
72
  .mockImplementationOnce((_p, _a, _o, cb) =>
48
73
  cb(null, { stdout: "1.0.0\n", stderr: "" }),
49
74
  )
75
+ .mockImplementationOnce((_p, _a, _o, cb) =>
76
+ cb(new Error("unknown command: auth status"), { stdout: "", stderr: "" }),
77
+ )
50
78
  .mockImplementationOnce((_p, _a, _o, cb) =>
51
79
  cb(null, { stdout: "pong", stderr: "" }),
52
80
  );
@@ -56,6 +84,24 @@ describe("ClaudeSDKProvider.isAvailable", () => {
56
84
  const result = await p.isAvailable();
57
85
  expect(result).toBe(true);
58
86
  });
87
+
88
+ it("falls back to `claude -p` probe and detects 'Not logged in' text", async () => {
89
+ execFileMock
90
+ .mockImplementationOnce((_p, _a, _o, cb) =>
91
+ cb(null, { stdout: "1.0.0\n", stderr: "" }),
92
+ )
93
+ .mockImplementationOnce((_p, _a, _o, cb) =>
94
+ cb(new Error("auth status not supported"), { stdout: "", stderr: "" }),
95
+ )
96
+ .mockImplementationOnce((_p, _a, _o, cb) =>
97
+ cb(null, { stdout: "Not logged in ยท Please run /login", stderr: "" }),
98
+ );
99
+
100
+ const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
101
+ const p = new ClaudeSDKProvider();
102
+ const result = await p.isAvailable();
103
+ expect(result).toBe(false);
104
+ });
59
105
  });
60
106
 
61
107
  describe("ClaudeSDKProvider โ€” isAuthErrorOutput helper", () => {