bloby-bot 0.53.10 → 0.54.11

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,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.53.10",
3
+ "version": "0.54.11",
4
4
  "releaseNotes": [
5
5
  "1. New Morphy animation system: config-driven sprites loaded from /morphy/*.json",
6
6
  "2. Swapped teleporting (splash) and headphones (bubble + chat) to the new format",
@@ -61,7 +61,7 @@
61
61
  "@streamdown/code": "^1.1.1",
62
62
  "@tailwindcss/vite": "^4.2.0",
63
63
  "@vitejs/plugin-react": "^6.0.1",
64
- "@whiskeysockets/baileys": "^7.0.0-rc11",
64
+ "@whiskeysockets/baileys": "7.0.0-rc13",
65
65
  "better-sqlite3": "^12.6.2",
66
66
  "class-variance-authority": "^0.7.1",
67
67
  "clsx": "^2.1.1",
package/shared/config.ts CHANGED
@@ -11,6 +11,11 @@ export interface ChannelConfig {
11
11
  skill?: string;
12
12
  /** Opt-in: process messages in group chats (default false). Channel mode ignores this. */
13
13
  allowGroups?: boolean;
14
+ /** Assistant mode only. When false (default) ONLY the account owner can trigger the agent
15
+ * with `@botname`. When true, ANYONE who tags the bot (in a DM or group) can drive it.
16
+ * DANGER: the triggerer gains control of an agent that can run Bash, edit files, etc.
17
+ * Only enable for fully trusted shared use (e.g. a partner). See the WhatsApp SKILL.md. */
18
+ allowOthersToTrigger?: boolean;
14
19
  }
15
20
 
16
21
  export interface AlexaChannelConfig {
@@ -14,6 +14,15 @@ let intentionallyStopped = false;
14
14
  let gaveUp = false;
15
15
  const MAX_RESTARTS = 3;
16
16
  const STABLE_THRESHOLD = 30_000; // 30s — if backend ran this long, it wasn't a crash loop
17
+ // Rolling-window backstop: the 30s "stable" rule resets the consecutive counter, so a backend
18
+ // that crashes every ~35s would otherwise restart forever. Give up if it crashes too often within
19
+ // the window (kept generous so a legitimately long-running-then-crashing backend isn't penalized).
20
+ let crashTimes: number[] = [];
21
+ const CRASH_WINDOW_MS = 5 * 60_000; // 5 min
22
+ const CRASH_WINDOW_MAX = 6; // > this many crashes in the window → give up regardless of the 30s reset
23
+ // Called once when the backend gives up, so the supervisor can tell the live chat (which exists
24
+ // precisely so the user can be told to fix it). Set via setBackendGiveUpHandler; logging-only default.
25
+ let onGiveUp: (() => void) | null = null;
17
26
 
18
27
  /** Extra env vars injected into every backend spawn (e.g. BLOBY_AGENT_SECRET) */
19
28
  let extraEnv: Record<string, string> = {};
@@ -23,6 +32,12 @@ export function setBackendEnv(env: Record<string, string>): void {
23
32
  extraEnv = { ...extraEnv, ...env };
24
33
  }
25
34
 
35
+ /** Register a callback fired once when the backend gives up (crash-looped past the limits).
36
+ * The supervisor wires this to broadcast a chat event so the user is told to fix it. */
37
+ export function setBackendGiveUpHandler(fn: () => void): void {
38
+ onGiveUp = fn;
39
+ }
40
+
26
41
  const LOG_FILE = path.join(WORKSPACE_DIR, '.backend.log');
27
42
 
28
43
  export function getBackendPort(basePort: number): number {
@@ -101,18 +116,24 @@ export function spawnBackend(port: number): ChildProcess {
101
116
 
102
117
  // Any unexpected exit (crash, SIGTERM, OOM, null code) — restart
103
118
  log.warn(`Backend exited unexpectedly (code ${code})`);
104
- // If backend was alive for >30s, it's not a crash loop — reset counter
105
- if (Date.now() - lastSpawnTime > STABLE_THRESHOLD) {
119
+ // Track crashes in a rolling window (backstop for the 30s-reset crash-loop hole).
120
+ const now = Date.now();
121
+ crashTimes = crashTimes.filter((t) => now - t < CRASH_WINDOW_MS);
122
+ crashTimes.push(now);
123
+ const windowExceeded = crashTimes.length > CRASH_WINDOW_MAX;
124
+ // If backend was alive for >30s, it's not a crash loop — reset the consecutive counter.
125
+ if (now - lastSpawnTime > STABLE_THRESHOLD) {
106
126
  restarts = 0;
107
127
  }
108
- if (restarts < MAX_RESTARTS) {
128
+ if (!windowExceeded && restarts < MAX_RESTARTS) {
109
129
  restarts++;
110
130
  const delay = Math.min(1000 * restarts, 5000);
111
131
  log.info(`Restarting backend (${restarts}/${MAX_RESTARTS}, delay ${delay}ms)...`);
112
132
  setTimeout(() => spawnBackend(port), delay);
113
133
  } else {
114
134
  gaveUp = true;
115
- log.error('Backend failed too many times. Use Bloby chat to debug.');
135
+ log.error(`Backend failed too many times${windowExceeded ? ` (${crashTimes.length} crashes in ${CRASH_WINDOW_MS / 60000}min)` : ''}. Use Bloby chat to debug.`);
136
+ try { onGiveUp?.(); } catch {}
116
137
  }
117
138
  });
118
139
 
@@ -212,4 +233,8 @@ export function isBackendStopping(): boolean {
212
233
 
213
234
  export function resetBackendRestarts(): void {
214
235
  restarts = 0;
236
+ // A deliberate restart (file edit, user fix, scheduler) is a fresh attempt — clear the rolling
237
+ // crash window too so a just-fixed backend gets a clean slate (deliberate stops never record a
238
+ // crash, so this only matters right after a give-up + fix).
239
+ crashTimes = [];
215
240
  }
@@ -83,6 +83,11 @@ interface DebounceEntry {
83
83
  /** Per-conversation accumulator for streaming bot text → WhatsApp. */
84
84
  export interface WaStreamState {
85
85
  chunkBuf: string;
86
+ /** True once the CURRENT turn has consumed its routing target (via `bot:response` or
87
+ * `bot:error`). Reset on `bot:turn-complete`. Guards the turn-complete safety-net drain so a
88
+ * normal turn — which already consumed on `bot:response` — never double-drains and eats the
89
+ * NEXT queued message's target (the root cause of chat↔WhatsApp bleed and DM-answered-in-group). */
90
+ consumedThisTurn?: boolean;
86
91
  }
87
92
 
88
93
  /** Agent-turn events that carry per-turn content. Broadcast only for dashboard surfaces
@@ -489,6 +494,7 @@ export class ChannelManager {
489
494
 
490
495
  if (type === 'bot:response' && eventData?.content && convId) {
491
496
  const target = this.consumeRoute(convId);
497
+ state.consumedThisTurn = true;
492
498
  const remaining = state.chunkBuf.trim();
493
499
  state.chunkBuf = '';
494
500
 
@@ -514,20 +520,46 @@ export class ChannelManager {
514
520
  return;
515
521
  }
516
522
 
517
- // Turn ended (or errored) without a bot:response — drain the head entry so it
518
- // doesn't bleed into the next turn's reply. The SDK guarantees one response per
519
- // pushed input; this safety net covers aborts, empty turns, and provider errors.
520
- if ((type === 'bot:turn-complete' || type === 'bot:error') && convId) {
521
- const head = this.peekRoute(convId);
522
- if (head) {
523
+ // Turn errored without a usable reply — drain THIS turn's route so it can't bleed into the
524
+ // next turn. Guarded by `consumedThisTurn`: if `bot:response` already fired this turn it
525
+ // consumed the target, so we must NOT drain again.
526
+ if (type === 'bot:error' && convId) {
527
+ if (!state.consumedThisTurn) {
523
528
  const dropped = this.consumeRoute(convId);
524
- log.warn(`[channels] ${type} without bot:response — dropping pending route (surface=${dropped?.surface}, to=${dropped?.waSendTo || 'none'})`);
525
- if (dropped?.surface === 'alexa') {
526
- const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
527
- alexa?.rejectHead(convId, type);
529
+ if (dropped) {
530
+ log.warn(`[channels] bot:error without bot:response — dropping pending route (surface=${dropped.surface}, to=${dropped.waSendTo || 'none'})`);
531
+ if (dropped.surface === 'alexa') {
532
+ const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
533
+ alexa?.rejectHead(convId, type);
534
+ }
528
535
  }
536
+ state.consumedThisTurn = true;
529
537
  }
530
538
  state.chunkBuf = '';
539
+ return;
540
+ }
541
+
542
+ // Turn finished. Drain the head ONLY if this turn never consumed its route — i.e. a true
543
+ // empty turn (no `bot:response`, no `bot:error`; see harness claude.ts which always emits
544
+ // `bot:turn-complete` after every result). A normal turn already consumed on `bot:response`,
545
+ // so draining here would eat the NEXT queued message's target and every later reply would
546
+ // land on the wrong surface (chat↔WhatsApp bleed, DM answered in a group). Reset the per-turn
547
+ // flag afterwards so the next turn starts clean. Turns are strictly sequential per conversation
548
+ // (single input queue, one `result` at a time), so this per-conversation flag is race-free.
549
+ if (type === 'bot:turn-complete' && convId) {
550
+ if (!state.consumedThisTurn) {
551
+ const head = this.peekRoute(convId);
552
+ if (head) {
553
+ const dropped = this.consumeRoute(convId);
554
+ log.warn(`[channels] turn-complete without bot:response — dropping pending route (surface=${dropped?.surface}, to=${dropped?.waSendTo || 'none'})`);
555
+ if (dropped?.surface === 'alexa') {
556
+ const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
557
+ alexa?.rejectHead(convId, type);
558
+ }
559
+ }
560
+ }
561
+ state.consumedThisTurn = false;
562
+ state.chunkBuf = '';
531
563
  }
532
564
  }
533
565
 
@@ -556,6 +588,22 @@ export class ChannelManager {
556
588
  return config.channels?.[channel];
557
589
  }
558
590
 
591
+ /** Robust "is this the account owner's own self-chat?" check.
592
+ *
593
+ * Keys purely on JID equality (`isSelfChat` — the chat resolves to the owner's OWN number,
594
+ * computed in whatsapp.ts from `ownPhoneJid` + LID translation). This is authoritative and is
595
+ * UNAFFECTED by Baileys' `fromMe` decode regressions (e.g. the rc11→rc13 protocolMessage
596
+ * `fromMe=false` bug): the chat JID of a self-message is still the owner's own number even when
597
+ * `fromMe` decodes wrong. So we deliberately do NOT also require `fromMe` (which the old gate
598
+ * did — that's what silently dropped self-messages under rc11).
599
+ *
600
+ * We also deliberately do NOT treat `fromMe` alone as self-chat: `fromMe` is true for the owner
601
+ * messaging a CONTACT from a linked device too, so a `fromMe`-based OR would misroute those into
602
+ * the admin brain. Only own-number JID equality is safe and false-positive-free. */
603
+ private isOwnerSelfChat(isSelfChat: boolean, isGroup: boolean): boolean {
604
+ return !isGroup && isSelfChat;
605
+ }
606
+
559
607
  /** Handle an incoming message from any channel — debounces rapid messages from the same sender.
560
608
  *
561
609
  * Per-mode behavior is decided here. To add a new mode: extend the gating block below
@@ -586,26 +634,36 @@ export class ChannelManager {
586
634
  if (!channelConfig.allowGroups) return;
587
635
  }
588
636
 
637
+ // Owner self-chat — JID-based, immune to Baileys `fromMe` decode regressions.
638
+ const selfChat = this.isOwnerSelfChat(isSelfChat, isGroup);
639
+
589
640
  // ── Channel mode: ONLY respond to self-chat ──
590
641
  if (mode === 'channel') {
591
- if (!fromMe || !isSelfChat) return;
642
+ if (!selfChat) return;
592
643
  }
593
644
 
594
- // ── Business mode: filter outgoing (except self-chat) ──
595
- if (mode === 'business' && fromMe && !isSelfChat) return;
645
+ // ── Business mode: filter outgoing to others (your messages to customers, not self-chat) ──
646
+ if (mode === 'business' && fromMe && !selfChat) return;
596
647
 
597
648
  // ── Assistant mode ──
598
649
  // Self-chat: falls through to debounce (processed as admin)
599
- // Others' messages or my untriggered messages: store for context, don't invoke
600
- // My messages with @botname trigger: falls through to debounce → agent
601
- if (mode === 'assistant' && !(fromMe && isSelfChat)) {
650
+ // Others' messages or untriggered messages: store for context, don't invoke
651
+ // Triggered messages: fall through to debounce → agent (owner always; others only if opted-in)
652
+ if (mode === 'assistant' && !selfChat) {
602
653
  // Store every message for context (both mine and theirs) — keyed by the chat (group or 1:1)
603
654
  this.storeAssistantContext(channel, chatJid, senderName, text, fromMe);
604
655
 
605
- // Only continue if it's me AND the message contains the trigger
656
+ // Trigger must be present.
606
657
  const botName = loadConfig().username || 'bloby';
607
658
  const triggerPattern = new RegExp(`(?:^|\\n)\\s*@${botName}[:\\s]`, 'i');
608
- if (!fromMe || !triggerPattern.test(text)) return;
659
+ if (!triggerPattern.test(text)) return;
660
+
661
+ // Who may drive the agent? By default ONLY the account owner (fromMe). When the channel is
662
+ // explicitly configured with `allowOthersToTrigger`, anyone who tags the bot can — a
663
+ // deliberately dangerous shared-control mode (the triggerer gets an agent with Bash, file
664
+ // access, etc.; see the WhatsApp SKILL.md disclaimer).
665
+ const allowOthers = channelConfig.allowOthersToTrigger === true;
666
+ if (!fromMe && !allowOthers) return;
609
667
  // Falls through to debounce → flushDebounce → handleAssistantMessage
610
668
  }
611
669
 
@@ -660,8 +718,12 @@ export class ChannelManager {
660
718
  // Reply identifier — strip JID suffix to get a stable id (phone for 1:1, group hash for groups)
661
719
  const chatId = chatJid.replace(/@.*/, '');
662
720
 
721
+ // Owner self-chat — JID-based, immune to Baileys `fromMe` decode regressions (matches the
722
+ // gate in handleInboundMessage so a self-message can't pass one check and fail the other).
723
+ const selfChat = this.isOwnerSelfChat(isSelfChat, isGroup);
724
+
663
725
  // Route based on mode and role
664
- if (mode === 'channel' || (mode === 'business' && fromMe && isSelfChat) || (mode === 'assistant' && fromMe && isSelfChat)) {
726
+ if (mode === 'channel' || (mode === 'business' && selfChat) || (mode === 'assistant' && selfChat)) {
665
727
  // Admin (self-chat in any mode)
666
728
  const message: InboundMessage = {
667
729
  channel,
@@ -15,6 +15,11 @@ export interface ChannelConfig {
15
15
  skill?: string;
16
16
  /** Opt-in: process messages in group chats (default false). Channel mode ignores this. */
17
17
  allowGroups?: boolean;
18
+ /** Assistant mode only. When false (default) ONLY the account owner (fromMe) can trigger
19
+ * the agent with `@botname`. When true, ANYONE who tags the bot (DM or group) can drive it.
20
+ * DANGER: the triggerer gains full control of an agent that can run Bash, edit files, etc.
21
+ * Enable only for fully trusted shared use. See the WhatsApp SKILL.md disclaimer. */
22
+ allowOthersToTrigger?: boolean;
18
23
  }
19
24
 
20
25
  export interface InboundMessageAttachment {
@@ -1,5 +1,6 @@
1
1
  import { useState } from 'react';
2
2
  import { Check, KeyRound, Loader2, AlertCircle } from 'lucide-react';
3
+ import { authFetch } from '../../lib/auth';
3
4
 
4
5
  export interface EnvField {
5
6
  name: string;
@@ -34,7 +35,7 @@ export default function EnvForm({ group }: Props) {
34
35
  setErrorMsg('');
35
36
 
36
37
  try {
37
- const res = await fetch('/api/env', {
38
+ const res = await authFetch('/api/env', {
38
39
  method: 'POST',
39
40
  headers: { 'Content-Type': 'application/json' },
40
41
  body: JSON.stringify({ vars: Object.fromEntries(filled) }),
@@ -191,7 +191,7 @@ async function buildConversationOptions(
191
191
  recentMessages?: RecentMessage[],
192
192
  ): Promise<Omit<Options, 'abortController' | 'stderr'>> {
193
193
  const memoryFiles = readMemoryFiles();
194
- const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
194
+ const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'claude');
195
195
  let systemPrompt = basePrompt;
196
196
  systemPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
197
197
 
@@ -594,7 +594,7 @@ export async function startBlobyAgentQuery(
594
594
  if (supportPrompt) {
595
595
  enrichedPrompt = supportPrompt;
596
596
  } else {
597
- const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
597
+ const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'claude');
598
598
  enrichedPrompt = basePrompt;
599
599
  enrichedPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
600
600
 
@@ -614,6 +614,15 @@ export async function startBlobyAgentQuery(
614
614
 
615
615
  activeQueries.set(conversationId, { abortController });
616
616
 
617
+ // Hard watchdog: a hung CLI subprocess (network stall, stuck MCP) would otherwise leave the
618
+ // `for await` loop pending forever — the finally never runs, bot:done never fires, and the
619
+ // caller's per-conversation slot (WhatsApp activeAgents / scheduler) is pinned for good. Abort
620
+ // after 5 min so the finally always emits bot:done. Cleared on normal completion.
621
+ const watchdog = setTimeout(() => {
622
+ log.warn(`[bloby-agent] One-shot query timed out (5m) — aborting conv=${conversationId}`);
623
+ abortController.abort();
624
+ }, 300_000);
625
+
617
626
  let fullText = '';
618
627
  const usedTools = new Set<string>();
619
628
  let stderrBuf = '';
@@ -705,6 +714,7 @@ export async function startBlobyAgentQuery(
705
714
  onMessage('bot:error', { conversationId, error: errMsg });
706
715
  }
707
716
  } finally {
717
+ clearTimeout(watchdog);
708
718
  activeQueries.delete(conversationId);
709
719
  const FILE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'];
710
720
  const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));