bloby-bot 0.52.4 → 0.53.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,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.52.4",
3
+ "version": "0.53.0",
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",
@@ -26,6 +26,16 @@ export function getBackendPort(basePort: number): number {
26
26
  }
27
27
 
28
28
  export function spawnBackend(port: number): ChildProcess {
29
+ // Self-guard against double-spawn. Several restart paths (file watcher, bot:turn-complete,
30
+ // scheduler pulse, channel manager) each chain their own stopBackend().then(spawnBackend);
31
+ // two genuinely-concurrent triggers could otherwise both spawn onto the contended
32
+ // BACKEND_PORT. The normal restart is unaffected: stopBackend() nulls `child` before its
33
+ // promise resolves, and the crash auto-restart runs only after exit (exitCode !== null),
34
+ // so this guard no-ops only when a live backend already exists.
35
+ if (child && child.exitCode === null) {
36
+ log.warn('Backend already running — skipping duplicate spawn');
37
+ return child;
38
+ }
29
39
  const backendPath = path.join(WORKSPACE_DIR, 'backend', 'index.ts');
30
40
  lastSpawnTime = Date.now();
31
41
  intentionallyStopped = false;
@@ -48,7 +48,8 @@ export class WsClient {
48
48
  this.ws.onmessage = (e) => {
49
49
  // Ignore pong frames
50
50
  if (e.data === 'pong') return;
51
- const msg = JSON.parse(e.data);
51
+ let msg: any;
52
+ try { msg = JSON.parse(e.data as string); } catch { return; }
52
53
  const handlers = this.handlers.get(msg.type);
53
54
  handlers?.forEach((h) => h(msg.data));
54
55
  };
@@ -377,7 +377,7 @@ export async function startConversation(
377
377
  }
378
378
 
379
379
  // Signal turn complete — backend restart + UI update
380
- const FILE_TOOLS = ['Write', 'Edit'];
380
+ const FILE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'];
381
381
  const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
382
382
 
383
383
  // Context-size signal for the orchestrator's proactive session recycling.
@@ -699,7 +699,7 @@ export async function startBlobyAgentQuery(
699
699
  }
700
700
  } finally {
701
701
  activeQueries.delete(conversationId);
702
- const FILE_TOOLS = ['Write', 'Edit'];
702
+ const FILE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'];
703
703
  const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
704
704
  onMessage('bot:done', { conversationId, usedFileTools });
705
705
  }
@@ -792,7 +792,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
792
792
  }
793
793
  }
794
794
 
795
- const usedFileTools = ['Write', 'Edit'].some((t) => usedTools.has(t));
795
+ const usedFileTools = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].some((t) => usedTools.has(t));
796
796
  log.info(`[claude/agent-api] Done: ${fullText.length} chars, tools=[${Array.from(usedTools).join(',')}], session=${sessionId || 'unknown'}`);
797
797
  return { ok: true, response: fullText, sessionId, toolsUsed: Array.from(usedTools), usedFileTools };
798
798
  } catch (err: any) {
@@ -59,7 +59,7 @@ export interface PiSession {
59
59
  getMessages(): PiMessage[];
60
60
  }
61
61
 
62
- const FILE_TOOL_NAMES = new Set(['Write', 'Edit', 'write', 'edit']);
62
+ const FILE_TOOL_NAMES = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'write', 'edit', 'multiEdit', 'notebookEdit']);
63
63
  const MAX_TOOL_ROUNDS = 25;
64
64
 
