bloby-bot 0.63.0 → 0.65.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.63.0",
3
+ "version": "0.65.0",
4
4
  "releaseNotes": [
5
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
6
  "2. Affects chat thumbnails, the image lightbox, voice-note playback, and agent image cards",
@@ -32,7 +32,6 @@
32
32
  function sendChatSubscribe() {
33
33
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
34
34
  ws.send(JSON.stringify({ type: 'chat:subscribe', data: { clientId: chatClientId } }));
35
- console.log('[app-ws] chat:subscribe sent clientId=' + chatClientId);
36
35
  }
37
36
 
38
37
  function buildWsUrl() {
@@ -59,7 +58,6 @@
59
58
  for (var i = 0; i < ids.length; i++) {
60
59
  var p = pendingRequests[ids[i]];
61
60
  clearTimeout(p.timer);
62
- console.log('[app-ws] WS dropped, falling back to fetch: ' + p.method + ' ' + p.path);
63
61
  p.resolve(originalFetch(p.input, p.init));
64
62
  }
65
63
  pendingRequests = {};
@@ -74,7 +72,6 @@
74
72
  connected = true;
75
73
  reconnectDelay = RECONNECT_BASE;
76
74
  startHeartbeat();
77
- console.log('[app-ws] Connected to /app/ws');
78
75
  if (chatSubscribed) sendChatSubscribe();
79
76
  };
80
77
 
@@ -94,7 +91,6 @@
94
91
  }
95
92
 
96
93
  if (msg.type === 'chat:subscribed') {
97
- console.log('[app-ws] chat:subscribed ack clientId=' + (msg.data && msg.data.clientId));
98
94
  return;
99
95
  }
100
96
 
@@ -104,7 +100,6 @@
104
100
  clearTimeout(pending.timer);
105
101
  delete pendingRequests[msg.data.id];
106
102
 
107
- console.log('[app-ws] Response via WS: ' + msg.data.status + ' ' + pending.method + ' ' + pending.path);
108
103
 
109
104
  var responseBody = msg.data.body;
110
105
  if (responseBody === null || responseBody === undefined) responseBody = '';
@@ -125,7 +120,6 @@
125
120
  failoverPending();
126
121
 
