bloby-bot 0.66.1 → 0.67.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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.66.1",
3
+ "version": "0.67.0",
4
4
  "releaseNotes": [
5
- "1. Fix: image (and audio) attachments now render in chat again /api/files is fetched with the auth token instead of a raw <img> src that 401'd after the endpoint hardening",
6
- "2. Affects chat thumbnails, the image lightbox, voice-note playback, and agent image cards",
7
- "3. Chat now renders Mac actions instead of raw tags: <mac_actions> arrays (and legacy <morphy_action>) fan out to clean \"Agent actions: Point at screen N\" chips, and card actions render as notch cards"
5
+ "1. Fix: agent self-update now actively relaunches the daemon (launchctl/systemctl) instead of relying on launchd KeepAlive so `update` from chat/pulse comes back up reliably, matching manual `bloby update`",
6
+ "2. New agent control surface (/__bloby/control/*): restart-and-verify the backend in-turn, tail backend + frontend logs, and acked self-update — replacing the flaky touch .update/.restart triggers",
7
+ "3. Frontend errors (runtime, console, Vite) are now captured so the \"Copy error for your agent\" button is never empty"
8
8
  ],
9
9
  "description": "Self-hosted, self-evolving AI agent with its own dashboard.",
10
10
  "type": "module",
@@ -15,7 +15,6 @@
15
15
  "files": [
16
16
  "LICENSE",
17
17
  "bin/",
18
- "cli/",
19
18
  "dist-bloby/",
20
19
  "supervisor/",
21
20
  "worker/",
@@ -329,6 +329,7 @@ printf "\n"
329
329
  printf " ${BLUE}bloby init${RESET} Set up your bot\n"
330
330
  printf " ${BLUE}bloby start${RESET} Start your bot\n"
331
331
  printf " ${BLUE}bloby status${RESET} Check if it's running\n"
332
+ printf " ${BLUE}bloby help${RESET} All commands\n"
332
333
  printf "\n"
333
334
  printf " ${PINK}>${RESET} Run ${BLUE}bloby init${RESET} to begin.\n"
334
335
  printf " ${DIM}(Open a new terminal if 'bloby' isn't found yet)${RESET}\n"
@@ -221,6 +221,8 @@ export class ChannelManager {
221
221
  /** Start WhatsApp connection (triggers QR flow if no credentials) */
222
222
  async connectWhatsApp(): Promise<void> {
223
223
  let provider = this.providers.get('whatsapp');
224
+ // Already linked and online — don't tear down a live socket to start a new one
225
+ if (provider?.getStatus().connected) return;
224
226
  if (!provider) {
225
227
  const whatsapp = new WhatsAppChannel(
226
228
  (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Atomic multi-file auth state for Baileys.
3
+ *
4
+ * Replaces Baileys' useMultiFileAuthState, whose writeData is a bare fs.writeFile
5
+ * (truncate-then-write, no rename). A process death between the truncate and the
6
+ * write leaves a 0-byte creds.json; on the next boot Baileys' readData swallows the
7
+ * JSON.parse error and silently mints a FRESH identity — the session looks "logged
8
+ * out" and the user is forced to re-scan the QR even though WhatsApp never unlinked
9
+ * the device. creds.update fires constantly while connected (key rotations,
10
+ * app-state sync), so that corruption window recurs for the whole session.
11
+ *
12
+ * Hardening over upstream:
13
+ * - Atomic writes: every file is written to a temp name then rename()d into place,
14
+ * so a kill at any moment leaves the old or the new content — never a truncated file.
15
+ * - creds.json is mirrored to creds.json.bak on every save; on load, a missing or
16
+ * corrupt creds.json is restored from the backup instead of re-initializing.
17
+ * - A corrupt creds.json with no usable backup is quarantined (renamed) so
18
+ * hasCredentials() stops treating the dead session as linked.
19
+ */
20
+
21
+ import { BufferJSON, initAuthCreds, proto } from '@whiskeysockets/baileys';
22
+ import type { AuthenticationCreds, AuthenticationState, SignalDataTypeMap } from '@whiskeysockets/baileys';
23
+ import { mkdir, readFile, rename, unlink, writeFile } from 'fs/promises';
24
+ import fs from 'fs';
25
+ import path from 'path';
26
+ import { log } from '../../shared/logger.js';
27
+
28
+ /** Serializes reads/writes per file path (Baileys events fire concurrently). In-process
29
+ * only — cross-process safety comes from the atomic rename, not from this. */
30
+ const fileQueues = new Map<string, Promise<unknown>>();
31
+
32
+ function queued<T>(file: string, task: () => Promise<T>): Promise<T> {
33
+ const prev = fileQueues.get(file) || Promise.resolve();
34
+ const next = prev.then(task, task);
35
+ // Store a value-free tail (a resolved read would otherwise pin the file's contents
36
+ // in memory forever) and drop the entry once its queue drains.
37
+ const tail = next.then(() => undefined, () => undefined);
38
+ fileQueues.set(file, tail);
39
+ void tail.then(() => {
40
+ if (fileQueues.get(file) === tail) fileQueues.delete(file);
41
+ });
42
+ return next;
43
+ }
44
+
45
+ /** Await everything currently queued for an auth folder — drained on disconnect so a
46
+ * process exit right after can't clip a signal-key or creds write. */
47
+ export function flushAuthWrites(folder: string): Promise<void> {
48
+ const prefix = folder.endsWith(path.sep) ? folder : folder + path.sep;
49
+ const tails: Promise<unknown>[] = [];
50
+ for (const [file, tail] of fileQueues) {
51
+ if (file.startsWith(prefix)) tails.push(tail);
52
+ }
53
+ return Promise.all(tails).then(() => undefined);
54
+ }
55
+
56
+ /** Same name mangling as upstream so existing auth dirs keep working as-is. */
57
+ const fixFileName = (file: string) => file.replace(/\//g, '__').replace(/:/g, '-');
58
+
59
+ /** True when the parsed object looks like real Baileys credentials (not null/empty/garbage). */
60
+ function isValidCreds(creds: unknown): creds is AuthenticationCreds {
61
+ const c = creds as AuthenticationCreds | null;
62
+ return !!(
63
+ c && typeof c === 'object' &&
64
+ c.noiseKey && c.signedIdentityKey && c.signedPreKey &&
65
+ typeof c.registrationId === 'number'
66
+ );
67
+ }
68
+
69
+ async function writeAtomic(filePath: string, data: string): Promise<void> {
70
+ const tmp = `${filePath}.${process.pid}.tmp`;
71
+ try {
72
+ await writeFile(tmp, data);
73
+ await rename(tmp, filePath);
74
+ } catch (err) {
75
+ try { await unlink(tmp); } catch {}
76
+ throw err;
77
+ }
78
+ }
79
+
80
+ export interface AtomicAuthState {
81
+ state: AuthenticationState;
82
+ saveCreds: () => Promise<void>;
83
+ /** creds.json was missing/corrupt but recovered from creds.json.bak */
84
+ restoredFromBackup: boolean;
85
+ /** No usable credentials found — a brand-new identity was initialized (pairing needed) */
86
+ freshIdentity: boolean;
87
+ }
88
+
89
+ export async function useAtomicMultiFileAuthState(folder: string): Promise<AtomicAuthState> {
90
+ await mkdir(folder, { recursive: true });
91
+
92
+ const filePath = (file: string) => path.join(folder, fixFileName(file));
93
+
94
+ // Sweep temp files orphaned by a previous process death mid-write
95
+ try {
96
+ for (const f of fs.readdirSync(folder)) {
97
+ if (f.endsWith('.tmp')) {
98
+ try { fs.unlinkSync(path.join(folder, f)); } catch {}
99
+ }
100
+ }
101
+ } catch {}
102
+
103
+ const readData = async (file: string): Promise<any> => {
104
+ try {
105
+ const data = await queued(filePath(file), () => readFile(filePath(file), { encoding: 'utf-8' }));
106
+ return JSON.parse(data, BufferJSON.reviver);
107
+ } catch {
108
+ return null;
109
+ }
110
+ };
111
+
112
+ const writeData = (data: unknown, file: string): Promise<void> => {
113
+ // Snapshot synchronously — the creds object mutates while writes are queued
114
+ const json = JSON.stringify(data, BufferJSON.replacer);
115
+ return queued(filePath(file), () => writeAtomic(filePath(file), json));
116
+ };
117
+
118
+ const removeData = (file: string): Promise<void> =>
119
+ queued(filePath(file), async () => {
120
+ try { await unlink(filePath(file)); } catch {}
121
+ });
122
+
123
+ // ── Load creds, recovering from backup when the primary is missing/corrupt ──
124
+ let creds: AuthenticationCreds | null = await readData('creds.json');
125
+ let restoredFromBackup = false;
126
+ if (!isValidCreds(creds)) {
127
+ const backup = await readData('creds.json.bak');
128
+ if (isValidCreds(backup)) {
129
+ creds = backup;
130
+ restoredFromBackup = true;
131
+ await writeData(backup, 'creds.json');
132
+ log.warn('[whatsapp] creds.json was missing or corrupt — restored from creds.json.bak');
133
+ } else {
134
+ if (fs.existsSync(filePath('creds.json'))) {
135
+ // Present but unreadable and no usable backup: quarantine it so the dead
136
+ // session isn't half-trusted, and start a clean pairing flow.
137
+ const quarantine = filePath(`creds.json.corrupt-${Date.now()}`);
138
+ try { await rename(filePath('creds.json'), quarantine); } catch {}
139
+ log.warn(`[whatsapp] creds.json is corrupt with no backup — quarantined as ${path.basename(quarantine)}; re-link required`);
140
+ }
141
+ // The corrupt backup must go too, or hasValidCredsFile() keeps presenting the
142
+ // dead session as "linked" on every future boot.
143
+ if (fs.existsSync(filePath('creds.json.bak'))) {
144
+ try { await rename(filePath('creds.json.bak'), filePath(`creds.json.bak.corrupt-${Date.now()}`)); } catch {}
145
+ }
146
+ creds = null;
147
+ }
148
+ }
149
+ const freshIdentity = !creds;
150
+ if (freshIdentity) {
151
+ // Any key material on disk is bound to a dead identity — sweep it so the fresh
152
+ // pairing starts clean instead of tripping over stale sessions and pre-keys.
153
+ // (Quarantined creds files are kept for forensics.)
154
+ try {
155
+ for (const f of fs.readdirSync(folder)) {
156
+ if (f === 'creds.json' || f.includes('.corrupt-')) continue;
157
+ try { fs.unlinkSync(path.join(folder, f)); } catch {}
158
+ }
159
+ } catch {}
160
+ }
161
+ const liveCreds: AuthenticationCreds = creds || initAuthCreds();
162
+
163
+ return {
164
+ state: {
165
+ creds: liveCreds,
166
+ keys: {
167
+ get: async (type, ids) => {
168
+ const data: { [_: string]: SignalDataTypeMap[typeof type] } = {};
169
+ await Promise.all(
170
+ ids.map(async (id) => {
171
+ let value = await readData(`${type}-${id}.json`);
172
+ if (type === 'app-state-sync-key' && value) {
173
+ value = proto.Message.AppStateSyncKeyData.fromObject(value);
174
+ }
175
+ data[id] = value;
176
+ }),
177
+ );
178
+ return data;
179
+ },
180
+ set: async (data) => {
181
+ const tasks: Promise<void>[] = [];
182
+ for (const category in data) {
183
+ const entries = data[category as keyof SignalDataTypeMap];
184
+ if (!entries) continue;
185
+ for (const id in entries) {
186
+ const value = entries[id];
187
+ const file = `${category}-${id}.json`;
188
+ tasks.push(value ? writeData(value, file) : removeData(file));
189
+ }
190
+ }
191
+ await Promise.all(tasks);
192
+ },
193
+ },
194
+ },
195
+ saveCreds: async () => {
196
+ // One snapshot for both files so primary and backup are always identical
197
+ const json = JSON.stringify(liveCreds, BufferJSON.replacer);
198
+ await queued(filePath('creds.json'), () => writeAtomic(filePath('creds.json'), json));
199
+ await queued(filePath('creds.json.bak'), () => writeAtomic(filePath('creds.json.bak'), json));
200
+ },
201
+ restoredFromBackup,
202
+ freshIdentity,
203
+ };
204
+ }
205
+
206
+ /** Sync check used by status endpoints: a linked session exists when creds.json (or its
207
+ * backup) is present and non-empty. A 0-byte creds.json — the artifact of an interrupted
208
+ * legacy write — no longer counts as "linked". */
209
+ export function hasValidCredsFile(folder: string): boolean {
210
+ for (const name of ['creds.json', 'creds.json.bak']) {
211
+ try {
212
+ if (fs.statSync(path.join(folder, name)).size > 0) return true;
213
+ } catch {}
214
+ }
215
+ return false;
216
+ }
@@ -4,9 +4,8 @@
4
4
  */
5
5
 
6
6
  import makeWASocket, {
7
- useMultiFileAuthState,
8
7
  makeCacheableSignalKeyStore,
9
- fetchLatestWaWebVersion,
8
+ fetchLatestBaileysVersion,
10
9
  downloadMediaMessage,
11
10
  DisconnectReason,
12
11
  Browsers,
@@ -20,6 +19,7 @@ import QRCode from 'qrcode';
20
19
  import pino from 'pino';
21
20
  import { DATA_DIR } from '../../shared/paths.js';
22
21
  import { log } from '../../shared/logger.js';
22
+ import { useAtomicMultiFileAuthState, hasValidCredsFile, flushAuthWrites } from './whatsapp-auth.js';
23
23
  import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
24
24
 
25
25
  const AUTH_DIR = path.join(DATA_DIR, 'channels', 'whatsapp', 'auth');
@@ -63,6 +63,17 @@ export class WhatsAppChannel implements ChannelProvider {
63
63
  private transcribe: TranscribeFn | null = null;
64
64
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
65
65
  private intentionalDisconnect = false;
66
+ /** Monotonic token — bumping it invalidates any in-flight connectInternal(), so a
67
+ * pending reconnect timer and a manual connect can't race into two live sockets
68
+ * (two sockets on one auth dir = 440 conflict loops + divergent creds writes). */
69
+ private connectGen = 0;
70
+ /** Consecutive failed reconnects — drives backoff; reset on successful open */
71
+ private reconnectAttempts = 0;
72
+ /** Auto-regenerated QR windows since the last explicit connect — caps the
73
+ * unattended pairing loop instead of cycling QR codes in the background forever */
74
+ private pairingRetries = 0;
75
+ /** Last pending creds write — drained on disconnect so process exit can't clip it */
76
+ private lastCredsWrite: Promise<unknown> = Promise.resolve();
66
77
 
67
78
  /** IDs of messages we sent — used to prevent echo loops */
68
79
  private sentMessageIds = new Set<string>();
@@ -87,11 +98,29 @@ export class WhatsAppChannel implements ChannelProvider {
87
98
 
88
99
  async connect(): Promise<void> {
89
100
  this.intentionalDisconnect = false;
101
+ this.reconnectAttempts = 0; // explicit connect starts a fresh backoff ladder
102
+ this.pairingRetries = 0;
90
103
  await this.connectInternal();
91
104
  }
92
105
 
106
+ /** Arm the reconnect timer. connectInternal can reject (disk errors during auth
107
+ * load) — without the catch + re-arm, a timer-driven failure would strand the
108
+ * channel offline with no retry until someone manually reconnects. */
109
+ private scheduleReconnect(delay: number): void {
110
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
111
+ this.reconnectTimer = setTimeout(() => {
112
+ this.connectInternal().catch((err: any) => {
113
+ if (this.intentionalDisconnect) return;
114
+ const next = Math.min(5000 * 2 ** this.reconnectAttempts++, 60_000);
115
+ log.warn(`[whatsapp] Reconnect attempt failed: ${err.message} — retrying in ${Math.round(next / 1000)}s`);
116
+ this.scheduleReconnect(next);
117
+ });
118
+ }, delay);
119
+ }
120
+
93
121
  async disconnect(): Promise<void> {
94
122
  this.intentionalDisconnect = true;
123
+ this.connectGen++; // invalidate any connectInternal() still in flight
95
124
  if (this.reconnectTimer) {
96
125
  clearTimeout(this.reconnectTimer);
97
126
  this.reconnectTimer = null;
@@ -103,6 +132,12 @@ export class WhatsAppChannel implements ChannelProvider {
103
132
  // Clear all typing intervals
104
133
  for (const interval of this.typingIntervals.values()) clearInterval(interval);
105
134
  this.typingIntervals.clear();
135
+ // Let in-flight auth writes (creds + signal keys) land before callers proceed to
136
+ // process.exit — writes are atomic now, but a lost update rolls session keys back.
137
+ await Promise.race([
138
+ Promise.all([this.lastCredsWrite, flushAuthWrites(AUTH_DIR)]),
139
+ new Promise((r) => setTimeout(r, 2000)),
140
+ ]);
106
141
  this.connected = false;
107
142
  this.qrData = null;
108
143
  this.qrSvg = null;
@@ -292,7 +327,9 @@ export class WhatsAppChannel implements ChannelProvider {
292
327
  }
293
328
 
294
329
  hasCredentials(): boolean {
295
- return fs.existsSync(path.join(AUTH_DIR, 'creds.json'));
330
+ // Validates content, not just existence — a 0-byte creds.json (interrupted legacy
331
+ // write) must not count as "linked", or boot lands in a silent QR loop.
332
+ return hasValidCredsFile(AUTH_DIR);
296
333
  }
297
334
 
298
335
  /** Delete stored credentials (for re-auth / logout) */
@@ -358,6 +395,14 @@ export class WhatsAppChannel implements ChannelProvider {
358
395
  }
359
396
 
360
397
  private async connectInternal(): Promise<void> {
398
+ const gen = ++this.connectGen;
399
+ // A pending reconnect must not fire on top of this attempt — clear it so the
400
+ // timer and a manual connect can't produce two sockets on the same auth dir.
401
+ if (this.reconnectTimer) {
402
+ clearTimeout(this.reconnectTimer);
403
+ this.reconnectTimer = null;
404
+ }
405
+
361
406
  // Clean up any existing socket before creating a new one
362
407
  if (this.sock) {
363
408
  try { this.sock.ev.removeAllListeners('creds.update'); } catch {}
@@ -370,18 +415,26 @@ export class WhatsAppChannel implements ChannelProvider {
370
415
  // Ensure auth directory exists
371
416
  fs.mkdirSync(AUTH_DIR, { recursive: true });
372
417
 
373
- const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
418
+ // Atomic-write auth state survives the kills/crashes that used to truncate
419
+ // creds.json, and recovers from creds.json.bak when the primary is corrupt.
420
+ const { state, saveCreds, freshIdentity } = await useAtomicMultiFileAuthState(AUTH_DIR);
421
+ if (gen !== this.connectGen || this.intentionalDisconnect) return; // superseded while loading auth
422
+ if (freshIdentity) log.info('[whatsapp] No valid credentials — starting pairing flow');
374
423
 
375
424
  // Suppress Baileys' noisy logging
376
425
  const logger = pino({ level: 'silent' }) as any;
377
426
 
378
427
  let version: [number, number, number] | undefined;
379
428
  try {
380
- const result = await fetchLatestWaWebVersion({});
429
+ // fetchLatestBaileysVersion = newest WA Web version the LIBRARY supports.
430
+ // (fetchLatestWaWebVersion scrapes web.whatsapp.com and can return a protocol
431
+ // revision newer than Baileys handles — a disconnect source.)
432
+ const result = await fetchLatestBaileysVersion();
381
433
  version = result.version;
382
434
  } catch {
383
435
  log.warn('[whatsapp] Could not fetch latest WA version — using default');
384
436
  }
437
+ if (gen !== this.connectGen || this.intentionalDisconnect) return; // superseded during version fetch
385
438
 
386
439
  const sock = makeWASocket({
387
440
  auth: {
@@ -390,18 +443,27 @@ export class WhatsAppChannel implements ChannelProvider {
390
443
  },
391
444
  version,
392
445
  browser: Browsers.macOS('Chrome'),
393
- printQRInTerminal: false,
394
446
  logger,
447
+ markOnlineOnConnect: false, // keep message notifications on the phone
395
448
  generateHighQualityLinkPreview: false,
449
+ // Baileys 7.x retry handshake: without this, inbound messages that need an
450
+ // E2EE session re-establishment are silently dropped (msg.message === null).
451
+ getMessage: async () => ({ conversation: '' }),
396
452
  });
397
453
 
398
454
  this.sock = sock;
399
455
 
400
- // Persist credential updates
401
- sock.ev.on('creds.update', saveCreds);
456
+ // Persist credential updates (fires constantly while connected — key rotations,
457
+ // app-state sync). Track the promise so disconnect() can drain it before exit.
458
+ sock.ev.on('creds.update', () => {
459
+ this.lastCredsWrite = saveCreds().catch((err: any) => {
460
+ log.warn(`[whatsapp] Failed to persist credentials: ${err.message}`);
461
+ });
462
+ });
402
463
 
403
464
  // Connection state changes
404
465
  sock.ev.on('connection.update', async (update) => {
466
+ if (this.sock !== sock) return; // stale event from a superseded socket
405
467
  const { connection, lastDisconnect, qr } = update;
406
468
 
407
469
  // QR code received — render to SVG
@@ -418,6 +480,7 @@ export class WhatsAppChannel implements ChannelProvider {
418
480
 
419
481
  if (connection === 'open') {
420
482
  this.connected = true;
483
+ this.reconnectAttempts = 0;
421
484
  this.qrData = null;
422
485
  this.qrSvg = null;
423
486
  this.buildLidMap();
@@ -453,9 +516,41 @@ export class WhatsAppChannel implements ChannelProvider {
453
516
  return;
454
517
  }
455
518
 
456
- // Any other disconnect try to reconnect
457
- log.info('[whatsapp] Reconnecting in 5s...');
458
- this.reconnectTimer = setTimeout(() => this.connectInternal(), 5000);
519
+ // Never paired (pair-success sets creds.registered before the post-pairing 515,
520
+ // so a registered=false close means the pairing window expired unused).
521
+ if (!state.creds.registered) {
522
+ if (state.creds.me) {
523
+ // Phantom identity from an uncompleted pairing-code attempt (Baileys sets
524
+ // creds.me as a placeholder when the code is requested). Reconnecting would
525
+ // try to LOG IN with an identity the server never registered → guaranteed
526
+ // 401 wipe. Reset to a clean unpaired state instead.
527
+ log.info('[whatsapp] Pairing code expired before confirmation — resetting credentials; connect again to retry');
528
+ await this.deleteCredentials();
529
+ } else if (this.pairingRetries < 2) {
530
+ // Fresh QR windows for a while (the link page is likely still open),
531
+ // then stop instead of cycling QR codes in the background forever.
532
+ this.pairingRetries++;
533
+ log.info(`[whatsapp] QR expired — generating a fresh one (retry ${this.pairingRetries}/2)`);
534
+ this.emitStatus();
535
+ this.scheduleReconnect(2000);
536
+ return;
537
+ } else {
538
+ log.info('[whatsapp] Pairing window closed without a scan — connect again to retry');
539
+ }
540
+ this.emitStatus();
541
+ return;
542
+ }
543
+
544
+ // restartRequired (515) is the normal post-pairing handoff — reconnect right
545
+ // away, but only the first time (a repeated-515 loop is a known Baileys failure
546
+ // mode; let it back off). Everything else backs off 5s → 60s so an offline
547
+ // network doesn't hot-loop.
548
+ const fastRestart = statusCode === DisconnectReason.restartRequired && this.reconnectAttempts === 0;
549
+ const delay = fastRestart ? 1000 : Math.min(5000 * 2 ** this.reconnectAttempts, 60_000);
550
+ this.reconnectAttempts++;
551
+ log.info(`[whatsapp] Reconnecting in ${Math.round(delay / 1000)}s...`);
552
+ this.emitStatus();
553
+ this.scheduleReconnect(delay);
459
554
  }
460
555
  });
461
556
 
@@ -1,6 +1,7 @@
1
1
  import http from 'http';
2
2
  import net from 'net';
3
3
  import fs from 'fs';
4
+ import os from 'os';
4
5
  import path from 'path';
5
6
  import { WebSocketServer, WebSocket } from 'ws';
6
7
  import { loadConfig, saveConfig } from '../shared/config.js';
@@ -1000,6 +1001,7 @@ ${connected
1000
1001
  ${!connected ? `<script>
1001
1002
  // Poll for status changes instead of full page reload (avoids SW reinstall cycle)
1002
1003
  let polling=true;
1004
+ let qrMisses=0;
1003
1005
  async function poll(){
1004
1006
  if(!polling) return;
1005
1007
  try{
@@ -1012,10 +1014,16 @@ ${!connected ? `<script>
1012
1014
  const wa=Array.isArray(statuses)?statuses.find(s=>s.channel==='whatsapp'):null;
1013
1015
  if(wa?.connected){location.reload();return}
1014
1016
  if(qrData.qr){
1017
+ qrMisses=0;
1015
1018
  const inner=document.querySelector('.qr-inner');
1016
1019
  if(inner) inner.innerHTML=qrData.qr;
1017
1020
  const loading=document.querySelector('.loading');
1018
1021
  if(loading) loading.remove();
1022
+ }else if(wa&&!wa.connected&&++qrMisses>=3){
1023
+ // QR window expired and background retries stopped — this page being open means
1024
+ // the user is still trying to link, so restart pairing for a fresh code.
1025
+ qrMisses=0;
1026
+ fetch('/api/channels/whatsapp/connect',{method:'POST'}).catch(()=>{});
1019
1027
  }
1020
1028
  }catch{}
1021
1029
  setTimeout(poll,3000);
@@ -3578,11 +3586,40 @@ ${alreadyLinked ? '' : `
3578
3586
  server.listen(config.port, () => {
3579
3587
  log.ok(`Supervisor on http://localhost:${config.port}`);
3580
3588
  log.ok(`Bloby chat at http://localhost:${config.port}/bloby`);
3589
+ writeRuntimeFile();
3581
3590
  if (config.tunnel.mode === 'off') {
3582
3591
  console.log('__READY__');
3583
3592
  }
3584
3593
  });
3585
3594
 
3595
+ // ~/.bloby/supervisor.json — the CLI's single source of truth for "is Bloby
3596
+ // running and which process is it". Written here (not by the CLI) because the
3597
+ // supervisor has at least four different parents (cli foreground, launchd,
3598
+ // systemd, docker) and only the supervisor itself is present in all of them.
3599
+ // The CLI validates pid liveness + command line, so a stale file after kill -9
3600
+ // is harmless.
3601
+ function writeRuntimeFile() {
3602
+ try {
3603
+ let version = 'unknown';
3604
+ try { version = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf-8')).version || 'unknown'; } catch {}
3605
+ fs.writeFileSync(path.join(DATA_DIR, 'supervisor.json'), JSON.stringify({
3606
+ pid: process.pid,
3607
+ startedAt: Date.now(),
3608
+ version,
3609
+ port: config.port,
3610
+ parent: process.env.INVOCATION_ID ? 'systemd' : (process.env.XPC_SERVICE_NAME?.includes('bloby') ? 'launchd' : 'other'),
3611
+ }, null, 2));
3612
+ } catch { /* best-effort — the CLI falls back to launchd/systemd queries */ }
3613
+ }
3614
+
3615
+ function removeRuntimeFile() {
3616
+ try {
3617
+ const p = path.join(DATA_DIR, 'supervisor.json');
3618
+ const cur = JSON.parse(fs.readFileSync(p, 'utf-8'));
3619
+ if (cur.pid === process.pid) fs.unlinkSync(p); // never delete a newer instance's file
3620
+ } catch {}
3621
+ }
3622
+
3586
3623
  // Track whether an agent is actively processing — file watcher defers restarts during active turns
3587
3624
  let agentQueryActive = false;
3588
3625
  let pendingBackendRestart = false; // Set when file watcher fires during agent turn
@@ -3683,8 +3720,14 @@ ${alreadyLinked ? '' : `
3683
3720
  try { fs.closeSync(logFd); } catch {}
3684
3721
  if (code === 0) {
3685
3722
  clearUpdateMarker(); // success (updated or already-latest) — don't re-run on the next boot
3686
- log.ok('Update completed — restarting with new version...');
3687
- process.exit(1); // non-zero triggers daemon manager to restart us onto the new code
3723
+ log.ok('Update completed — relaunching daemon onto the new version...');
3724
+ relaunchSupervisor();
3725
+ // NO process.exit here: the old reliance on launchd KeepAlive after exit(1) does NOT fire
3726
+ // when the supervisor runs in the foreground or the launchd job isn't loaded (the user hit
3727
+ // exactly this). relaunchSupervisor() actively reloads the daemon (mirroring the battle-
3728
+ // tested manual `bloby update` path); its `launchctl unload` / the new daemon's killPort
3729
+ // terminates THIS old process. If the daemon isn't installed at all (pure foreground dev),
3730
+ // the relaunch no-ops and we stay up on the old code rather than going down unrecoverably.
3688
3731
  } else {
3689
3732
  // Leave the marker so the next boot retries (bounded by attempts); allow another flush now.
3690
3733
  updateInProgress = false;
@@ -3702,6 +3745,51 @@ ${alreadyLinked ? '' : `
3702
3745
  }
3703
3746
  }
3704
3747
 
3748
+ /** Relaunch the supervisor onto freshly-updated code by actively (re)loading the daemon — the same
3749
+ * `launchctl unload/load` (macOS) / `systemctl restart` (Linux) that the battle-tested manual
3750
+ * `bloby update` uses. Spawned DETACHED + unref'd so it OUTLIVES this process when the relaunch's
3751
+ * unload terminates us. `sleep 1` lets the final HTTP ack flush first. If the daemon isn't
3752
+ * installed (foreground dev with no plist), `bloby daemon restart` no-ops — we then stay up on the
3753
+ * current code instead of going down with nothing to bring us back. Replaces the previous reliance
3754
+ * on launchd KeepAlive after process.exit, which never fires in the foreground / when unloaded. */
3755
+ function relaunchSupervisor(): void {
3756
+ // Under systemd we ARE the unit's main process: a non-zero exit triggers
3757
+ // Restart=on-failure and systemd respawns us onto the new code in ~5s. The
3758
+ // old detached `bloby daemon restart` path is a dead end here — it needs
3759
+ // sudo for systemctl and there is no TTY to ask for a password.
3760
+ if (process.env.INVOCATION_ID) {
3761
+ log.ok('Updated — exiting so systemd restarts the unit onto the new version...');
3762
+ try { removeRuntimeFile(); } catch {}
3763
+ try { const cfg = loadConfig(); delete cfg.tunnelUrl; saveConfig(cfg); } catch {}
3764
+ setTimeout(() => process.exit(1), 1000); // let the final HTTP ack flush
3765
+ return;
3766
+ }
3767
+ // No service installed (pure foreground dev, no plist/unit): `bloby restart`
3768
+ // now consolidates foreground instances INTO the daemon, so spawning it here
3769
+ // would kill this process and silently install a service nobody asked for.
3770
+ // Stay up on the old code instead — the human restarts when they choose.
3771
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.bloby.bot.plist');
3772
+ const unitPath = '/etc/systemd/system/bloby.service';
3773
+ if (!fs.existsSync(process.platform === 'darwin' ? plistPath : unitPath)) {
3774
+ log.warn('Updated, but no background service is installed — restart Bloby manually to load the new version.');
3775
+ return;
3776
+ }
3777
+ const cliPath = path.join(PKG_DIR, 'bin', 'cli.js');
3778
+ try {
3779
+ // `bloby daemon restart` boots the launchd job out and bootstraps it again; the
3780
+ // bootout terminates THIS process, so the relauncher must be detached + unref'd
3781
+ // to outlive it.
3782
+ const relauncher = cpSpawn(process.execPath, [cliPath, 'daemon', 'restart'], {
3783
+ detached: true,
3784
+ stdio: 'ignore',
3785
+ env: { ...process.env },
3786
+ });
3787
+ relauncher.unref();
3788
+ } catch (err) {
3789
+ log.error(`Relaunch failed to spawn: ${err instanceof Error ? err.message : err} — run \`bloby restart\` manually.`);
3790
+ }
3791
+ }
3792
+
3705
3793
  /** On boot, resume an update that was queued but never ran (supervisor died in the request→flush
3706
3794
  * window). Safe to auto-run: bin/cli.js update version-checks and no-ops if already latest, and
3707
3795
  * the marker's TTL + attempts cap prevent a restart loop. */
@@ -4083,6 +4171,7 @@ ${alreadyLinked ? '' : `
4083
4171
  stopTunnel();
4084
4172
  await stopViteDevServers();
4085
4173
  server.close();
4174
+ removeRuntimeFile();
4086
4175
  process.exit(0);
4087
4176
  };
4088
4177
  process.on('SIGINT', () => shutdown());
package/tsconfig.json CHANGED
@@ -15,6 +15,6 @@
15
15
  "@client/*": ["./workspace/client/src/*"]
16
16
  }
17
17
  },
18
- "include": ["server/**/*", "workspace/client/src/**/*", "workspace/backend/**/*", "cli/**/*", "vite.config.ts"],
18
+ "include": ["server/**/*", "workspace/client/src/**/*", "workspace/backend/**/*", "vite.config.ts"],
19
19
  "exclude": ["node_modules", "dist", "data"]
20
20
  }
@@ -1,31 +0,0 @@
1
- import { Command } from 'commander';
2
- import os from 'node:os';
3
-
4
- import { DATA_DIR } from '../core/config.js';
5
- import type { DaemonAction, DaemonConfig } from '../core/types.js';
6
- import { getAdapter } from '../platforms/index.js';
7
-
8
- export function registerDaemonCommand(program: Command) {
9
- const adapter = getAdapter();
10
-
11
- program
12
- .command('daemon <action>')
13
- .description('Manage the Bloby background daemon (install, start, stop, restart, status, logs, uninstall)')
14
- .action(async (action: string) => {
15
- const validActions: DaemonAction[] = ['install', 'start', 'stop', 'restart', 'status', 'logs', 'uninstall'];
16
-
17
- if (!validActions.includes(action as DaemonAction)) {
18
- console.error(`Invalid daemon action: ${action}`);
19
- process.exit(1);
20
- }
21
-
22
- const config: DaemonConfig = {
23
- user: process.env.SUDO_USER || os.userInfo().username,
24
- home: process.env.BLOBY_REAL_HOME || os.homedir(),
25
- nodePath: process.env.BLOBY_NODE_PATH || process.execPath,
26
- dataDir: DATA_DIR,
27
- };
28
-
29
- await adapter.handleDaemonAction(action as DaemonAction, config);
30
- });
31
- }