65
65
  export function createPiSession(init: PiSessionInit): PiSession {
@@ -28,6 +28,18 @@ import { execSync, spawn as cpSpawn } from 'child_process';
28
28
  import crypto from 'crypto';
29
29
  import { ChannelManager } from './channels/manager.js';
30
30
 
31
+ // Last-resort process-level safety nets. The supervisor is the SINGLE process that keeps
32
+ // chat alive (G1) and heals the backend (G3); a stray throw inside an emitter callback
33
+ // (a WS frame, an fs.watch event, a read-stream error) or an unhandled rejection must NOT
34
+ // take it down. Log loudly and stay up — never exit here. Per-site guards still handle
35
+ // their own errors; this is defense-in-depth, not a substitute for them.
36
+ process.on('uncaughtException', (err) => {
37
+ log.error(`[supervisor] uncaughtException (kept alive): ${err instanceof Error ? err.stack || err.message : String(err)}`);
38
+ });
39
+ process.on('unhandledRejection', (reason) => {
40
+ log.error(`[supervisor] unhandledRejection (kept alive): ${reason instanceof Error ? reason.stack || reason.message : String(reason)}`);
41
+ });
42
+
31
43
  const DIST_BLOBY = path.join(PKG_DIR, 'dist-bloby');
32
44
  const SUPERVISOR_PUBLIC = path.join(PKG_DIR, 'supervisor', 'public');
33
45
 
@@ -317,10 +329,15 @@ export async function startSupervisor() {
317
329
  }
318
330
  const chatSubscribers = new Set<ChatSubscriber>();
319
331
 
320
- /** Call worker API endpoints (in-process via supervisor's own HTTP server) */
321
- async function workerApi(apiPath: string, method = 'GET', body?: any) {
332
+ /** Call worker API endpoints (in-process via supervisor's own HTTP server).
333
+ * `timeoutMs` is opt-in: pass it only for calls that must not hang the caller
334
+ * (e.g. the bot:response persist that gates the chat reply broadcast). Leave it
335
+ * unset for calls whose duration is legitimately long (e.g. Whisper transcription),
336
+ * so a generous timeout here never spuriously fails them. */
337
+ async function workerApi(apiPath: string, method = 'GET', body?: any, timeoutMs?: number) {
322
338
  const opts: RequestInit = { method, headers: { 'Content-Type': 'application/json', 'x-internal': internalSecret } };
323
339
  if (body) opts.body = JSON.stringify(body);
340
+ if (timeoutMs) opts.signal = AbortSignal.timeout(timeoutMs);
324
341
  const res = await fetch(`http://127.0.0.1:${config.port}${apiPath}`, opts);
325
342
  return res.json();
326
343
  }
@@ -1455,7 +1472,9 @@ mint();
1455
1472
  }));
1456
1473
  }
1457
1474
  }
1458
- } catch {}
1475
+ } catch (err: any) {
1476
+ log.warn(`[workspace-chat] recent/status fetch failed (seeding without history): ${err?.message || err}`);
1477
+ }
1459
1478
 
1460
1479
  if (!hasConversation(convId)) {
1461
1480
  log.info(`[workspace-chat] Starting new live conversation: ${convId}`);
@@ -1635,7 +1654,11 @@ mint();
1635
1654
  // Hashed assets (.js, .css): immutable caching
1636
1655
  const cacheControl = ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable';
1637
1656
  res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': cacheControl });
1638
- fs.createReadStream(fullPath).pipe(res);
1657
+ // A file removed/replaced mid-stream (common during a workspace rebuild) emits an
1658
+ // async 'error' on the stream — without this listener it crashes the supervisor (G1).
1659
+ const rs = fs.createReadStream(fullPath);
1660
+ rs.on('error', () => { if (!res.headersSent) { res.writeHead(404); res.end('Not found'); } else res.destroy(); });
1661
+ rs.pipe(res);
1639
1662
  } else {
1640
1663
  res.writeHead(404);
1641
1664
  res.end('Not found');
@@ -1658,14 +1681,16 @@ mint();
1658
1681
  const ext = path.extname(assetPath);
1659
1682
  const mime = MIME_TYPES[ext] || 'application/octet-stream';
1660
1683
  res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'public, max-age=86400' });