127
122
  if (!intentionalClose) {
128
- console.log('[app-ws] Disconnected, reconnecting in ' + reconnectDelay + 'ms');
129
123
  reconnectTimer = setTimeout(function () {
130
124
  reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
131
125
  connect();
@@ -150,14 +144,12 @@
150
144
 
151
145
  // Fallback: WS not connected
152
146
  if (!connected || !ws || ws.readyState !== WebSocket.OPEN) {
153
- console.log('[app-ws] WS not connected, falling back to fetch: ' + method + ' ' + url);
154
147
  return originalFetch.apply(this, arguments);
155
148
  }
156
149
 
157
150
  // Fallback: non-serializable body (FormData, Blob, ArrayBuffer)
158
151
  var body = init && init.body;
159
152
  if (body && (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer)) {
160
- console.log('[app-ws] Non-serializable body, falling back to fetch: ' + method + ' ' + url);
161
153
  return originalFetch.apply(this, arguments);
162
154
  }
163
155
 
@@ -189,7 +181,6 @@
189
181
  bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
190
182
  }
191
183
 
192
- console.log('[app-ws] Proxying via WS: ' + method + ' ' + reqPath);
193
184
 
194
185
  ws.send(
195
186
  JSON.stringify({
@@ -204,7 +195,6 @@
204
195
  return new Promise(function (resolve, reject) {
205
196
  var timer = setTimeout(function () {
206
197
  delete pendingRequests[id];
207
- console.log('[app-ws] Request timed out, falling back to fetch: ' + method + ' ' + reqPath);
208
198
  resolve(originalFetch(savedInput, savedInit));
209
199
  }, REQUEST_TIMEOUT);
210
200
 
@@ -41,7 +41,6 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
41
41
  function handleExtMessage(event: MessageEvent) {
42
42
  if (event.data?.type === 'bloby:page-context') {
43
43
  extensionPageContext.current = event.data.context;
44
- console.log('[blobyChat] Extension page context:', event.data.context?.url);
45
44
  }
46
45
  }
47
46
 
@@ -153,11 +152,9 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
153
152
  useEffect(() => {
154
153
  if (!ws) return;
155
154
 
156
- console.log('[blobyChat] ──── WS HANDLERS REGISTERED ────');
157
155
 
158
156
  const unsubs = [
159
157
  ws.on('bot:typing', () => {
160
- console.log('[blobyChat] bot:typing → streaming=true');
161
158
  setStreaming(true);
162
159
  setTools([]);
163
160
  }),
@@ -175,7 +172,6 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
175
172
  // This creates separate message bubbles and shows dots during work
176
173
  const content = streamBufferRef.current;
177
174
  if (content) {
178
- console.log(`[blobyChat] bot:tool (${data.name}) — committing ${content.length} chars as bubble`);
179
175
  committedTextLength.current += content.length;
180
176
  setMessages((msgs) => [
181
177
  ...msgs,
@@ -205,9 +201,6 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
205
201
  if (committedTextLength.current > 0) {
206
202
  content = content.slice(committedTextLength.current).replace(/^\n+/, '');
207
203
  committedTextLength.current = 0;
208
- console.log('[blobyChat] bot:response — stripped partial, remaining:', content.slice(0, 60));
209
- } else {
210
- console.log('[blobyChat] bot:response — new bubble');
211
204
  }
212
205
 
213
206
  // Only add a bubble if there's new content
@@ -232,7 +225,6 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
232
225
  ws.on('bot:task-created', () => {
233
226
  const content = streamBufferRef.current;
234
227
  if (content) {
235
- console.log('[blobyChat] bot:task-created — committing stream, showing dots');
236
228
  committedTextLength.current += content.length;
237
229
  setMessages((msgs) => [
238
230
  ...msgs,
@@ -250,11 +242,9 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
250
242
  }),
251
243
  ws.on('bot:idle', () => {
252
244
  // Server confirmed agent is idle — safe to stop streaming
253
- console.log('[blobyChat] bot:idle → streaming=false');
254
245
  setStreaming(false);
255
246
  }),
256
247
  ws.on('bot:error', (data: { error: string }) => {
257
- console.log('[blobyChat] bot:error');
258
248
  setStreamBuffer('');
259
249
  streamBufferRef.current = '';
260
250
  setStreaming(false);
@@ -334,7 +324,6 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
334
324
  // If bot is streaming, commit partial response first
335
325
  const partialContent = streamBufferRef.current;
336
326
  if (partialContent) {
337
- console.log('[blobyChat] Committing partial —', partialContent.length, 'chars');
338
327
  committedTextLength.current += partialContent.length;
339
328
  setMessages((msgs) => [
340
329
  ...msgs,
@@ -128,7 +128,6 @@ export function useChat(ws: WsClient | null) {
128
128
 
129
129
  const unsubs = [
130
130
  ws.on('bot:typing', () => {
131
- console.log('[useChat] bot:typing → streaming=true');
132
131
  setStreaming(true);
133
132
  setTools([]);
134
133
  }),
@@ -149,7 +148,6 @@ export function useChat(ws: WsClient | null) {
149
148
  });
150
149
  }),
151
150
  ws.on('bot:response', (data: { conversationId: string; messageId?: string; content: string }) => {
152
- console.log('[useChat] bot:response — adding message bubble');
153
151
  setConversationId(data.conversationId);
154
152
 
155
153
  // Always add as a new bubble
@@ -170,11 +168,9 @@ export function useChat(ws: WsClient | null) {
170
168
  }),
171
169
  ws.on('bot:idle', () => {
172
170
  // Server confirmed agent is idle — safe to stop streaming
173
- console.log('[useChat] bot:idle → streaming=false');
174
171
  setStreaming(false);
175
172
  }),
176
173
  ws.on('bot:error', (data: { error: string }) => {
177
- console.log('[useChat] bot:error');
178
174
  setStreamBuffer('');
179
175
  streamBufferRef.current = '';
180
176
  setStreaming(false);
@@ -218,7 +214,6 @@ export function useChat(ws: WsClient | null) {
218
214
  // so the user's new message appears BELOW the bot's in-progress text (chronological order)
219
215
  const partialContent = streamBufferRef.current;
220
216
  if (partialContent) {
221
- console.log('[useChat] Committing partial stream buffer as message before user send');
222
217
  setMessages((msgs) => [
223
218
  ...msgs,
224
219
  {
@@ -27,6 +27,7 @@ import { startScheduler, stopScheduler, readPulseConfig, readCronsConfig, nextRu
27
27
  import { execSync, spawn as cpSpawn } from 'child_process';
28
28
  import crypto from 'crypto';
29
29
  import { ChannelManager } from './channels/manager.js';
30
+ import { SHELL_HTML } from './shell.js';
30
31
 
31
32
  // Last-resort process-level safety nets. The supervisor is the SINGLE process that keeps
32
33
  // chat alive (G1) and heals the backend (G3); a stray throw inside an emitter callback
@@ -120,32 +121,29 @@ const SW_JS = `// Service worker — app-shell caching + push notifications
120
121
  // cached shell that masks a broken (or just-fixed) frontend and produces the confusing
121
122
  // "normal refresh is broken but hard refresh works" split. Cache is a pure offline fallback.
122
123
 
123
- var CACHE = 'bloby-v23';
124
+ var CACHE = 'bloby-v24';
124
125
  var HASHED_RE = new RegExp('/assets/.+-[a-zA-Z0-9]{6,}[.](js|css)$');
125
126
 
126
127
  // Precache the HTML shell on install so the cache is never empty.
127
128
  // Without this, the first navigation isn't intercepted (SW wasn't
128
129
  // controlling yet), so refresh would find an empty cache → white screen.
129
130
  self.addEventListener('install', function(e) {
130
- console.log('[SW] installing, cache:', CACHE);
131
131
  e.waitUntil(
132
132
  caches.open(CACHE)
133
133
  .then(function(c) { return c.add('/'); })
134
- .then(function() { console.log('[SW] precached / — calling skipWaiting'); return self.skipWaiting(); })
134
+ .then(function() { return self.skipWaiting(); })
135
135
  .catch(function(err) { console.error('[SW] install failed:', err); throw err; })
136
136
  );
137
137
  });
138
138
 
139
139
  self.addEventListener('activate', function(e) {
140
- console.log('[SW] activating, cache:', CACHE);
141
140
  e.waitUntil(
142
141
  caches.keys()
143
142
  .then(function(keys) {
144
143
  var old = keys.filter(function(k) { return k !== CACHE; });
145
- if (old.length) console.log('[SW] deleting old caches:', old);
146
144
  return Promise.all(old.map(function(k) { return caches.delete(k); }));
147
145
  })
148
- .then(function() { console.log('[SW] claiming clients'); return self.clients.claim(); })
146
+ .then(function() { return self.clients.claim(); })
149
147
  );
150
148
  });
151
149
 
@@ -183,18 +181,20 @@ self.addEventListener('fetch', function(event) {
183
181
  // /bloby/* is a separate app — let it go to network to avoid
184
182
  // caching the wrong HTML under the / key.
185
183
  if (request.mode === 'navigate') {
186
- console.log('[SW] navigate ', url.pathname);
184
+ // Workspace iframe documents (?__bloby_frame=1) share pathname '/' with the shell.
185
+ // Network-only: never c.put() them (would overwrite the precached shell under the '/'
186
+ // key) and never fall back to c.match('/') (would serve the shell INTO the iframe).
187
+ if (url.searchParams.has('__bloby_frame')) return;
187
188
  if (url.pathname === '/' || url.pathname === '/index.html') {
188
189
  // Network-first: always fetch the live dashboard; only fall back to cache when offline.
189
190
  event.respondWith(caches.open(CACHE).then(function(c) {
190
191
  return fetch(request)
191
192
  .then(function(r) { if (r.ok) c.put('/', r.clone()); return r; })
192
- .catch(function(err) { console.warn('[SW] / network failed, using cache:', err.message); return c.match('/'); });
193
+ .catch(function() { return c.match('/'); });
193
194
  }));
194
195
  return;
195
196
  }
196
197
  // Other navigations (/bloby/*, etc.) — network only
197
- console.log('[SW] navigate (network-only) →', url.pathname);
198
198
  return;
199
199
  }
200
200
 
@@ -421,7 +421,6 @@ export async function startSupervisor() {
421
421
  log.error(`Vite dev server failed to start — dashboard degraded, chat still available: ${err instanceof Error ? err.message : err}`);
422
422
  vitePorts = { dashboard: -1 }; // sentinel → dashboard proxy serves RECOVERING_HTML
423
423
  }
424
- console.log(`[supervisor] Upgrade listeners on server: ${server.listenerCount('upgrade')}`);
425
424
 
426
425
  // Ensure file storage dirs exist
427
426
  ensureFileDirs();
@@ -584,7 +583,6 @@ export async function startSupervisor() {
584
583
  server.on('request', async (req, res) => {
585
584
  // Bloby widget — served directly (not part of Vite build)
586
585
  if (req.url === '/bloby/widget.js') {
587
- console.log('[supervisor] Serving /bloby/widget.js directly');
588
586
  res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
589
587
  res.end(fs.readFileSync(paths.widgetJs));
590
588
  return;
@@ -592,7 +590,6 @@ export async function startSupervisor() {
592
590
 
593
591
  // App WS client — served directly (proxies /app/api calls through WebSocket)
594
592
  if (req.url === '/bloby/app-ws.js') {
595
- console.log('[supervisor] Serving /bloby/app-ws.js directly');
596
593
  res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
597
594
  res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'app-ws.js')));
598
595
  return;
@@ -626,9 +623,7 @@ export async function startSupervisor() {
626
623
  // App API routes → proxy to user's backend server
627
624
  if (req.url?.startsWith('/app/api')) {
628
625
  const backendPath = req.url.replace(/^\/app/, '');
629
- console.log(`[supervisor] → backend :${backendPort} | ${req.method} ${backendPath}`);
630
626
  if (!isBackendAlive()) {
631
- console.log('[supervisor] Backend down — returning 503');
632
627
  res.writeHead(503, { 'Content-Type': 'application/json' });
633
628
  res.end(JSON.stringify({ error: 'Backend is starting...' }));
634
629
  return;
@@ -639,18 +634,10 @@ export async function startSupervisor() {
639
634
  (proxyRes) => {
640
635
  const ct = String(proxyRes.headers['content-type'] || '');
641
636
  const isSse = ct.includes('text/event-stream');
642
- if (isSse) {
643
- console.log(`[app-proxy] SSE upstream status=${proxyRes.statusCode} ct="${ct}" url=${backendPath}`);
644
- }
645
637
  res.writeHead(proxyRes.statusCode!, proxyRes.headers);
638
+ // SSE needs Nagle off so chat tokens flush immediately instead of in batched frames.
646
639
  if (isSse) {
647
640
  try { res.socket?.setNoDelay(true); } catch {}
648
- proxyRes.on('data', (chunk: Buffer) => {
649
- console.log(`[app-proxy] SSE chunk bytes=${chunk.length} preview=${JSON.stringify(chunk.toString('utf-8').slice(0, 80))}`);
650
- });
651
- proxyRes.on('end', () => console.log(`[app-proxy] SSE upstream END`));
652
- proxyRes.on('error', (e: any) => console.log(`[app-proxy] SSE upstream ERROR ${e.message}`));
653
- res.on('close', () => console.log(`[app-proxy] SSE res CLOSE`));
654
641
  }
655
642
  proxyRes.pipe(res);
656
643
  },
@@ -2134,7 +2121,6 @@ ${alreadyLinked ? '' : `
2134
2121
  if (req.method === 'GET' && agentPath === '/api/agent/chat/stream') {
2135
2122
  const clientId = urlObj.searchParams.get('clientId') || undefined;
2136
2123
  const subId = crypto.randomBytes(8).toString('hex');
2137
- console.log(`[sse-handler] OPEN sub=${subId} clientId=${clientId} remote=${req.socket.remoteAddress}`);
2138
2124
 
2139
2125
  res.setHeader('Content-Type', 'text/event-stream');
2140
2126
  res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
@@ -2142,19 +2128,14 @@ ${alreadyLinked ? '' : `
2142
2128
  res.setHeader('X-Accel-Buffering', 'no');
2143
2129
  res.writeHead(200);
2144
2130
  try { res.socket?.setNoDelay(true); } catch {}
2145
- const wrote = res.write(': connected\n\n');
2146
- console.log(`[sse-handler] wrote initial comment sub=${subId} writeOk=${wrote}`);
2131
+ res.write(': connected\n\n');
2147
2132
 
2148
2133
  const sub: ChatSubscriber = {
2149
2134
  id: subId,
2150
2135
  clientId,
2151
2136
  send: (type, data) => {
2152
- if (res.writableEnded) {
2153
- console.log(`[sse-handler] skip write (ended) sub=${subId} type=${type}`);
2154
- return;
2155
- }
2156
- const ok = res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
2157
- console.log(`[sse-handler] write sub=${subId} type=${type} ok=${ok} bytes=${(type.length + JSON.stringify(data).length + 10)}`);
2137
+ if (res.writableEnded) return;
2138
+ res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
2158
2139
  },
2159
2140
  close: () => { try { res.end(); } catch {} },
2160
2141
  };
@@ -2171,20 +2152,14 @@ ${alreadyLinked ? '' : `
2171
2152
  const keepAlive = setInterval(() => {
2172
2153
  if (res.writableEnded) return;
2173
2154
  res.write(': ping\n\n');
2174
- console.log(`[sse-handler] ping sub=${subId}`);
2175
2155
  }, 25_000);
2176
2156
 
2177
2157
  req.on('close', () => {
2178
- console.log(`[sse-handler] CLOSE sub=${subId} reason=req-close`);
2179
2158
  clearInterval(keepAlive);
2180
2159
  chatSubscribers.delete(sub);
2181
2160
  });
2182
- res.on('close', () => {
2183
- console.log(`[sse-handler] CLOSE sub=${subId} reason=res-close`);
2184
- });
2185
- res.on('error', (err: any) => {
2186
- console.log(`[sse-handler] ERROR sub=${subId} ${err?.message}`);
2187
- });
2161
+ // Keep an error listener so a client reset can't surface as an unhandled 'error'.
2162
+ res.on('error', () => {});
2188
2163
  return;
2189
2164
  }
2190
2165
 
@@ -2560,6 +2535,47 @@ ${alreadyLinked ? '' : `
2560
2535
  } catch { /* fall through to Vite */ }
2561
2536
  }
2562
2537
 
2538
+ // Document-ish request? Used by the backend-down interstitial (NOT the shell branch,
2539
+ // which needs top-level-only gating). sec-fetch-dest 'document' covers top-level
2540
+ // navigations; sec-fetch-mode 'navigate' also matches iframe documents (dest 'iframe',
2541
+ // wanted here — the interstitial must show inside the workspace frame too); the accept
2542
+ // fallback covers old clients that send no sec-fetch headers at all.
2543
+ const wantsHtml = req.method === 'GET' && (
2544
+ req.headers['sec-fetch-dest'] === 'document' ||
2545
+ req.headers['sec-fetch-mode'] === 'navigate' ||
2546
+ (!req.headers['sec-fetch-dest'] && String(req.headers['accept'] || '').includes('text/html'))
2547
+ );
2548
+
2549
+ // SHELL INVERSION: TOP-LEVEL document navigations get the immortal shell (bubble +
2550
+ // chat + a same-origin iframe that hosts the actual workspace app). The iframe
2551
+ // re-requests the same URL with ?__bloby_frame=1, which skips this branch and falls
2552
+ // through to the interstitials / Vite proxy below — so Vite reloads, rebuilds, and
2553
+ // crash pages all happen INSIDE the iframe while the chat chrome survives. (The
2554
+ // Lovable/Bolt pattern.) The __bloby_frame param is lost on real in-frame navigations
2555
+ // (<a href> links, location.href redirects, 302s), so the frame is also excluded by
2556
+ // sec-fetch-dest — those requests carry dest 'iframe' and must fall through to Vite,
2557
+ // never get a nested shell. wantsHtml is intentionally NOT reused here: its
2558
+ // mode === 'navigate' arm matches iframe documents (fine for the interstitial below,
2559
+ // wrong for the shell). pathname === '/' is special-cased because the service worker's
2560
+ // install-time precache fetch of '/' may carry no sec-fetch/accept headers yet must
2561
+ // still cache the shell.
2562
+ // Kill switch: BLOBY_NO_SHELL=1 restores legacy behavior (workspace served top-level;
2563
+ // widget.js injects the bubble into the workspace document as before).
2564
+ const fetchDest = req.headers['sec-fetch-dest'];
2565
+ const isSubframe = fetchDest === 'iframe' || fetchDest === 'frame' || fetchDest === 'embed' || fetchDest === 'object';
2566
+ if (
2567
+ req.method === 'GET' &&
2568
+ process.env.BLOBY_NO_SHELL !== '1' &&
2569
+ !(req.url || '').includes('__bloby_frame=1') &&
2570
+ !isSubframe &&
2571
+ (cleanUrl === '/' || fetchDest === 'document' ||
2572
+ (!fetchDest && String(req.headers['accept'] || '').includes('text/html')))
2573
+ ) {
2574
+ res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache', 'X-Bloby-Origin': 'supervisor' });
2575
+ res.end(SHELL_HTML);
2576
+ return;
2577
+ }
2578
+
2563
2579
  // Workspace backend has crash-looped and given up → serve the "backend down" interstitial
2564
2580
  // for dashboard DOCUMENT navigations, instead of proxying to Vite (which serves the user's
2565
2581
  // SPA that then 503s on every /app/api call and, for the common workspace-lock template,
@@ -2567,11 +2583,6 @@ ${alreadyLinked ? '' : `
2567
2583
  // top-level navigations only (not assets/HMR/XHR) and only when the backend has truly given
2568
2584
  // up — never during a normal 1–2s restart. The chat PWA (/bloby/*) is served earlier and is
2569
2585
  // unaffected.
2570
- const wantsHtml = req.method === 'GET' && (
2571
- req.headers['sec-fetch-dest'] === 'document' ||
2572
- req.headers['sec-fetch-mode'] === 'navigate' ||
2573
- (!req.headers['sec-fetch-dest'] && String(req.headers['accept'] || '').includes('text/html'))
2574
- );
2575
2586
  if (wantsHtml && isBackendDead()) {
2576
2587
  // X-Bloby-Origin marks this as the agent's OWN branded page so the relay passes it through
2577
2588
  // (and never mistakes it for a Cloudflare tunnel error to be replaced).
@@ -2589,7 +2600,6 @@ ${alreadyLinked ? '' : `
2589
2600
  }
2590
2601
 
2591
2602
  // Everything else → proxy to dashboard Vite dev server
2592
- console.log(`[supervisor] → dashboard Vite :${vitePorts.dashboard} | ${req.method} ${(req.url || '').split('?')[0]}`);
2593
2603
  const GUARD_TAG = '<script defer src="/bloby/workspace-guard.js"></script>';
2594
2604
  const proxy = http.request(
2595
2605
  { host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: req.headers },
@@ -2638,10 +2648,9 @@ ${alreadyLinked ? '' : `
2638
2648
  const appWss = new WebSocketServer({ noServer: true });
2639
2649
 
2640
2650
  appWss.on('connection', (ws) => {
2641
- console.log('[supervisor] App API WS client connected');
2642
2651
  // An 'error' event with no listener is rethrown by Node as an uncaught exception,
2643
2652
  // which would crash the whole supervisor. ws still tears down + fires 'close'.
2644
- ws.on('error', (err: any) => console.warn(`[app-ws] socket error: ${err?.message || err}`));
2653
+ ws.on('error', () => {});
2645
2654
  // Liveness: a half-open socket (mobile/Wi-Fi drop behind the tunnel) never fires 'close', so
2646
2655
  // its chat subscription + maps would leak and broadcastBloby would keep writing to it. The
2647
2656
  // heartbeat below pings; a peer that misses a pong is terminated (which fires 'close' → cleanup).
@@ -2683,7 +2692,6 @@ ${alreadyLinked ? '' : `
2683
2692
  close: () => {},
2684
2693
  };
2685
2694
  chatSubscribers.add(chatSub);
2686
- console.log(`[app-ws-chat] subscribe sub=${subId} clientId=${clientId} total=${chatSubscribers.size}`);
2687
2695
 
2688
2696
  if (agentQueryActive && currentStreamConvId) {
2689
2697
  chatSub.send('chat:state', {
@@ -2701,7 +2709,6 @@ ${alreadyLinked ? '' : `
2701
2709
  if (msg.type === 'chat:unsubscribe') {
2702
2710
  if (chatSub) {
2703
2711
  chatSubscribers.delete(chatSub);
2704
- console.log(`[app-ws-chat] unsubscribe sub=${chatSub.id} total=${chatSubscribers.size}`);
2705
2712
  chatSub = null;
2706
2713
  }
2707
2714
  return;
@@ -2712,10 +2719,8 @@ ${alreadyLinked ? '' : `
2712
2719
  const { id, method, path: reqPath, headers: reqHeaders, body } = msg.data;
2713
2720
  const backendPath = (reqPath || '').replace(/^\/app/, '');
2714
2721
 
2715
- console.log(`[supervisor] App WS → backend :${backendPort} | ${method} ${backendPath} (${id})`);
2716
2722
 
2717
2723
  if (!isBackendAlive()) {
2718
- console.log('[supervisor] App WS: Backend down — returning 503');
2719
2724
  if (ws.readyState === WebSocket.OPEN) {
2720
2725
  ws.send(JSON.stringify({
2721
2726
  type: 'app:api:response',
@@ -2743,7 +2748,6 @@ ${alreadyLinked ? '' : `
2743
2748
  else if (Array.isArray(v)) resHeaders[k] = v.join(', ');
2744
2749
  }
2745
2750
 
2746
- console.log(`[supervisor] App WS ← backend: ${proxyRes.statusCode} (${id})`);
2747
2751
 
2748
2752
  if (ws.readyState === WebSocket.OPEN) {
2749
2753
  ws.send(JSON.stringify({
@@ -2772,10 +2776,8 @@ ${alreadyLinked ? '' : `
2772
2776
  ws.on('close', () => {
2773
2777
  if (chatSub) {
2774
2778
  chatSubscribers.delete(chatSub);
2775
- console.log(`[app-ws-chat] auto-unsubscribe on close sub=${chatSub.id} total=${chatSubscribers.size}`);
2776
2779
  chatSub = null;
2777
2780
  }
2778
- console.log('[supervisor] App API WS client disconnected');
2779
2781
  });
2780
2782
  });
2781
2783
 
@@ -2783,18 +2785,11 @@ ${alreadyLinked ? '' : `
2783
2785
  * subscribers. The workspace mirror sees the exact same event stream the widget does. */
2784
2786
  function broadcastBloby(type: string, data: any = {}) {
2785
2787
  const msg = JSON.stringify({ type, data });
2786
- let wsCount = 0;
2787
2788
  for (const client of blobyWss.clients) {
2788
- if (client.readyState === WebSocket.OPEN) { client.send(msg); wsCount++; }
2789
+ if (client.readyState === WebSocket.OPEN) client.send(msg);
2789
2790
  }
2790
- let sseCount = 0;
2791
2791
  for (const sub of chatSubscribers) {
2792
- try { sub.send(type, data); sseCount++; } catch (e: any) {
2793
- console.log(`[sse-broadcast] send failed sub=${sub.id}: ${e.message}`);
2794
- }
2795
- }
2796
- if (type.startsWith('bot:') || type.startsWith('chat:')) {
2797
- console.log(`[sse-broadcast] type=${type} ws=${wsCount} sse=${sseCount} subs=${chatSubscribers.size}`);
2792
+ try { sub.send(type, data); } catch {}
2798
2793
  }
2799
2794
  }
2800
2795
 
@@ -3365,17 +3360,14 @@ ${alreadyLinked ? '' : `
3365
3360
  // Bloby chat WebSocket — Vite HMR is handled automatically (hmr.server = this server)
3366
3361
  server.on('upgrade', async (req, socket: net.Socket, head) => {
3367
3362
  // Strip the query string: /bloby/ws?token=<7-day session token> must not be logged.
3368
- console.log(`[supervisor] WebSocket upgrade: ${(req.url || '').split('?')[0]} | protocol=${req.headers['sec-websocket-protocol'] || 'none'}`);
3369
3363
 
3370
3364
  // App API WebSocket — no auth (backend handles its own auth)
3371
3365
  if (req.url?.startsWith('/app/ws')) {
3372
- console.log('[supervisor] → App API WebSocket');
3373
3366
  appWss.handleUpgrade(req, socket, head, (ws) => appWss.emit('connection', ws, req));
3374
3367
  return;
3375
3368
  }
3376
3369
 
3377
3370
  if (!req.url?.startsWith('/bloby/ws')) {
3378
- console.log('[supervisor] → Letting Vite handle this upgrade');
3379
3371
  return;
3380
3372
  }
3381
3373
 
@@ -3392,7 +3384,6 @@ ${alreadyLinked ? '' : `
3392
3384
  }
3393
3385
  }
3394
3386
 
3395
- console.log('[supervisor] → Bloby chat WebSocket');
3396
3387
  blobyWss.handleUpgrade(req, socket, head, (ws) => blobyWss.emit('connection', ws, req));
3397
3388
  });
3398
3389
 
@@ -3818,7 +3809,6 @@ ${alreadyLinked ? '' : `
3818
3809
  closeDb();
3819
3810
  await stopBackend();
3820
3811
  stopTunnel();
3821
- console.log('[supervisor] Stopping Vite dev servers...');
3822
3812
  await stopViteDevServers();
3823
3813
  server.close();
3824
3814
  process.exit(0);
@@ -0,0 +1,120 @@
1
+ // The immortal shell — served by the supervisor at top-level '/' (and document navigations).
2
+ // It contains ONLY the bubble + chat chrome (widget.js) and a same-origin iframe hosting the
3
+ // user's workspace app. There is NO Vite client here, NO HMR, no service-worker auto-reload —
4
+ // nothing that can reload this page. All workspace reloads, rebuilds, and interstitials
5
+ // (RECOVERING_HTML / backend-down) happen INSIDE the iframe, so the chat — the user's lifeline
6
+ // to the agent — never dies. The iframe src carries ?__bloby_frame=1 so the supervisor routes
7
+ // it past the shell branch to the real workspace (Vite proxy / interstitials).
8
+ // Kill switch: BLOBY_NO_SHELL=1 makes the supervisor skip this entirely (legacy top-level
9
+ // workspace). Static string by design — no server-side templating.
10
+
11
+ export const SHELL_HTML = `<!DOCTYPE html>
12
+ <html lang="en" style="background-color:#0A0A0A;margin:0;overflow:hidden">
13
+ <head>
14
+ <meta charset="UTF-8" />
15
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
16
+ <meta name="theme-color" content="#212121" />
17
+ <meta name="mobile-web-app-capable" content="yes" />
18
+ <meta name="apple-mobile-web-app-capable" content="yes" />
19
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
20
+ <meta name="apple-mobile-web-app-title" content="Bloby" />
21
+ <link rel="icon" type="image/png" href="/morphy-favicon.png" />
22
+ <link rel="apple-touch-icon" href="/morphy-icon-192.png" />
23
+ <link rel="manifest" href="/manifest.json" />
24
+ <title>Bloby</title>
25
+ </head>
26
+ <body style="background-color:#0A0A0A;margin:0;overflow:hidden">
27
+ <!-- Dark background fallback — visible instantly while widget.js loads.
28
+ The canvas animation (in widget.js) takes over as the actual splash. -->
29
+ <div id="splash" style="background:#0A0A0A;position:fixed;inset:0;z-index:9998;transition:opacity .25s ease-out"></div>
30
+
31
+ <iframe
32
+ id="bloby-workspace"
33
+ style="position:fixed;inset:0;width:100vw;height:100dvh;border:none;background:#0A0A0A"
34
+ allow="camera; microphone; geolocation; clipboard-read; clipboard-write; fullscreen; autoplay; display-capture"
35
+ ></iframe>
36
+
37
+ <script>
38
+ (function () {
39
+ var frame = document.getElementById('bloby-workspace');
40
+
41
+ // Preserve deep links: the iframe loads the SAME url the shell was opened with, plus
42
+ // __bloby_frame=1 so the supervisor serves the workspace instead of the shell again.
43
+ // Collapse leading slashes first — a pathname like '//evil.com' would otherwise be a
44
+ // protocol-relative URL and point the iframe off-origin under our trusted domain.
45
+ var path = location.pathname.replace(/^\\/+/, '/');
46
+ var target = new URL(path + location.search + location.hash, location.origin);
47
+ if (target.origin !== location.origin) target = new URL('/', location.origin);
48
+ target.searchParams.set('__bloby_frame', '1');
49
+
50
+ // Framed shell self-check: browsers without Fetch Metadata (older Safari/WebViews) can
51
+ // get a shell served INTO the workspace iframe when an in-frame navigation loses the
52
+ // __bloby_frame param (the supervisor's sec-fetch-dest exclusion can't see those).
53
+ // A nested shell replaces itself with the workspace document it should have been.
54
+ try {
55
+ if (window.top !== window) {
56
+ location.replace(target.pathname + target.search + target.hash);
57
+ return;
58
+ }
59
+ } catch (e) { /* cross-origin top — not our nesting case; carry on */ }
60
+
61
+ frame.src = target.pathname + target.search + target.hash;
62
+
63
+ // widget.js (loaded below, in THIS document) waits for window.__blobyAppReady / the
64
+ // 'bloby:app-ready' window event before transitioning splash → bubble. The workspace
65
+ // posts 'bloby:app-ready' up to us; we re-dispatch it locally. Idempotent.
66
+ var appReadyFired = false;
67
+ function fireAppReady() {
68
+ if (appReadyFired) return;
69
+ appReadyFired = true;
70
+ window.__blobyAppReady = true;
71
+ window.dispatchEvent(new Event('bloby:app-ready'));
72
+ }
73
+
74
+ window.addEventListener('message', function (e) {
75
+ // Same-origin iframe only — drop anything else (extensions, third-party embeds).
76
+ if (e.origin !== location.origin) return;
77
+ var d = e.data;
78
+ if (!d || typeof d.type !== 'string') return;
79
+ if (d.type === 'bloby:app-ready') {
80
+ fireAppReady();
81
+ } else if (d.type === 'bloby:title') {
82
+ document.title = String(d.title || '').slice(0, 200) || 'Bloby';
83
+ }
84
+ });
85
+
86
+ // Interstitials and broken workspaces never post 'bloby:app-ready' — the bubble must
87
+ // still appear (it IS the recovery path). Fire on iframe load, with a timer backstop
88
+ // for the case where the iframe never finishes loading at all.
89
+ frame.addEventListener('load', fireAppReady);
90
+ setTimeout(fireAppReady, 8000);
91
+
92
+ // Last-resort splash backstop: only widget.js ever hides #splash (it does so as soon
93
+ // as its sprite loads, ~1s). If widget.js fails to load, throws, or its sprite fetch
94
+ // fails, the shell — the user's lifeline — must not sit as a permanent black screen
95
+ // over the workspace iframe.
96
+ setTimeout(function () {
97
+ var s = document.getElementById('splash');
98
+ if (s && s.style.display !== 'none') s.style.display = 'none';
99
+ }, 10000);
100
+ })();
101
+ </script>
102
+
103
+ <script>
104
+ // Register the SW but DO NOT auto-reload on controllerchange.
105
+ // The old pattern (skipWaiting + claim + controllerchange → location.reload)
106
+ // caused surprise reloads on mobile PWAs: iOS aggressively re-checks sw.js
107
+ // and any byte-level variation in the response (esp. via Vite dev) trips a
108
+ // new SW activation → controllerchange → unwanted reload. The new SW
109
+ // silently takes over for future fetches; the user gets the new build on
110
+ // their next natural refresh.
111
+ if ('serviceWorker' in navigator) {
112
+ navigator.serviceWorker.register('/sw.js').catch(function (err) {
113
+ console.error('[sw-reg] registration failed:', err);
114
+ });
115
+ }
116
+ </script>
117
+ <script src="/bloby/widget.js"></script>
118
+ </body>
119
+ </html>
120
+ `;