bloby-bot 0.70.12 → 0.71.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.
Files changed (65) hide show
  1. package/bin/cli.js +234 -48
  2. package/dist-bloby/assets/{bloby-DSNB0g4w.js → bloby-es6cZJzs.js} +6 -6
  3. package/dist-bloby/assets/globals-DBqwNiJV.css +2 -0
  4. package/dist-bloby/assets/{globals-B3cTbITX.js → globals-DN3F0CQE.js} +1 -1
  5. package/dist-bloby/assets/{highlighted-body-OFNGDK62-BLforpkr.js → highlighted-body-OFNGDK62-8PiOHw9p.js} +1 -1
  6. package/dist-bloby/assets/mermaid-GHXKKRXX-BJWX8urU.js +1 -0
  7. package/dist-bloby/assets/{onboard-Dn2Ws_G2.js → onboard-BKgy17OU.js} +1 -1
  8. package/dist-bloby/bloby.html +3 -3
  9. package/dist-bloby/onboard.html +3 -3
  10. package/package.json +3 -4
  11. package/scripts/install +156 -41
  12. package/scripts/install.ps1 +146 -29
  13. package/scripts/install.sh +156 -41
  14. package/shared/config.ts +37 -2
  15. package/shared/relay.ts +3 -1
  16. package/supervisor/channels/manager.ts +84 -44
  17. package/supervisor/channels/telegram.ts +57 -16
  18. package/supervisor/channels/types.ts +4 -1
  19. package/supervisor/channels/whatsapp.ts +57 -10
  20. package/supervisor/chat/OnboardWizard.tsx +0 -15
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +1 -1
  22. package/supervisor/chat/src/components/Chat/AuthedImage.tsx +16 -3
  23. package/supervisor/chat/src/components/Chat/BlobyImageCard.tsx +2 -2
  24. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +25 -8
  25. package/supervisor/chat/src/components/Chat/InputBar.tsx +62 -7
  26. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +37 -18
  27. package/supervisor/chat/src/components/Chat/MessageList.tsx +3 -3
  28. package/supervisor/chat/src/hooks/useChat.ts +52 -0
  29. package/supervisor/chat/src/lib/authedFile.ts +24 -12
  30. package/supervisor/file-saver.ts +92 -19
  31. package/supervisor/harnesses/attachment-policy.ts +111 -0
  32. package/supervisor/harnesses/claude.ts +62 -15
  33. package/supervisor/harnesses/codex.ts +69 -43
  34. package/supervisor/harnesses/pi/index.ts +367 -112
  35. package/supervisor/harnesses/pi/providers/humanize-error.ts +27 -2
  36. package/supervisor/harnesses/pi/providers/retry.ts +31 -0
  37. package/supervisor/harnesses/pi/providers/stream-anthropic.ts +31 -3
  38. package/supervisor/harnesses/pi/providers/stream-google.ts +26 -3
  39. package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +32 -9
  40. package/supervisor/harnesses/pi/providers/types.ts +29 -1
  41. package/supervisor/harnesses/pi/session.ts +143 -3
  42. package/supervisor/harnesses/pi/test-completion.ts +56 -0
  43. package/supervisor/harnesses/pi/tools/bash.ts +198 -22
  44. package/supervisor/harnesses/pi/tools/glob.ts +79 -0
  45. package/supervisor/harnesses/pi/tools/grep.ts +0 -0
  46. package/supervisor/harnesses/pi/tools/registry.ts +18 -6
  47. package/supervisor/harnesses/pi/tools/todo-write.ts +45 -0
  48. package/supervisor/harnesses/pi/tools/web-fetch.ts +129 -0
  49. package/supervisor/index.ts +93 -18
  50. package/supervisor/widget.js +19 -5
  51. package/worker/db.ts +2 -0
  52. package/worker/index.ts +18 -1
  53. package/worker/prompts/bloby-system-prompt-codex.txt +1 -1
  54. package/worker/prompts/bloby-system-prompt-pi.txt +6 -24
  55. package/worker/prompts/bloby-system-prompt.txt +1 -1
  56. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +4 -117
  57. package/workspace/client/src/components/Dashboard/deleteme_placeholders.tsx +194 -0
  58. package/workspace/client/src/components/Layout/Sidebar.tsx +52 -30
  59. package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +25 -15
  60. package/workspace/client/src/components/deleteme_onboarding/tour-theme.css +24 -0
  61. package/workspace/skills/mac/SKILL.md +13 -4
  62. package/dist-bloby/assets/globals-DyeW509Y.css +0 -2
  63. package/dist-bloby/assets/mermaid-GHXKKRXX-C1H_fSCU.js +0 -1
  64. package/supervisor/public/headphones_spritesheet.webp +0 -0
  65. package/supervisor/public/spritesheet.webp +0 -0
@@ -7,6 +7,23 @@ set -e
7
7
  # Downloads Node.js + Bloby into ~/.bloby — no system dependencies needed.
8
8
  # ─────────────────────────────────────────────────────────────────────────────
9
9
 