1661
- fs.createReadStream(assetPath).pipe(res);
1684
+ const rs = fs.createReadStream(assetPath);
1685
+ rs.on('error', () => { if (!res.headersSent) { res.writeHead(404); res.end('Not found'); } else res.destroy(); });
1686
+ rs.pipe(res);
1662
1687
  return;
1663
1688
  }
1664
1689
  } catch { /* fall through to Vite */ }
1665
1690
  }
1666
1691
 
1667
1692
  // Everything else → proxy to dashboard Vite dev server
1668
- console.log(`[supervisor] → dashboard Vite :${vitePorts.dashboard} | ${req.method} ${req.url}`);
1693
+ console.log(`[supervisor] → dashboard Vite :${vitePorts.dashboard} | ${req.method} ${(req.url || '').split('?')[0]}`);
1669
1694
  const proxy = http.request(
1670
1695
  { host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: req.headers },
1671
1696
  (proxyRes) => {
@@ -1689,6 +1714,9 @@ mint();
1689
1714
 
1690
1715
  appWss.on('connection', (ws) => {
1691
1716
  console.log('[supervisor] App API WS client connected');
1717
+ // An 'error' event with no listener is rethrown by Node as an uncaught exception,
1718
+ // which would crash the whole supervisor. ws still tears down + fires 'close'.
1719
+ ws.on('error', (err: any) => console.warn(`[app-ws] socket error: ${err?.message || err}`));
1692
1720
 
1693
1721
  // Per-WS chat subscription: when the client opts in, this WS joins chatSubscribers
1694
1722
  // and receives every bot:* / chat:* event the dashboard widget does. SSE through the
@@ -1939,9 +1967,13 @@ mint();
1939
1967
  if (type === 'bot:response') {
1940
1968
  currentStreamBuffer = '';
1941
1969
  try {
1970
+ // 15s timeout: if this in-process write ever hangs (vs. rejects), the reply
1971
+ // broadcast below would never fire and the user's answer would silently vanish.
1972
+ // The timeout converts a hang into the already-handled catch → chat:persist-error,
1973
+ // and the reply still broadcasts. A message INSERT is sub-ms, so 15s is generous.
1942
1974
  await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
1943
1975
  role: 'assistant', content: eventData.content, meta: { model },
1944
- });
1976
+ }, 15000);
1945
1977
  } catch (err: any) {
1946
1978
  log.warn(`[bloby] DB persist bot response error: ${err.message}`);
1947
1979
  broadcastBloby('chat:persist-error', { conversationId: convId, role: 'assistant', error: err.message });
@@ -1959,6 +1991,9 @@ mint();
1959
1991
 
1960
1992
  blobyWss.on('connection', (ws) => {
1961
1993
  log.info('Bloby chat connected');
1994
+ // See appWss above: a listener-less 'error' event would crash the supervisor and kill
1995
+ // chat for everyone (G1). ws still fires 'close' afterward, so map cleanup still runs.
1996
+ ws.on('error', (err: any) => log.warn(`[bloby-ws] socket error: ${err?.message || err}`));
1962
1997
  let convId = Math.random().toString(36).slice(2) + Date.now().toString(36);
1963
1998
  conversations.set(ws, []);
1964
1999
 
@@ -1983,7 +2018,11 @@ mint();
1983
2018
  return;
1984
2019
  }
1985
2020
 
1986
- const msg = JSON.parse(rawStr);
2021
+ // Guarded parse — a single malformed (non-'ping') text frame must never throw out
2022
+ // of this handler and crash the supervisor (G1). Mirrors the appWss handler above.
2023
+ let msg: any;
2024
+ try { msg = JSON.parse(rawStr); } catch { return; }
2025
+ if (!msg || typeof msg !== 'object') return;
1987
2026
 
1988
2027
  // Whisper transcription via WebSocket (bypasses relay POST issues)
1989
2028
  if (msg.type === 'whisper:transcribe') {
@@ -2273,7 +2312,9 @@ mint();
2273
2312
  }));
2274
2313
  }
2275
2314
  }
