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.
@@ -11,9 +11,6 @@ export async function startViteDevServers(supervisorPort: number, hmrServer: htt
11
11
  dashboard: supervisorPort + 2,
12
12
  };
13
13
 
14
- console.log(`[vite-dev] Starting dashboard Vite dev server on :${ports.dashboard}`);
15
- console.log(`[vite-dev] HMR attached to supervisor server`);
16
- console.log(`[vite-dev] PKG_DIR = ${PKG_DIR}`);
17
14
 
18
15
  try {
19
16
  dashboardVite = await createViteServer({
@@ -31,7 +28,6 @@ export async function startViteDevServers(supervisorPort: number, hmrServer: htt
31
28
  logLevel: 'info',
32
29
  });
33
30
  await dashboardVite.listen();
34
- console.log(`[vite-dev] ✓ Dashboard Vite ready on :${ports.dashboard}`);
35
31
  } catch (err) {
36
32
  console.error('[vite-dev] ✗ Dashboard Vite failed:', err);
37
33
  throw err;
@@ -58,15 +54,12 @@ export async function startViteDevServers(supervisorPort: number, hmrServer: htt
58
54
  /** Tell connected browsers to full-reload via Vite's HMR WebSocket */
59
55
  export function reloadDashboard(): void {
60
56
  if (!dashboardVite) return;
61
- console.log('[vite-dev] Sending full-reload to dashboard clients');
62
57
  dashboardVite.hot.send({ type: 'full-reload', path: '*' });
63
58
  }
64
59
 
65
60
  export async function stopViteDevServers(): Promise<void> {
66
- console.log('[vite-dev] Stopping Vite dev servers...');
67
61
  if (dashboardVite) {
68
62
  await dashboardVite.close();
69
63
  dashboardVite = null;
70
- console.log('[vite-dev] Dashboard Vite stopped');
71
64
  }
72
65
  }
@@ -1,4 +1,19 @@
1
1
  (function () {
2
+ // app-ws.js (the /app/api → /app/ws fetch proxy) must load in EVERY document
3
+ // that includes widget.js — in shell mode the workspace runs inside the
4
+ // iframe, where the early return below would skip it, leaving the user's
5
+ // /app/api calls as raw tunnel POSTs. app-ws.js guards itself
6
+ // (window.__appWs), so duplicate loads are no-ops.
7
+ var awsScript = document.createElement('script');
8
+ awsScript.src = '/bloby/app-ws.js';
9
+ document.head.appendChild(awsScript);
10
+
11
+ // Shell mode: the immortal shell loads this script at top level, but legacy
12
+ // workspace index.html + the interstitial pages still reference it too, and
13
+ // those now render inside the shell's iframe — inside any iframe this must
14
+ // no-op so exactly one bubble exists. Cross-origin window.top access throws,
15
+ // which also means we're not the top window.
16
+ try { if (window.top !== window) return; } catch (e) { return; }
2
17
  if (document.getElementById('bloby-widget')) return;
3
18
 
4
19
  var PANEL_WIDTH = '480px';
@@ -571,7 +586,7 @@
571
586
  var reader = new FileReader();
572
587
  reader.onloadend = function() {
573
588
  var base64 = reader.result.split(',')[1];
574
- if (base64 && iframe.contentWindow) iframe.contentWindow.postMessage({ type: 'bloby:voice-record', audio: base64 }, '*');
589
+ if (base64 && iframe.contentWindow) iframe.contentWindow.postMessage({ type: 'bloby:voice-record', audio: base64 }, location.origin);
575
590
  };
576
591
  reader.readAsDataURL(blob);
577
592
  };
@@ -583,7 +598,7 @@
583
598
  if (sent) return; sent = true;
584
599
  var text = hpSpeechTranscript.trim();
585
600
  hpSpeechTranscript = '';
586
- if (text && iframe.contentWindow) iframe.contentWindow.postMessage({ type: 'bloby:voice-record', transcript: text }, '*');
601
+ if (text && iframe.contentWindow) iframe.contentWindow.postMessage({ type: 'bloby:voice-record', transcript: text }, location.origin);
587
602
  };
588
603
  instance.onend = function() { hpSpeechInstance = null; sendTranscript(); };
589
604
  try { instance.stop(); } catch(e) { hpSpeechInstance = null; sendTranscript(); }
@@ -669,7 +684,6 @@
669
684
  var toggleCooldown = false;
670
685
 
671
686
  function toggle() {
672
- console.log('[widget] toggle called', { canvasPhase: canvasPhase, isOpen: isOpen });
673
687
  if (canvasPhase === 'splash' || canvasPhase === 'transitioning') {
674
688
  skipToBubble();
675
689
  try { sessionStorage.setItem(SPLASH_KEY, '1'); } catch(e) {}
@@ -694,14 +708,12 @@
694
708
  // Hold detected via pointerdown timer. pointerup stops recording.
695
709
 
696
710
  bubble.addEventListener('pointerdown', function(e) {
697
- console.log('[widget] pointerdown', { canvasPhase: canvasPhase, hpState: hpState, type: e.pointerType });
698
711
  if (canvasPhase !== 'bubble') return;
699
712
  e.preventDefault(); // Prevent iOS long-press text selection
700
713
  hpPointerDown = true;
701
714
  hpWasHold = false;
702
715
 
703
716
  hpHoldTimer = setTimeout(function() {
704
- console.log('[widget] hold timer fired');
705
717
  hpWasHold = true;
706
718
  if (hpState === 'idle' || hpState === 'deactivating') {
707
719
  startHpRecording();
@@ -710,7 +722,6 @@
710
722
  });
711
723
 
712
724
  bubble.addEventListener('pointerup', function() {
713
- console.log('[widget] pointerup', { hpState: hpState, hpWasHold: hpWasHold });
714
725
  hpPointerDown = false;
715
726
  if (hpHoldTimer) { clearTimeout(hpHoldTimer); hpHoldTimer = null; }
716
727
  if (hpState === 'activating' || hpState === 'recording') {
@@ -728,7 +739,6 @@
728
739
  });
729
740
 
730
741
  bubble.addEventListener('pointercancel', function() {
731
- console.log('[widget] pointercancel');
732
742
  hpPointerDown = false;
733
743
  if (hpHoldTimer) { clearTimeout(hpHoldTimer); hpHoldTimer = null; }
734
744
  if (hpState === 'activating' || hpState === 'recording') stopHpRecording(true);
@@ -737,7 +747,6 @@
737
747
  // click still fires on desktop (pointerdown preventDefault does not suppress it there).
738
748
  // On mobile, pointerup already handled the tap, so this is a no-op guard.
739
749
  bubble.addEventListener('click', function(e) {
740
- console.log('[widget] click', { canvasPhase: canvasPhase, hpWasHold: hpWasHold, hpState: hpState, isOpen: isOpen });
741
750
  // Prevent double-toggle: pointerup already handled taps.
742
751
  e.stopPropagation();
743
752
  });
@@ -760,21 +769,25 @@
760
769
 
761
770
  // Handle messages from iframe
762
771
  window.addEventListener('message', function (e) {
772
+ // Chat iframe + workspace iframe are same-origin; hardens against any third-party frame.
773
+ if (e.origin !== location.origin) return;
763
774
  if (!e.data || !e.data.type) return;
764
775
  if (e.data.type === 'bloby:close' && isOpen) toggle();
776
+ // Workspace iframe forwards Escape keypresses so ESC still closes the chat
777
+ // when focus is inside the user's app.
778
+ if (e.data.type === 'bloby:esc' && isOpen) toggle();
779
+ // Shell mode: the workspace app's same-document 'bloby:app-ready' window
780
+ // event fires inside the iframe, so the guard forwards it as a postMessage.
781
+ if (e.data.type === 'bloby:app-ready') { appReady = true; maybeTransition(); }
765
782
  if (e.data.type === 'bloby:new-message' && !isOpen) { unreadCount++; updateBadge(); }
766
783
  if (e.data.type === 'bloby:install-app') {
767
784
  if (deferredInstallPrompt) {
768
785
  deferredInstallPrompt.prompt();
769
786
  deferredInstallPrompt.userChoice.then(function (r) { if (r.outcome === 'accepted') deferredInstallPrompt = null; });
770
- } else { iframe.contentWindow.postMessage({ type: 'bloby:show-ios-install' }, '*'); }
787
+ } else { iframe.contentWindow.postMessage({ type: 'bloby:show-ios-install' }, location.origin); }
771
788
  }
772
789
  if (e.data.type === 'bloby:onboard-complete') { onboardActive = false; hideAfterTransition = false; canvas.style.display = 'block'; }
773
790
  });
774
791
 
775
792
  try { if (sessionStorage.getItem('bloby_widget_open') === '1') { sessionStorage.removeItem('bloby_widget_open'); if (canvasPhase === 'bubble') toggle(); } } catch (e) {}
776
-
777
- var awsScript = document.createElement('script');
778
- awsScript.src = '/bloby/app-ws.js';
779
- document.head.appendChild(awsScript);
780
793
  })();
@@ -1,6 +1,6 @@
1
1
  (function () {
2
2
  // Injected by the supervisor into the workspace dashboard HTML (and ONLY there — never the
3
- // chat PWA or the supervisor's own interstitials). Two jobs, both aimed at non-technical users:
3
+ // chat PWA or the supervisor's own interstitials). Five jobs, all aimed at non-technical users:
4
4
  //
5
5
  // 1. Backend-down auto-detect — when the workspace backend crash-loops and gives up, the
6
6
  // supervisor serves a full "backend down" interstitial on the next navigation. Poll for
@@ -19,6 +19,22 @@
19
19
  // console AND to a sessionStorage ring buffer that survives the reload, so the page that
20
20
  // comes back can print exactly what killed its predecessor. Look for the
21
21
  // "[bloby-forensics]" banner in the console after a mystery refresh.
22
+ //
23
+ // 4. Shell relay — under shell inversion the workspace renders inside the immortal shell's
24
+ // same-origin iframe and the chat bubble lives in the shell, so signals that used to be
25
+ // same-window must cross the frame boundary: app readiness ({type:'bloby:app-ready'}),
26
+ // document title ({type:'bloby:title'}), Escape keydowns ({type:'bloby:esc'}), and the
27
+ // nested onboard wizard's {type:'bloby:onboard-complete'} are posted up to the parent.
28
+ // No-ops when this document IS the top window (legacy mode / BLOBY_NO_SHELL=1).
29
+ //
30
+ // 5. Vite reconnect-reload suppression + staleness check — Vite's HMR client hard-reloads
31
+ // the page whenever its WebSocket drops. With an immortal dev server behind a fragile
32
+ // tunnel (and mobile backgrounding), that reload is almost always pointless. A
33
+ // synchronously-throwing 'vite:ws:disconnect' listener cancels it — but Vite 8 has NO
34
+ // reconnect loop (the suppressed reload IS its reconnection strategy), so the same
35
+ // handler starts a recovery poll against a server-side change counter
36
+ // (/__bloby/fe-stamp) and reloads ONLY when frontend files actually changed while the
37
+ // socket was dead.
22
38
  if (window.__blobyWorkspaceGuard) return;
23
39
  window.__blobyWorkspaceGuard = true;
24
40
 
@@ -28,7 +44,6 @@
28
44
  function flog(event, extra) {
29
45
  var entry = { t: new Date().toISOString(), e: event };
30
46
  if (extra) entry.x = String(extra).slice(0, 600);
31
- console.log('[bloby-forensics]', entry.t, event, extra || '');
32
47
  try {
33
48
  var arr = JSON.parse(sessionStorage.getItem(FORENSICS_KEY) || '[]');
34
49
  arr.push(entry);
@@ -37,22 +52,15 @@
37
52
  } catch (e) {}
38
53
  }
39
54
 
40
- // On load: report how we got here + dump the trail the previous page session left behind.
55
+ // On load: record how we got here into the forensics ring buffer. The trail the previous
56
+ // page session left behind survives the reload in sessionStorage under FORENSICS_KEY and can
57
+ // be inspected there after a mystery refresh.
41
58
  (function () {
42
59
  var navType = '?';
43
60
  try {
44
61
  var nav = performance.getEntriesByType('navigation')[0];
45
62
  if (nav) navType = nav.type; // 'navigate' | 'reload' | 'back_forward' | 'prerender'
46
63
  } catch (e) {}
47
- var trail = null;
48
- try { trail = sessionStorage.getItem(FORENSICS_KEY); } catch (e) {}
49
- console.log(
50
- '%c[bloby-forensics] PAGE LOADED — navigation.type=' + navType +
51
- ' wasDiscarded=' + (document.wasDiscarded === true) +
52
- '\nTrail left by the previous page session (newest last):',
53
- 'color:#4AEEFF;font-weight:bold',
54
- trail ? JSON.parse(trail) : '(none — first load in this tab)'
55
- );
56
64
  flog('page-load', 'navType=' + navType + ' wasDiscarded=' + (document.wasDiscarded === true) + ' visible=' + document.visibilityState);
57
65
  })();
58
66
 
@@ -98,14 +106,122 @@
98
106
  }
99
107
  } catch (e) {}
100
108
 
101
- // Vite HMR client events — THE prime suspect. 'vite:ws:disconnect' followed by
102
- // 'location.reload' with a client.mjs stack = Vite's reload-on-reconnect fired.
109
+ // Vite HMR client events — forensics (job 3) + reconnect-reload suppression and staleness
110
+ // check (job 5), all on one hot context.
111
+ //
112
+ // The suppression throw below is dispatched from the transport's socket 'close' handler
113
+ // without an await/catch (client.mjs), so it surfaces as a window 'unhandledrejection'.
114
+ // The error-overlay handler further down recognizes this marker and ignores it.
115
+ var VITE_SUPPRESS_MARK = '[bloby] suppressing vite reload-on-reconnect';
103
116
  try {
104
117
  import('/@vite/client').then(function (m) {
105
118
  var hot = m.createHotContext('/__bloby_forensics.js');
106
- hot.on('vite:ws:disconnect', function () { flog('vite:ws:disconnect', 'visible=' + document.visibilityState); });
107
- hot.on('vite:ws:connect', function () { flog('vite:ws:connect'); });
108
- hot.on('vite:beforeFullReload', function (p) { flog('vite:beforeFullReload', p && p.path ? 'path=' + p.path : ''); });
119
+
120
+ // Staleness stamp: a Vite-plugin middleware (reached through the supervisor's dashboard
121
+ // proxy) serves /__bloby/fe-stamp as {stamp:N}, bumped on every frontend hot update.
122
+ // Comparing the stamp across a disconnect tells us whether anything actually changed
123
+ // while we were gone. When Vite is down the supervisor answers 503 HTML — json() rejects
124
+ // and we treat it as "not back yet", never as "changed".
125
+ var lastStamp = null;
126
+ function fetchStamp() {
127
+ return fetch('/__bloby/fe-stamp', { cache: 'no-store' })
128
+ .then(function (r) { return r.json(); })
129
+ .then(function (j) { return (j && typeof j.stamp === 'number') ? j.stamp : null; });
130
+ }
131
+ function refreshStamp(reason) {
132
+ fetchStamp().then(function (s) {
133
+ if (s !== null) lastStamp = s;
134
+ }).catch(function (err) { flog('fe-stamp', reason + ' fetch failed: ' + err); });
135
+ }
136
+ // Baseline with retry: a transient blip right at page load must not leave us without a
137
+ // baseline — the recovery poll would then adopt a possibly-already-moved stamp as truth.
138
+ (function initStamp(attempt) {
139
+ fetchStamp().then(function (s) {
140
+ if (s !== null) { lastStamp = s; return; }
141
+ throw new Error('bad payload');
142
+ }).catch(function (err) {
143
+ if (attempt >= 5) { flog('fe-stamp', 'init gave up: ' + err); return; }
144
+ setTimeout(function () { initStamp(attempt + 1); }, 2000 * (attempt + 1));
145
+ });
146
+ })(0);
147
+
148
+ // Recovery poll — the ONLY change signal after a disconnect. Vite 8 never reconnects a
149
+ // dropped socket: transport.connect() runs once at module init, and the reload we
150
+ // suppress below is what would have re-run it. So once disconnected, HMR messages and
151
+ // 'vite:ws:connect' are gone for good; we poll the stamp instead. Stamp moved → reload
152
+ // (restores both freshness and HMR). Stamp unchanged → page content is current, keep
153
+ // polling (HMR stays dead until the next agent edit bumps the stamp). A failed fetch
154
+ // (supervisor's 503 HTML while Vite is down → json() rejects) means "not back yet".
155
+ // Single instance so rapid disconnects don't stack timers; paused while hidden
156
+ // (mirrors Vite's own visibility-aware ping) and kicked on visibility/online.
157
+ var recoveryTimer = null;
158
+ function recoveryTick() {
159
+ if (document.visibilityState === 'hidden') return;
160
+ fetchStamp().then(function (s) {
161
+ if (s === null || recoveryTimer === null) return;
162
+ if (lastStamp === null) { lastStamp = s; return; } // no baseline — adopt and watch
163
+ if (s !== lastStamp) {
164
+ flog('stale-after-disconnect', 'stamp ' + lastStamp + '→' + s);
165
+ stopRecoveryPoll();
166
+ location.reload(); // goes through the forensics wrapper: logged + splash
167
+ }
168
+ }).catch(function () {});
169
+ }
170
+ function startRecoveryPoll() {
171
+ if (recoveryTimer !== null) return;
172
+ flog('recovery-poll', 'started');
173
+ recoveryTimer = setInterval(recoveryTick, 3000);
174
+ recoveryTick();
175
+ }
176
+ function stopRecoveryPoll() {
177
+ if (recoveryTimer === null) return;
178
+ clearInterval(recoveryTimer);
179
+ recoveryTimer = null;
180
+ flog('recovery-poll', 'stopped');
181
+ }
182
+ document.addEventListener('visibilitychange', function () {
183
+ if (document.visibilityState === 'visible' && recoveryTimer !== null) recoveryTick();
184
+ });
185
+ window.addEventListener('online', function () {
186
+ if (recoveryTimer !== null) recoveryTick();
187
+ });
188
+
189
+ hot.on('vite:ws:disconnect', function () {
190
+ flog('vite:ws:disconnect', 'visible=' + document.visibilityState);
191
+ // The poll must start BEFORE the suppressing throw aborts this handler.
192
+ startRecoveryPoll();
193
+ // Cancel Vite's reload-on-reconnect: client.mjs handleMessage awaits
194
+ // notifyListeners('vite:ws:disconnect') BEFORE waitForSuccessfulPing() +
195
+ // location.reload(). notifyListeners runs cbs.map((cb) => cb(data)) synchronously, so
196
+ // a SYNCHRONOUS throw here rejects that awaited promise and the reload never runs.
197
+ // (An async listener would NOT work — Promise.allSettled swallows rejections.) We
198
+ // suppress because the dev server is immortal while the tunnel/mobile connection is
199
+ // fragile: every blip would otherwise hard-reload a perfectly-current page. Net
200
+ // effect with the recovery poll above: connection blips no longer reload the page;
201
+ // we reload ONLY when edits actually happened while disconnected.
202
+ throw new Error(VITE_SUPPRESS_MARK + ' — staleness is handled by the recovery poll instead');
203
+ });
204
+ // Dead code on Vite 8 after the first disconnect (the event is emitted only inside
205
+ // transport.connect(), which never re-runs) — kept as a fast path for a future Vite
206
+ // that reconnects in place.
207
+ hot.on('vite:ws:connect', function () {
208
+ flog('vite:ws:connect');
209
+ stopRecoveryPoll();
210
+ if (lastStamp === null) { refreshStamp('connect'); return; }
211
+ fetchStamp().then(function (s) {
212
+ if (s === null) return;
213
+ if (s !== lastStamp) {
214
+ flog('stale-after-reconnect', 'stamp ' + lastStamp + '→' + s);
215
+ location.reload(); // goes through the forensics wrapper: logged + splash
216
+ }
217
+ }).catch(function (err) { flog('fe-stamp', 'connect fetch failed: ' + err); });
218
+ });
219
+ // We just received these updates over the live socket, so we're current — resync.
220
+ hot.on('vite:afterUpdate', function () { refreshStamp('after-update'); });
221
+ hot.on('vite:beforeFullReload', function (p) {
222
+ flog('vite:beforeFullReload', p && p.path ? 'path=' + p.path : '');
223
+ refreshStamp('before-full-reload');
224
+ });
109
225
  hot.on('vite:invalidate', function (p) { flog('vite:invalidate', (p && p.path) || ''); });
110
226
  hot.on('vite:error', function (p) { flog('vite:error', p && p.err && p.err.message || ''); });
111
227
  flog('vite-hooks', 'installed');
@@ -114,6 +230,80 @@
114
230
  flog('vite-hooks', 'unavailable: ' + e);
115
231
  }
116
232
 
233
+ /* ── 4. Shell relay ───────────────────────────────────────────────────── */
234
+ // The shell (parent) hosts the bubble + chat; this document is its same-origin iframe.
235
+ // Everything here no-ops when we ARE the top window (legacy mode / BLOBY_NO_SHELL=1).
236
+ (function () {
237
+ var framed = false;
238
+ try { framed = !!window.parent && window.parent !== window; } catch (e) {}
239
+ if (!framed) return;
240
+
241
+ function post(msg) {
242
+ try { window.parent.postMessage(msg, location.origin); } catch (e) {}
243
+ }
244
+
245
+ // a. App readiness. The workspace's main.tsx dispatches the same-window DOM Event
246
+ // 'bloby:app-ready' and sets window.__blobyAppReady after first paint. The event may have
247
+ // fired before this script ran (or a custom workspace may never dispatch it), so also
248
+ // check the flag now and poll it; give up after 20s. Send at most once.
249
+ //
250
+ // Readiness also hides the workspace's own opaque #splash (index.html, z-index 9998):
251
+ // widget.js — the only thing that ever hid it — no-ops inside frames, so without this
252
+ // every shell-mode load (including onboarding, whose iframe sits at z-index 200) stays
253
+ // black forever. Timeout backstop (~8s, matching the shell's own) keeps broken apps
254
+ // visible too. Framed-only, so legacy mode still gets widget.js's splash animation.
255
+ var readySent = false;
256
+ function hideSplash() {
257
+ try {
258
+ var s = document.getElementById('splash');
259
+ if (s) s.style.display = 'none';
260
+ } catch (e) {}
261
+ }
262
+ setTimeout(hideSplash, 8000);
263
+ function sendReady() {
264
+ if (readySent) return;
265
+ readySent = true;
266
+ hideSplash();
267
+ post({ type: 'bloby:app-ready' });
268
+ }
269
+ window.addEventListener('bloby:app-ready', sendReady);
270
+ if (window.__blobyAppReady) sendReady();
271
+ var readyPolls = 0;
272
+ var readyTimer = setInterval(function () {
273
+ if (window.__blobyAppReady) sendReady();
274
+ if (readySent || ++readyPolls >= 80) clearInterval(readyTimer); // 80 × 250ms = 20s
275
+ }, 250);
276
+
277
+ // b. Title sync — the shell mirrors the iframe's title into the real tab title.
278
+ function postTitle() {
279
+ if (document.title) post({ type: 'bloby:title', title: document.title });
280
+ }
281
+ postTitle();
282
+ try {
283
+ var titleEl = document.querySelector('title');
284
+ if (titleEl) {
285
+ new MutationObserver(postTitle).observe(titleEl, { childList: true, characterData: true, subtree: true });
286
+ }
287
+ } catch (e) {}
288
+
289
+ // c. Escape — keydown inside the iframe never reaches the shell document, so the shell's
290
+ // widget would miss its close-the-chat-panel shortcut.
291
+ window.addEventListener('keydown', function (e) {
292
+ if (e.key === 'Escape') post({ type: 'bloby:esc' });
293
+ });
294
+
295
+ // d. Onboard relay — the onboarding wizard iframe is nested INSIDE this workspace app and
296
+ // posts {type:'bloby:onboard-complete'} to ITS parent (= this window); the widget that
297
+ // must un-hide the bubble now lives in the shell. Re-post upward. Loop-safe by direction:
298
+ // only messages from below (never from the parent shell) are relayed.
299
+ window.addEventListener('message', function (e) {
300
+ if (e.source === window.parent) return;
301
+ if (e.origin === location.origin && e.data && e.data.type === 'bloby:onboard-complete') {
302
+ post(e.data);
303
+ }
304
+ });
305
+ })();
306
+
117
307
  /* ── 1. Backend-down auto-detect ──────────────────────────────────────── */
118
308
  var reloading = false;
119
309
  function pollBackend() {
@@ -217,6 +407,14 @@
217
407
  new MutationObserver(evaluate).observe(document.body, { childList: true });
218
408
  setInterval(evaluate, 1500);
219
409
  window.addEventListener('error', function () { sawError = true; evaluate(); });
220
- window.addEventListener('unhandledrejection', function () { sawError = true; evaluate(); });
410
+ window.addEventListener('unhandledrejection', function (e) {
411
+ // The reconnect-reload suppressor (job 5) throws a marker error inside Vite's HMR
412
+ // dispatch; it surfaces here on every tunnel blip. Not an app error — swallow it.
413
+ try {
414
+ var msg = e && e.reason && e.reason.message;
415
+ if (msg && String(msg).indexOf(VITE_SUPPRESS_MARK) !== -1) { e.preventDefault(); return; }
416
+ } catch (err) {}
417
+ sawError = true; evaluate();
418
+ });
221
419
  evaluate();
222
420
  })();
package/vite.config.ts CHANGED
@@ -1,8 +1,39 @@
1
- import { defineConfig } from 'vite';
1
+ import { defineConfig, type Plugin } from 'vite';
2
2
  import react from '@vitejs/plugin-react';
3
3
  import tailwindcss from '@tailwindcss/vite';
4
4
  import path from 'path';
5
5
 
6
+ // Backs workspace-guard.js's reconnect staleness check: Vite's reload-on-reconnect
7
+ // is suppressed client-side, and on reconnect the client fetches this stamp and
8
+ // reloads ONLY if frontend code actually changed while it was disconnected.
9
+ // Seeded with Date.now() (not 0) so a Vite/supervisor restart — which resets this
10
+ // module — always reads as "changed" → clients reload after restarts, which is
11
+ // exactly right (in-memory HMR state is gone).
12
+ function blobyFeStamp(): Plugin {
13
+ let stamp = Date.now();
14
+ return {
15
+ name: 'bloby-fe-stamp',
16
+ // hotUpdate (not legacy handleHotUpdate): fires on create/update/delete and
17
+ // for .html edits that trigger full-reload. It runs once per environment, so
18
+ // only count the client one.
19
+ hotUpdate() {
20
+ if (this.environment.name !== 'client') return;
21
+ stamp++;
22
+ },
23
+ configureServer(server) {
24
+ // Reached through the supervisor's catch-all proxy to Vite. connect mounts
25
+ // by prefix, so reject subpaths to keep this an exact match.
26
+ server.middlewares.use('/__bloby/fe-stamp', (req, res, next) => {
27
+ const rest = (req.url || '').split('?')[0];
28
+ if (rest !== '' && rest !== '/') return next();
29
+ res.setHeader('Content-Type', 'application/json');
30
+ res.setHeader('Cache-Control', 'no-store');
31
+ res.end(JSON.stringify({ stamp }));
32
+ });
33
+ },
34
+ };
35
+ }
36
+
6
37
  export default defineConfig({
7
38
  root: process.env.BLOBY_WORKSPACE
8
39
  ? path.join(process.env.BLOBY_WORKSPACE, 'client')
@@ -64,5 +95,5 @@ export default defineConfig({
64
95
  'use-sync-external-store/shim',
65
96
  ],
66
97
  },
67
- plugins: [react(), tailwindcss()],
98
+ plugins: [react(), tailwindcss(), blobyFeStamp()],
68
99
  });
@@ -19,6 +19,15 @@ export type ConditionResult = false | true | Record<string, string>;
19
19
 
20
20
  export const conditions: Record<string, () => Promise<ConditionResult>> = {
21
21
 
22
+ /**
23
+ * Shell mode: the workspace renders inside a same-origin iframe under the
24
+ * supervisor's shell page. BLOBY_NO_SHELL=1 is the kill switch back to
25
+ * legacy top-level serving — the iframe note must disappear with it.
26
+ */
27
+ 'workspace-iframe-shell': async () => {
28
+ return process.env.BLOBY_NO_SHELL !== '1';
29
+ },
30
+
22
31
  /**
23
32
  * Official Workspace Lock is active.
24
33
  * Calls GET http://localhost:{backendPort}/api/lock/status
@@ -1,4 +1,12 @@
1
1
  [
2
+ {
3
+ "id": "workspace-iframe-shell",
4
+ "description": "Shell mode active (BLOBY_NO_SHELL != 1): workspace renders inside a same-origin iframe under Bloby's shell. Priority 0 is load-bearing — must run before the workspace-lock replace fragments consume the workspace-security marker this append anchors on.",
5
+ "target": "workspace-security",
6
+ "action": "append",
7
+ "priority": 0,
8
+ "content": "## Iframe Shell\n\nThe dashboard your human sees renders inside a same-origin iframe under Bloby's shell page — the workspace app is never the top-level browsing context. This is transparent for normal code, with a few hard rules:\n\n- **Redirect-based auth (Google/GitHub OAuth, Stripe Checkout, etc.):** never navigate the current window to a third-party page — providers refuse to render inside iframes. Open a popup (`window.open`) or put `target=\"_top\"` on the redirect link. Prefer popup-based SDK flows when the provider offers one.\n- **`window.top` and `window.parent` belong to Bloby's shell.** Never navigate, reload, or postMessage them from workspace code.\n- **Browser permissions work normally** — camera, microphone, geolocation, clipboard, fullscreen, autoplay, and display-capture are all granted to the iframe by the shell.\n- **The browser tab title syncs automatically** from `document.title`. Nothing to do."
9
+ },
2
10
  {
3
11
  "id": "workspace-lock-official",
4
12
  "description": "Official marketplace Workspace Lock is active (PIN or password)",
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
6
6
  <meta name="theme-color" content="#212121" />
7
+ <meta name="mobile-web-app-capable" content="yes" />
7
8
  <meta name="apple-mobile-web-app-capable" content="yes" />
8
9
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
9
10
  <meta name="apple-mobile-web-app-title" content="Bloby" />
@@ -8,31 +8,28 @@
8
8
  // build errors, never a stale cached shell. Cache is a pure offline fallback. (Mirror of the
9
9
  // supervisor's SW_JS in supervisor/index.ts — keep in sync.)
10
10
 
11
- const CACHE = 'bloby-v17';
11
+ const CACHE = 'bloby-v24';
12
12
 
13
13
  // Precache the HTML shell on install so the cache is never empty.
14
14
  // Without this, the first navigation isn't intercepted (SW wasn't
15
15
  // controlling yet), so refresh would find an empty cache → white screen.
16
16
  self.addEventListener('install', (e) => {
17
- console.log('[SW] installing, cache:', CACHE);
18
17
  e.waitUntil(
19
18
  caches.open(CACHE)
20
19
  .then(c => c.add('/'))
21
- .then(() => { console.log('[SW] precached / — calling skipWaiting'); return self.skipWaiting(); })
20
+ .then(() => self.skipWaiting())
22
21
  .catch(err => { console.error('[SW] install failed:', err); throw err; })
23
22
  );
24
23
  });
25
24
 
26
25
  self.addEventListener('activate', (e) => {
27
- console.log('[SW] activating, cache:', CACHE);
28
26
  e.waitUntil(
29
27
  caches.keys()
30
28
  .then(keys => {
31
29
  const old = keys.filter(k => k !== CACHE);
32
- if (old.length) console.log('[SW] deleting old caches:', old);
33
30
  return Promise.all(old.map(k => caches.delete(k)));
34
31
  })
35
- .then(() => { console.log('[SW] claiming clients'); return self.clients.claim(); })
32
+ .then(() => self.clients.claim())
36
33
  );
37
34
  });
38
35
 
@@ -70,18 +67,16 @@ self.addEventListener('fetch', (event) => {
70
67
  // /bloby/* is a separate app — let it go to network to avoid
71
68
  // caching the wrong HTML under the / key.
72
69
  if (request.mode === 'navigate') {
73
- console.log('[SW] navigate →', url.pathname);
74
70
  if (url.pathname === '/' || url.pathname === '/index.html') {
75
71
  // Network-first: always fetch the live dashboard; cache only as offline fallback.
76
72
  event.respondWith(caches.open(CACHE).then(c =>
77
73
  fetch(request)
78
74
  .then(r => { if (r.ok) c.put('/', r.clone()); return r; })
79
- .catch(err => { console.warn('[SW] / network failed, using cache:', err.message); return c.match('/'); })
75
+ .catch(() => c.match('/'))
80
76
  ));
81
77
  return;
82
78
  }
83
79
  // Other navigations (/bloby/*, etc.) — network only
84
- console.log('[SW] navigate (network-only) →', url.pathname);
85
80
  return;
86
81
  }
87
82