10
+ # Repair $HOME before deriving any install path. Under `sudo`, cron, CI, or a
11
+ # minimal shell, $HOME can be empty or "/" — without this we'd install into the
12
+ # wrong place (or /). Colors aren't defined yet, so this early error is plain.
13
+ if [ -z "${HOME:-}" ] || [ ! -d "${HOME:-}" ]; then
14
+ _whoami=$(id -un 2>/dev/null || echo "")
15
+ if [ -n "$_whoami" ] && command -v getent >/dev/null 2>&1; then
16
+ HOME=$(getent passwd "$_whoami" 2>/dev/null | cut -d: -f6)
17
+ elif [ -n "$_whoami" ] && command -v dscl >/dev/null 2>&1; then
18
+ HOME=$(dscl . -read "/Users/$_whoami" NFSHomeDirectory 2>/dev/null | awk '{print $2}')
19
+ fi
20
+ export HOME
21
+ fi
22
+ if [ -z "${HOME:-}" ] || [ ! -d "$HOME" ]; then
23
+ printf 'Error: could not determine your home directory ($HOME is unset).\n' >&2
24
+ exit 1
25
+ fi
26
+
10
27
  MIN_NODE_MAJOR=18
11
28
  NODE_VERSION="22.14.0"
12
29
  BLOBY_HOME="$HOME/.bloby"
@@ -14,6 +31,11 @@ TOOLS_DIR="$BLOBY_HOME/tools"
14
31
  NODE_DIR="$TOOLS_DIR/node"
15
32
  BIN_DIR="$BLOBY_HOME/bin"
16
33
  USE_SYSTEM_NODE=false
34
+ LIBC=""
35
+ # Temp paths the cleanup trap removes — kept empty until we own them so an early
36
+ # exit can never `rm -rf` an inherited value (e.g. the system $TMPDIR).
37
+ WORK_DIR=""
38
+ TMPFILE=""
17
39
 
18
40
  # Brand colors: #00ADFE (light) and #0158FB (deep) -- Morphy palette, 24-bit truecolor
19
41
  BLUE='\033[38;2;0;173;254m'
@@ -37,15 +59,47 @@ if [ ! -t 1 ]; then
37
59
  BLUE='' PINK='' YELLOW='' RED='' DIM='' BOLD='' RESET='' G1='' G2='' G3='' G4='' G5='' G6='' G7=''
38
60
  fi
39
61
 