2276
- } catch {}
2315
+ } catch (err: any) {
2316
+ log.warn(`[bloby] recent/status fetch failed (seeding without history): ${err?.message || err}`);
2317
+ }
2277
2318
 
2278
2319
  log.info(`[orchestrator] ──── USER MESSAGE ────`);
2279
2320
  log.info(`[orchestrator] Content: "${content.slice(0, 100)}..."`);
@@ -2394,7 +2435,8 @@ mint();
2394
2435
 
2395
2436
  // Bloby chat WebSocket — Vite HMR is handled automatically (hmr.server = this server)
2396
2437
  server.on('upgrade', async (req, socket: net.Socket, head) => {
2397
- console.log(`[supervisor] WebSocket upgrade: ${req.url} | protocol=${req.headers['sec-websocket-protocol'] || 'none'}`);
2438
+ // Strip the query string: /bloby/ws?token=<7-day session token> must not be logged.
2439
+ console.log(`[supervisor] WebSocket upgrade: ${(req.url || '').split('?')[0]} | protocol=${req.headers['sec-websocket-protocol'] || 'none'}`);
2398
2440
 
2399
2441
  // App API WebSocket — no auth (backend handles its own auth)
2400
2442
  if (req.url?.startsWith('/app/ws')) {
@@ -2738,7 +2780,15 @@ mint();
2738
2780
  }
2739
2781
 
2740
2782
  // Shutdown
2783
+ let shuttingDown = false;
2741
2784
  const shutdown = async () => {
2785
+ if (shuttingDown) return; // both SIGINT and SIGTERM (or a repeat) call this
2786
+ shuttingDown = true;
2787
+ // Hard-exit deadline: never let a hung teardown step (e.g. the relay `disconnect`
2788
+ // fetch on a dead network during sleep/wake) block process exit. Without this the
2789
+ // daemon manager SIGKILLs us, orphaning the backend child + cloudflared. .unref() so
2790
+ // the timer itself can never keep the loop alive past a clean, fast shutdown.
2791
+ setTimeout(() => process.exit(0), 5000).unref();
2742
2792
  log.info('Shutting down...');
2743
2793
  await channelManager.disconnectAll();
2744
2794
  stopScheduler();
package/worker/index.ts CHANGED
@@ -149,7 +149,24 @@ app.get('/api/conversations/:id/messages', (req, res) => {
149
149
  }
150
150
  });
151
151
  app.delete('/api/conversations/:id', (req, res) => { deleteConversation(req.params.id); res.json({ ok: true }); });
152
- app.get('/api/settings', (_, res) => res.json(getAllSettings()));
152
+ app.get('/api/settings', (_, res) => {
153
+ // SECURITY: GET /api/settings is reachable UNAUTHENTICATED — the supervisor auth gate
154
+ // skips GET/HEAD, and the public relay handle (bloby.bot/<handle>) proxies straight
155
+ // through to here. So this response must never carry credentials/secrets. Strip them.
156
+ // The known consumers (widget.js, bloby-main, workspace App.tsx) read only non-secret
157
+ // keys (onboard flags, user_name, agent_name, whisper_enabled); "is a password set" is
158
+ // exposed separately as portalConfigured on /api/onboard/status.
159
+ const SECRET_KEYS = new Set([
160
+ 'portal_pass', 'totp_secret', 'totp_pending_secret', 'totp_recovery_codes',
161
+ 'whisper_key', 'vapid_private_key',
162
+ ]);
163
+ const safe: Record<string, string> = {};
164
+ for (const [k, v] of Object.entries(getAllSettings())) {
165
+ if (SECRET_KEYS.has(k) || k.startsWith('totp_pending_login')) continue;
166
+ safe[k] = v as string;
167
+ }
168
+ res.json(safe);
169
+ });
153
170
  app.put('/api/settings/:key', (req, res) => {
154
171
  setSetting(req.params.key, req.body.value);
155
172
  res.json({ ok: true });