40
- # Cleanup on exit (restore cursor, reset colors, remove temp files)
62
+ # Cleanup on exit (restore cursor, reset colors, remove temp files).
63
+ # Only remove paths we actually created — never an inherited/unset value.
41
64
  cleanup() {
42
65
  printf '\033[?25h' # show cursor
43
66
  printf "${RESET}"
44
- rm -f "$TMPFILE" 2>/dev/null
45
- rm -rf "$TMPDIR" 2>/dev/null
67
+ [ -n "$TMPFILE" ] && rm -f "$TMPFILE" 2>/dev/null
68
+ [ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR" 2>/dev/null
69
+ return 0
46
70
  }
47
71
  trap cleanup EXIT INT TERM
48
72
 
73
+ # ─── Download + integrity helpers ───────────────────────────────────────────
74
+
75
+ # download_file <url> <dest> — curl/wget with HTTPS floor, timeout, and retry.
76
+ download_file() {
77
+ if command -v curl >/dev/null 2>&1; then
78
+ curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 20 --retry 3 --retry-delay 2 -o "$2" "$1"
79
+ elif command -v wget >/dev/null 2>&1; then
80
+ wget -q --https-only --tries=3 --timeout=20 -O "$2" "$1"
81
+ else
82
+ printf " ${RED}✗${RESET} curl or wget is required to install Bloby\n"
83
+ exit 1
84
+ fi
85
+ }
86
+
87
+ # verify_sha256 <file> <expected-hex> — 0 match, 1 mismatch, 2 no tool available.
88
+ verify_sha256() {
89
+ _actual=""
90
+ if command -v sha256sum >/dev/null 2>&1; then
91
+ _actual=$(sha256sum "$1" 2>/dev/null | awk '{print $1}')
92
+ elif command -v shasum >/dev/null 2>&1; then
93
+ _actual=$(shasum -a 256 "$1" 2>/dev/null | awk '{print $1}')
94
+ else
95
+ return 2
96
+ fi
97
+ # No hash computed (tool failed / file vanished) → "skip" (2), not "mismatch" (1),
98
+ # so we warn rather than aborting with a misleading "tampered download".
99
+ [ -n "$_actual" ] || return 2
100
+ [ "$_actual" = "$2" ]
101
+ }
102
+
49
103
  printf "\n"
50
104
  printf "${G1}${BOLD} █▄ ${RESET}\n"
51
105
  printf "${G2}${BOLD} ▄ ▄ ██ ${RESET}\n"
@@ -83,6 +137,14 @@ detect_platform() {
83
137
  ;;
84
138
  esac
85
139
 
140
+ # Detect musl (Alpine): nodejs.org ships glibc builds only, so a bundled Node
141
+ # would segfault at runtime. Flag it now and require a system Node instead.
142
+ if [ "$PLATFORM" = "linux" ]; then
143
+ if [ -f /etc/alpine-release ] || (ldd --version 2>&1 | grep -qi musl); then
144
+ LIBC="musl"
145
+ fi
146
+ fi
147
+
86
148
  printf " ${DIM}Platform: ${PLATFORM}/${NODEARCH}${RESET}\n"
87
149
  }
88
150
 
@@ -115,35 +177,68 @@ install_node() {
115
177
  fi
116
178
  fi
117
179
 
118
- printf " ${BLUE}↓${RESET} Downloading Node.js v${NODE_VERSION}...\n"
180
+ # nodejs.org ships glibc-linked builds; on musl they segfault at runtime.
181
+ if [ "$LIBC" = "musl" ]; then
182
+ printf " ${RED}✗${RESET} Alpine/musl detected — Bloby's bundled Node.js requires glibc.\n"
183
+ printf " ${DIM}Install Node.js >= ${MIN_NODE_MAJOR} (e.g. ${BOLD}apk add nodejs npm${RESET}${DIM}) and re-run this installer.${RESET}\n"
184
+ exit 1
185
+ fi
119
186
 
120
- NODE_URL="https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-${PLATFORM}-${NODEARCH}.tar.xz"
121
- TMPFILE=$(mktemp /tmp/node-XXXXXX.tar.xz)
187
+ printf " ${BLUE}↓${RESET} Downloading Node.js v${NODE_VERSION}...\n"
122
188
 
123
- # Download
124
- if command -v curl >/dev/null 2>&1; then
125
- curl -fsSL -o "$TMPFILE" "$NODE_URL"
126
- elif command -v wget >/dev/null 2>&1; then
127
- wget -qO "$TMPFILE" "$NODE_URL"
189
+ NODE_FILE="node-v${NODE_VERSION}-${PLATFORM}-${NODEARCH}.tar.xz"
190
+ NODE_URL="https://nodejs.org/dist/v${NODE_VERSION}/${NODE_FILE}"
191
+ # Trailing X's, NO suffix: BSD mktemp (macOS) only substitutes trailing X's,
192
+ # so a ".tar.xz" suffix would create a literal, predictable, non-unique file.
193
+ TMPFILE=$(mktemp "${TMPDIR:-/tmp}/bloby-node-XXXXXX")
194
+ download_file "$NODE_URL" "$TMPFILE"
195
+
196
+ # Integrity: verify against nodejs.org SHASUMS256.txt before extracting. A
197
+ # mismatch is fatal; an unreachable sums file or missing hash tool degrades to
198
+ # a warning so installs still proceed (the download itself is TLS-protected).
199
+ printf " ${DIM}Verifying download...${RESET}\n"
200
+ EXPECTED_SHA=""
201
+ SUMS=$(curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 20 "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" 2>/dev/null \
202
+ || wget -q --https-only --timeout=20 -O- "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" 2>/dev/null \
203
+ || echo "")
204
+ if [ -n "$SUMS" ]; then
205
+ EXPECTED_SHA=$(printf '%s\n' "$SUMS" | awk -v f="$NODE_FILE" '$2==f {print $1; exit}')
206
+ fi
207
+ if [ -n "$EXPECTED_SHA" ]; then
208
+ vrc=0
209
+ verify_sha256 "$TMPFILE" "$EXPECTED_SHA" || vrc=$?
210
+ if [ "$vrc" = "0" ]; then
211
+ printf " ${BLUE}✔${RESET} Checksum verified\n"
212
+ elif [ "$vrc" = "2" ]; then
213
+ printf " ${YELLOW}!${RESET} Could not compute checksum — skipping verification\n"
214
+ else
215
+ printf " ${RED}✗${RESET} Node.js checksum mismatch — aborting (corrupt or tampered download)\n"
216
+ exit 1
217
+ fi
128
218
  else
129
- printf " ${RED}✗${RESET} curl or wget required\n"
130
- exit 1
219
+ printf " ${YELLOW}!${RESET} Could not fetch checksums — skipping verification\n"
131
220
  fi
132
221
 
133
- # Extract
222
+ # Extract into a staging dir, verify it runs, then atomically swap in. An
223
+ # interrupt mid-extract no longer wipes the existing (working) node.
134
224
  mkdir -p "$TOOLS_DIR"
135
- rm -rf "$NODE_DIR"
136
- mkdir -p "$NODE_DIR"
137
-
138
- tar xf "$TMPFILE" -C "$NODE_DIR" --strip-components=1
139
- rm -f "$TMPFILE"
140
-
141
- # Verify
142
- if [ ! -x "$NODE_DIR/bin/node" ]; then
143
- printf " ${RED}✗${RESET} Node.js download failed\n"
225
+ NODE_NEW="$NODE_DIR.new"
226
+ rm -rf "$NODE_NEW"
227
+ mkdir -p "$NODE_NEW"
228
+ tar xf "$TMPFILE" -C "$NODE_NEW" --strip-components=1
229
+ rm -f "$TMPFILE"; TMPFILE=""
230
+
231
+ if ! "$NODE_NEW/bin/node" -v >/dev/null 2>&1; then
232
+ printf " ${RED}✗${RESET} Node.js download failed (extracted binary does not run)\n"
233
+ rm -rf "$NODE_NEW"
144
234
  exit 1
145
235
  fi
146
236
 
237
+ rm -rf "$NODE_DIR.old"
238
+ [ -e "$NODE_DIR" ] && mv "$NODE_DIR" "$NODE_DIR.old"
239
+ mv "$NODE_NEW" "$NODE_DIR"
240
+ rm -rf "$NODE_DIR.old"
241
+
147
242
  printf " ${BLUE}✔${RESET} Node.js v${NODE_VERSION} installed\n"
148
243
  }
149
244
 
@@ -174,19 +269,15 @@ install_bloby() {
174
269
  exit 1
175
270
  fi
176
271
 
177
- # Download and extract tarball
178
- TMPDIR=$(mktemp -d)
179
- if command -v curl >/dev/null 2>&1; then
180
- curl -fsSL -o "$TMPDIR/bloby.tgz" "$TARBALL_URL"
181
- elif command -v wget >/dev/null 2>&1; then
182
- wget -qO "$TMPDIR/bloby.tgz" "$TARBALL_URL"
183
- fi
272
+ # Download and extract tarball (download_file guards on missing curl/wget)
273
+ WORK_DIR=$(mktemp -d)
274
+ download_file "$TARBALL_URL" "$WORK_DIR/bloby.tgz"
184
275
 
185
- tar xzf "$TMPDIR/bloby.tgz" -C "$TMPDIR"
186
- EXTRACTED="$TMPDIR/package"
276
+ tar xzf "$WORK_DIR/bloby.tgz" -C "$WORK_DIR"
277
+ EXTRACTED="$WORK_DIR/package"
187
278
 
188
279
  if [ ! -d "$EXTRACTED" ]; then
189
- rm -rf "$TMPDIR"
280
+ rm -rf "$WORK_DIR"; WORK_DIR=""
190
281
  printf " ${RED}✗${RESET} Installation failed\n"
191
282
  exit 1
192
283
  fi
@@ -219,7 +310,7 @@ install_bloby() {
219
310
  fi
220
311
  fi
221
312
 
222
- rm -rf "$TMPDIR"
313
+ rm -rf "$WORK_DIR"; WORK_DIR=""
223
314
 
224
315
  # Install dependencies inside ~/.bloby/
225
316
  # claude-agent-sdk 0.3.x moved @anthropic-ai/sdk + @modelcontextprotocol/sdk to
@@ -245,6 +336,15 @@ install_bloby() {
245
336
  WS_INSTALL_LOG=$(mktemp)
246
337
  if ! (cd "$BLOBY_HOME/workspace" && "$NPM" install --omit=dev > "$WS_INSTALL_LOG" 2>&1); then
247
338
  printf " ${RED}✗${RESET} Workspace dependency install failed:\n"
339
+ # Native modules (better-sqlite3 etc.) need a compiler toolchain — detect
340
+ # the common "missing build tools" failure and point at the exact fix.
341
+ if grep -qiE 'make: .*command not found|gyp ERR! find Python|no developer tools|xcode-select|command not found: make|g\+\+: not found' "$WS_INSTALL_LOG"; then
342
+ if [ "$PLATFORM" = "darwin" ]; then
343
+ printf " ${YELLOW}!${RESET} Native build tools missing. Run ${BOLD}xcode-select --install${RESET} then re-run this installer.\n"
344
+ else
345
+ printf " ${YELLOW}!${RESET} Native build tools missing. On Debian/Ubuntu: ${BOLD}sudo apt-get install -y build-essential python3${RESET}, then re-run.\n"
346
+ fi
347
+ fi
248
348
  cat "$WS_INSTALL_LOG"
249
349
  rm -f "$WS_INSTALL_LOG"
250
350
  exit 1
@@ -348,19 +448,34 @@ install_bloby
348
448
  create_wrapper
349
449
  setup_path
350
450
 
451
+ # Smoke-test: the wrapper + node + cli must actually run, not just exist on disk.
452
+ if ! "$BIN_DIR/bloby" --version >/dev/null 2>&1; then
453
+ printf "\n ${RED}✗${RESET} Bloby installed but failed to run (\`bloby --version\`).\n"
454
+ printf " ${DIM}Open a NEW terminal and run ${BOLD}bloby --version${RESET}${DIM}; if it still fails, re-run this installer.${RESET}\n\n"
455
+ exit 1
456
+ fi
457
+
351
458
  printf "\n"
352
- printf " ${PINK}${BOLD}✔ Bloby is ready!${RESET}\n"
459
+ printf " ${PINK}${BOLD}✔ Bloby is installed!${RESET}\n"
353
460
  printf "\n"
354
- printf " ${DIM}─────────────────────────────${RESET}\n"
355
- printf " ${BOLD}Get started:${RESET}\n"
461
+ printf " ${BLUE}${BOLD}╭───────────────────────────────────────────────────────╮${RESET}\n"
462
+ printf " ${BLUE}${BOLD}│${RESET} ${RESET}${BLUE}${BOLD}│${RESET}\n"
463
+ printf " ${BLUE}${BOLD}│${RESET}${BOLD} NEXT STEP - type this, then press Enter:${RESET} ${BLUE}${BOLD}│${RESET}\n"
464
+ printf " ${BLUE}${BOLD}│${RESET} ${RESET}${BLUE}${BOLD}│${RESET}\n"
465
+ printf " ${BLUE}${BOLD}│${RESET}${BLUE}${BOLD} > bloby init${RESET} ${BLUE}${BOLD}│${RESET}\n"
466
+ printf " ${BLUE}${BOLD}│${RESET} ${RESET}${BLUE}${BOLD}│${RESET}\n"
467
+ printf " ${BLUE}${BOLD}│${RESET}${DIM} ─────────────────────────────────────────────────${RESET} ${BLUE}${BOLD}│${RESET}\n"
468
+ printf " ${BLUE}${BOLD}│${RESET} ${RESET}${BLUE}${BOLD}│${RESET}\n"
469
+ printf " ${BLUE}${BOLD}│${RESET}${YELLOW}${BOLD} Not working? (\"command not found\")${RESET} ${BLUE}${BOLD}│${RESET}\n"
470
+ printf " ${BLUE}${BOLD}│${RESET} Just open a NEW terminal window and run${RESET} ${BLUE}${BOLD}│${RESET}\n"
471
+ printf " ${BLUE}${BOLD}│${RESET} bloby init again.${RESET} ${BLUE}${BOLD}│${RESET}\n"
472
+ printf " ${BLUE}${BOLD}│${RESET} ${RESET}${BLUE}${BOLD}│${RESET}\n"
473
+ printf " ${BLUE}${BOLD}╰───────────────────────────────────────────────────────╯${RESET}\n"
356
474
  printf "\n"
357
- printf " ${BLUE}bloby init${RESET} Set up your bot\n"
475
+ printf " ${DIM}Other commands:${RESET}\n"
358
476
  printf " ${BLUE}bloby start${RESET} Start your bot\n"
359
477
  printf " ${BLUE}bloby status${RESET} Check if it's running\n"
360
478
  printf " ${BLUE}bloby help${RESET} All commands\n"
361
479
  printf "\n"
362
- printf " ${PINK}>${RESET} Run ${BLUE}bloby init${RESET} to begin.\n"
363
- printf " ${DIM}(Open a new terminal if 'bloby' isn't found yet)${RESET}\n"
364
- printf "\n"
365
480
  printf " ${DIM}https://bloby.bot${RESET}\n"
366
481
  printf "\n"
package/shared/config.ts CHANGED
@@ -97,7 +97,20 @@ const MODEL_MIGRATIONS: Record<string, string> = {
97
97
 
98
98
  export function loadConfig(): BotConfig {
99
99
  if (!fs.existsSync(paths.config)) throw new Error('No config. Run `bloby init`.');
100
- const config = JSON.parse(fs.readFileSync(paths.config, 'utf-8'));
100
+ let config: BotConfig;
101
+ try {
102
+ config = JSON.parse(fs.readFileSync(paths.config, 'utf-8'));
103
+ } catch {
104
+ // Torn/truncated write — recover from the .bak mirror rather than crashing
105
+ // the supervisor (or, worse, letting the CLI regenerate a fresh wallet).
106
+ const bak = `${paths.config}.bak`;
107
+ if (fs.existsSync(bak)) {
108
+ config = JSON.parse(fs.readFileSync(bak, 'utf-8'));
109
+ try { saveConfig(config); } catch {}
110
+ } else {
111
+ throw new Error('config.json is corrupt and no backup (.bak) was found.');
112
+ }
113
+ }
101
114
  let dirty = false;
102
115
 
103
116
  // Backward compat: migrate old { enabled: boolean } → { mode }
@@ -119,5 +132,27 @@ export function loadConfig(): BotConfig {
119
132
 
120
133
  export function saveConfig(config: BotConfig): void {
121
134
  fs.mkdirSync(DATA_DIR, { recursive: true });
122
- fs.writeFileSync(paths.config, JSON.stringify(config, null, 2));
135
+ // Atomic write: temp file + rename, then mirror to .bak. config.json holds the
136
+ // funded wallet and is written by BOTH this supervisor and the CLI, so a plain
137
+ // writeFileSync can be observed half-written (the Jun-9 0-byte-creds class of
138
+ // bug, here applied to the one file that must never be lost).
139
+ const json = JSON.stringify(config, null, 2);
140
+ const tmp = `${paths.config}.${process.pid}.tmp`;
141
+ try {
142
+ fs.writeFileSync(tmp, json);
143
+ try {
144
+ fs.renameSync(tmp, paths.config);
145
+ } catch (err) {
146
+ // Windows: rename over a file another process holds open can EPERM/EEXIST.
147
+ const code = (err as NodeJS.ErrnoException)?.code;
148
+ if (process.platform === 'win32' && (code === 'EPERM' || code === 'EEXIST')) {
149
+ fs.copyFileSync(tmp, paths.config);
150
+ fs.unlinkSync(tmp);
151
+ } else throw err;
152
+ }
153
+ try { fs.copyFileSync(paths.config, `${paths.config}.bak`); } catch {}
154
+ } catch (err) {
155
+ try { fs.unlinkSync(tmp); } catch {}
156
+ throw err;
157
+ }
123
158
  }
package/shared/relay.ts CHANGED
@@ -122,7 +122,9 @@ export function startHeartbeat(token: string, tunnelUrl?: string): void {
122
122
  };
123
123
 
124
124
  beat(); // immediate first beat
125
- heartbeatTimer = setInterval(beat, 30_000); // every 30s
125
+ // Every 120s. The relay's HEARTBEAT_TIMEOUT_MS must stay well above this (currently
126
+ // 360s = 3 missed beats of grace) so a single dropped beat never flaps a healthy bot.
127
+ heartbeatTimer = setInterval(beat, 120_000);
126
128
  }
127
129
 
128
130
  export function stopHeartbeat(): void {
@@ -29,7 +29,7 @@ import { AlexaChannel } from './alexa.js';
29
29
  import { TelegramChannel, type TelegramInbound } from './telegram.js';
30
30
  import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, RoutingTarget, SenderRole } from './types.js';
31
31
  import type { AgentAttachment } from '../bloby-agent.js';
32
- import { saveAttachment, type SavedFile } from '../file-saver.js';
32
+ import { saveAttachment, MAX_ATTACHMENTS_PER_MESSAGE, MAX_TOTAL_ATTACHMENT_BYTES, type SavedFile } from '../file-saver.js';
33
33
  import type { WAMessageKey } from '@whiskeysockets/baileys';
34
34
 
35
35
  const MAX_CONCURRENT_AGENTS = 5;
@@ -37,16 +37,34 @@ const MAX_BUFFER_MESSAGES = 30;
37
37
  const DEBOUNCE_MS = 4000; // 4s — wait for the user to finish typing
38
38
 
39
39
  /** Persist channel-inbound attachments to disk so harnesses that consume file
40
- * paths (Codex's `localImage`) can see them. Failures are logged and the
41
- * attachment is dropped — text-only delivery is still useful. */
42
- function saveInboundAttachments(attachments?: AgentAttachment[]): SavedFile[] {
43
- if (!attachments?.length) return [];
40
+ * paths (Codex's `localImage`) can see them. Per-file failures are logged and that
41
+ * attachment is dropped — one oversize/corrupt file can't abort the whole message,
42
+ * and text-only delivery still goes through. Bounded by MAX_ATTACHMENTS_PER_MESSAGE
43
+ * (count) and MAX_TOTAL_ATTACHMENT_BYTES (decoded bytes) so a single message can't
44
+ * flood the disk; saveAttachment itself caps each file's size. */
45
+ function saveInboundAttachments(attachments?: AgentAttachment[]): { saved: SavedFile[]; accepted: AgentAttachment[] } {
46
+ if (!attachments?.length) return { saved: [], accepted: [] };
47
+ const capped = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE);
48
+ if (attachments.length > capped.length) {
49
+ log.warn(`[channels] Dropping ${attachments.length - capped.length} inbound attachment(s) over the per-message cap (${MAX_ATTACHMENTS_PER_MESSAGE})`);
50
+ }
44
51
  const saved: SavedFile[] = [];
45
- for (const att of attachments) {
46
- try { saved.push(saveAttachment(att)); }
52
+ // The raw attachments that actually saved within budget — handed to the harness so the
53
+ // model inlines exactly what got persisted + shown in chat (no over-cap divergence).
54
+ const accepted: AgentAttachment[] = [];
55
+ let totalBytes = 0;
56
+ for (const att of capped) {
57
+ // Estimate decoded size from the base64 length (×3/4) before writing so a burst of
58
+ // mid-size files can't blow the per-message byte budget in aggregate.
59
+ totalBytes += Math.floor((att.data?.length || 0) * 0.75);
60
+ if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
61
+ log.warn('[channels] Per-message attachment byte budget exceeded — dropping remaining inbound attachments');
62
+ break;
63
+ }
64
+ try { saved.push(saveAttachment(att)); accepted.push(att); }
47
65
  catch (err: any) { log.warn(`[channels] Failed to save inbound attachment: ${err.message}`); }
48
66
  }
49
- return saved;
67
+ return { saved, accepted };
50
68
  }
51
69
 
52
70
  interface ChannelManagerOpts {
@@ -68,6 +86,17 @@ interface BufferedMessage {
68
86
  content: string;
69
87
  }
70
88
 
89
+ /** Cap a rolling per-customer history buffer AND keep the window starting on a
90
+ * user message. A blind length-splice can leave the window assistant-first,
91
+ * which the Anthropic Messages API rejects outright once the pi harness sends
92
+ * the buffer as structured chat history — every reply in that thread then
93
+ * silently fails (audit C-7). Trimming at the source fixes it for every
94
+ * provider flavor at once. */
95
+ function trimCustomerBuffer(buf: BufferedMessage[]): void {
96
+ if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES);
97
+ while (buf.length > 0 && buf[0].role !== 'user') buf.shift();
98
+ }
99
+
71
100
  interface DebounceEntry {
72
101
  messages: string[];
73
102
  attachments: InboundMessageAttachment[];
@@ -133,8 +162,8 @@ export class ChannelManager {
133
162
  if (channelConfigs?.whatsapp?.enabled && !this.providers.has('whatsapp')) {
134
163
  log.info('[channels] Initializing WhatsApp channel...');
135
164
  const whatsapp = new WhatsAppChannel(
136
- (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
137
- const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
165
+ (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, media, inboundKey) => {
166
+ const attachments = media?.map((att) => ({ type: att.type, mediaType: att.mediaType, data: att.data, name: att.name }));
138
167
  this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
139
168
  },
140
169
  (status) => this.handleStatusChange(status),
@@ -200,7 +229,7 @@ export class ChannelManager {
200
229
  }
201
230
  }
202
231
  const isOwner = !!ownerUserId && msg.fromUserId === String(ownerUserId);
203
- const attachments = msg.images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
232
+ const attachments = msg.attachments?.map((att) => ({ type: att.type, mediaType: att.mediaType, data: att.data, name: att.name }));
204
233
  // Sanitize the attacker-controlled display name so it can't fake a `[Telegram | … | admin]`
205
234
  // context tag or inject newlines into the agent's context.
206
235
  const safeName = msg.senderName ? msg.senderName.replace(/[\[\]|\r\n]/g, ' ').slice(0, 64).trim() || undefined : undefined;
@@ -225,8 +254,8 @@ export class ChannelManager {
225
254
  if (provider?.getStatus().connected) return;
226
255
  if (!provider) {
227
256
  const whatsapp = new WhatsAppChannel(
228
- (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
229
- const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
257
+ (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, media, inboundKey) => {
258
+ const attachments = media?.map((att) => ({ type: att.type, mediaType: att.mediaType, data: att.data, name: att.name }));
230
259
  this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
231
260
  },
232
261
  (status) => this.handleStatusChange(status),
@@ -600,7 +629,7 @@ export class ChannelManager {
600
629
  const buf = this.customerBuffers.get(target.assistantBufferKey);
601
630
  if (buf) {
602
631
  buf.push({ role: 'assistant', content: eventData.content });
603
- if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES);
632
+ trimCustomerBuffer(buf);
604
633
  }
605
634
  }
606
635
  return;
@@ -981,12 +1010,34 @@ export class ChannelManager {
981
1010
  const channelTag = `[${this.channelLabel(msg.channel)} | ${msg.sender} | ${earlyRoleTag}]\n`;
982
1011
  const displayContent = channelTag + rawDisplay;
983
1012
 
1013
+ // Convert inbound attachments to agent format and persist them to disk BEFORE the
1014
+ // user-message persist/broadcast — so the StoredAttachment array (filePath-based, served
1015
+ // at /api/files/<relPath>) can ride along in meta.attachments + chat:sync. Without this
1016
+ // the agent sees the media but the chat shows nothing (live or after refresh). An image
1017
+ // keeps an auto-generated name; a file uses the channel-provided filename. (Mirrors the
1018
+ // PWA path in supervisor/index.ts.)
1019
+ const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
1020
+ type: att.type,
1021
+ name: att.type === 'image'
1022
+ ? `${msg.channel}_image.${att.mediaType.split('/')[1] || 'jpg'}`
1023
+ : (att.name || `${msg.channel}_file`),
1024
+ mediaType: att.mediaType,
1025
+ data: att.data,
1026
+ }));
1027
+ // Save to disk so providers that consume file paths (Codex → localImage) can see the
1028
+ // attachment. Claude consumes raw base64 from `agentAttachments` directly, but the
1029
+ // on-disk copy is what the chat UI renders (filePath → /api/files/<relPath>).
1030
+ const { saved: savedFiles, accepted: acceptedAttachments } = saveInboundAttachments(agentAttachments);
1031
+ const storedAtts = savedFiles.map((f) => ({ type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath }));
1032
+
984
1033
  // Save user message to DB
985
1034
  try {
1035
+ const userMeta: any = { model, channel: msg.channel };
1036
+ if (storedAtts.length) userMeta.attachments = JSON.stringify(storedAtts);
986
1037
  await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
987
1038
  role: 'user',
988
1039
  content: displayContent,
989
- meta: { model, channel: msg.channel },
1040
+ meta: userMeta,
990
1041
  });
991
1042
  } catch (err: any) {
992
1043
  log.warn(`[channels] DB persist error: ${err.message}`);
@@ -995,7 +1046,12 @@ export class ChannelManager {
995
1046
  // Broadcast to chat clients (mirroring)
996
1047
  broadcastBloby('chat:sync', {
997
1048
  conversationId: convId,
998
- message: { role: 'user', content: displayContent, timestamp: new Date().toISOString() },
1049
+ message: {
1050
+ role: 'user',
1051
+ content: displayContent,
1052
+ timestamp: new Date().toISOString(),
1053
+ attachments: storedAtts.length ? storedAtts : undefined,
1054
+ },
999
1055
  });
1000
1056
 
1001
1057
  // Fetch names and recent messages
@@ -1022,18 +1078,6 @@ export class ChannelManager {
1022
1078
  // Channel context — same tag we already prepended to the stored display content
1023
1079
  const channelContext = channelTag;
1024
1080
 
1025
- // Convert inbound attachments to agent format
1026
- const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
1027
- type: 'image' as const,
1028
- name: `whatsapp_image.${att.mediaType.split('/')[1] || 'jpg'}`,
1029
- mediaType: att.mediaType,
1030
- data: att.data,
1031
- }));
1032
- // Save to disk so providers that consume file paths (Codex → localImage)
1033
- // can see the attachment. Claude consumes raw base64 from `agentAttachments`
1034
- // directly, but the on-disk copy is still useful for the path mention.
1035
- const savedFiles = saveInboundAttachments(agentAttachments);
1036
-
1037
1081
  // Show "typing..." in the correct chat
1038
1082
  this.startTyping(msg.channel, msg.rawSender);
1039
1083
 
@@ -1116,7 +1160,7 @@ export class ChannelManager {
1116
1160
  assistantBufferKey: msg.role === 'assistant' ? `${msg.channel}:${msg.sender}` : undefined,
1117
1161
  inboundKey: msg.inboundKey,
1118
1162
  };
1119
- this.pushWithRouting(convId, target, channelContent, agentAttachments, savedFiles);
1163
+ this.pushWithRouting(convId, target, channelContent, acceptedAttachments, savedFiles);
1120
1164
  }
1121
1165
 
1122
1166
  /** Synchronously handle an Alexa utterance: push into the shared conversation,
@@ -1305,10 +1349,8 @@ export class ChannelManager {
1305
1349
  // Add the new user message to the buffer
1306
1350
  buffer.push({ role: 'user', content: msg.text });
1307
1351
 
1308
- // Trim buffer to max size
1309
- if (buffer.length > MAX_BUFFER_MESSAGES) {
1310
- buffer.splice(0, buffer.length - MAX_BUFFER_MESSAGES);
1311
- }
1352
+ // Trim buffer to max size (user-first — see trimCustomerBuffer)
1353
+ trimCustomerBuffer(buffer);
1312
1354
 
1313
1355
  // Build recent messages for context (everything except the last one, which is the current message)
1314
1356
  const recentMessages: RecentMessage[] = buffer.length > 1
@@ -1329,14 +1371,16 @@ export class ChannelManager {
1329
1371
 
1330
1372
  const channelContext = `[${this.channelLabel(msg.channel)} | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
1331
1373
 
1332
- // Convert inbound attachments to agent format
1374
+ // Convert inbound attachments to agent format (image → auto-name; file → channel filename)
1333
1375
  const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
1334
- type: 'image' as const,
1335
- name: `whatsapp_image.${att.mediaType.split('/')[1] || 'jpg'}`,
1376
+ type: att.type,
1377
+ name: att.type === 'image'
1378
+ ? `${msg.channel}_image.${att.mediaType.split('/')[1] || 'jpg'}`
1379
+ : (att.name || `${msg.channel}_file`),
1336
1380
  mediaType: att.mediaType,
1337
1381
  data: att.data,
1338
1382
  }));
1339
- const savedFiles = saveInboundAttachments(agentAttachments);
1383
+ const { saved: savedFiles, accepted: acceptedAttachments } = saveInboundAttachments(agentAttachments);
1340
1384
 
1341
1385
  // Stable convId per customer (not per message)
1342
1386
  const convId = `channel-${agentKey}`;
@@ -1376,9 +1420,7 @@ export class ChannelManager {
1376
1420
  if (type === 'bot:response' && eventData.content) {
1377
1421
  // Add full response to the conversation buffer
1378
1422
  buffer!.push({ role: 'assistant', content: eventData.content });
1379
- if (buffer!.length > MAX_BUFFER_MESSAGES) {
1380
- buffer!.splice(0, buffer!.length - MAX_BUFFER_MESSAGES);
1381
- }
1423
+ trimCustomerBuffer(buffer!);
1382
1424
 
1383
1425
  // Send remaining text after the last tool use (or full response if no tools were used)
1384
1426
  const remaining = waChunkBuf.trim();
@@ -1397,7 +1439,7 @@ export class ChannelManager {
1397
1439
  this.processQueue();
1398
1440
  }
1399
1441
  },
1400
- agentAttachments,
1442
+ acceptedAttachments,
1401
1443
  savedFiles,
1402
1444
  { botName, humanName },
1403
1445
  recentMessages,
@@ -1424,9 +1466,7 @@ export class ChannelManager {
1424
1466
  }
1425
1467
  const label = fromMe ? 'me' : (senderName || chatId);
1426
1468
  buffer.push({ role: 'user', content: `[${label}]: ${text}` });
1427
- if (buffer.length > MAX_BUFFER_MESSAGES) {
1428
- buffer.splice(0, buffer.length - MAX_BUFFER_MESSAGES);
1429
- }
1469
+ trimCustomerBuffer(buffer);
1430
1470
  log.info(`[channels] Assistant context stored: ${bufferKey} | ${buffer.length} msgs | [${label}]: "${text.slice(0, 60)}"`);
1431
1471
  }
1432
1472