bloby-bot 0.53.3 → 0.53.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.53.3",
3
+ "version": "0.53.6",
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",
@@ -8,6 +8,10 @@ let child: ChildProcess | null = null;
8
8
  let restarts = 0;
9
9
  let lastSpawnTime = 0;
10
10
  let intentionallyStopped = false;
11
+ // True once the backend has crash-looped past MAX_RESTARTS and given up — i.e. it's down and
12
+ // will NOT come back without the user fixing the code. The supervisor shows the "backend down"
13
+ // interstitial in this state. Cleared on every spawn attempt (a deliberate restart is "trying again").
14
+ let gaveUp = false;
11
15
  const MAX_RESTARTS = 3;
12
16
  const STABLE_THRESHOLD = 30_000; // 30s — if backend ran this long, it wasn't a crash loop
13
17
 
@@ -39,6 +43,7 @@ export function spawnBackend(port: number): ChildProcess {
39
43
  const backendPath = path.join(WORKSPACE_DIR, 'backend', 'index.ts');
40
44
  lastSpawnTime = Date.now();
41
45
  intentionallyStopped = false;
46
+ gaveUp = false;
42
47
 
43
48
  // Clear log file on each restart — only keeps current run
44
49
  try { fs.writeFileSync(LOG_FILE, ''); } catch {}
@@ -106,6 +111,7 @@ export function spawnBackend(port: number): ChildProcess {
106
111
  log.info(`Restarting backend (${restarts}/${MAX_RESTARTS}, delay ${delay}ms)...`);
107
112
  setTimeout(() => spawnBackend(port), delay);
108
113
  } else {
114
+ gaveUp = true;
109
115
  log.error('Backend failed too many times. Use Bloby chat to debug.');
110
116
  }
111
117
  });
@@ -183,6 +189,23 @@ export function isBackendAlive(): boolean {
183
189
  return child !== null && child.exitCode === null;
184
190
  }
185
191
 
192
+ /** True when the backend has crash-looped past MAX_RESTARTS and given up — down and not
193
+ * coming back without a code fix. Drives the supervisor's "backend down" interstitial. */
194
+ export function isBackendDead(): boolean {
195
+ return gaveUp;
196
+ }
197
+
198
+ /** Read the tail of the backend log (default 100 lines) for the "copy logs" debug helper. */
199
+ export function readBackendLogTail(maxLines = 100): string {
200
+ try {
201
+ const text = fs.readFileSync(LOG_FILE, 'utf-8');
202
+ const lines = text.split('\n');
203
+ return lines.slice(-maxLines).join('\n').trim();
204
+ } catch {
205
+ return '';
206
+ }
207
+ }
208
+
186
209
  export function isBackendStopping(): boolean {
187
210
  return stopPromise !== null;
188
211
  }
@@ -11,7 +11,7 @@ import { log } from '../shared/logger.js';
11
11
  import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel, startNamedTunnel, restartNamedTunnel } from './tunnel.js';
12
12
  import { createWorkerApp } from '../worker/index.js';
13
13
  import { closeDb, getSession, getSetting } from '../worker/db.js';
14
- import { spawnBackend, stopBackend, restartBackend, getBackendPort, isBackendAlive, isBackendStopping, setBackendEnv } from './backend.js';
14
+ import { spawnBackend, stopBackend, restartBackend, getBackendPort, isBackendAlive, isBackendStopping, isBackendDead, readBackendLogTail, setBackendEnv } from './backend.js';
15
15
  import { handleAgentQuery, type AgentQueryRequest } from './agent-api.js';
16
16
  import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
17
17
  import {
@@ -69,6 +69,8 @@ const PLATFORM_ASSETS = new Set([
69
69
  '/pi-logo.svg',
70
70
  '/codex.svg',
71
71
  '/manifest.json',
72
+ '/what-happened.webm',
73
+ '/what-happened.mp4',
72
74
  ]);
73
75
 
74
76
  // Directory-prefix platform assets — anything under these is served from supervisor/public/.
@@ -250,6 +252,77 @@ const RECOVERING_HTML = `<!DOCTYPE html><html style="background:#222122"><head><
250
252
  </div><script>setTimeout(function(){location.reload()},3000)</script>
251
253
  <script src="/bloby/widget.js"></script></body></html>`;
252
254
 
255
+ /** Interstitial shown (by the supervisor, not the workspace) when the workspace backend has
256
+ * crash-looped and given up. Replaces proxying the dashboard SPA to Vite — which would 503 on
257
+ * every /app/api call and, for the common workspace-lock template, misread "no backend" as
258
+ * "no password set" and show the lock-setup screen. Embeds the Bloby chat widget so the user
259
+ * can ask the agent to fix it inline, a "copy logs" button (last 100 backend log lines baked in
260
+ * at render time), and a poll that reloads into the real dashboard once the backend is back. */
261
+ function backendDownPage(logTail: string): string {
262
+ // Embed logs as a JS string literal; escape `<` so a stray `</script>` in the logs can't break out.
263
+ const logs = JSON.stringify(logTail && logTail.length ? logTail : '(no backend logs were captured)').replace(/</g, '\\u003c');
264
+ return `<!DOCTYPE html>
265
+ <html lang="en"><head>
266
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
267
+ <title>Backend down · Bloby</title>
268
+ <style>
269
+ *{margin:0;padding:0;box-sizing:border-box}
270
+ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#0a0a0b;color:#e4e4e7;display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:1.5rem;overflow:hidden}
271
+ .c{text-align:center;max-width:480px;width:100%;animation:fade-up .6s ease-out both}
272
+ .video-wrap{position:relative;width:200px;height:200px;margin:0 auto 1.4rem;display:flex;align-items:center;justify-content:center}
273
+ .video-wrap::before{content:'';position:absolute;inset:-20px;background:radial-gradient(circle,rgba(1,102,255,0.18) 0%,transparent 60%);filter:blur(20px);animation:glow 3s ease-in-out infinite}
274
+ .video-wrap video{position:relative;width:100%;height:100%;object-fit:contain;pointer-events:none;border-radius:50%}
275
+ h1{font-size:1.55rem;font-weight:700;margin-bottom:.6rem;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
276
+ p{color:#a1a1aa;line-height:1.6;margin-bottom:.5rem;font-size:.95rem}
277
+ .lead{color:#e4e4e7;font-size:1rem}
278
+ .actions{margin-top:1.3rem}
279
+ button{font:inherit;cursor:pointer;border-radius:10px;padding:.65rem 1.2rem;font-size:.9rem;font-weight:600;border:none;background:linear-gradient(135deg,#0166FF,#0069FE);color:#fff;transition:filter .15s}
280
+ button:hover{filter:brightness(1.12)}
281
+ .sub{font-size:.82rem;color:#71717a;display:inline-flex;align-items:center;gap:.5rem;background:#18181b;border:1px solid #27272a;border-radius:9999px;padding:.35rem .9rem;margin-top:1.1rem}
282
+ .sub .dot{width:8px;height:8px;border-radius:50%;background:linear-gradient(135deg,#0166FF,#009AFE);box-shadow:0 0 8px rgba(1,102,255,.6);animation:pulse 1.6s ease-in-out infinite}
283
+ .badge{display:block;font-size:.7rem;color:#52525b;margin-top:1.3rem}
284
+ @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.85)}}
285
+ @keyframes glow{0%,100%{opacity:.55;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}}
286
+ @keyframes fade-up{0%{opacity:0;transform:translateY(12px)}100%{opacity:1;transform:translateY(0)}}
287
+ </style></head>
288
+ <body><div class="c">
289
+ <div class="video-wrap"><video autoplay loop muted playsinline>
290
+ <source src="/what-happened.webm" type="video/webm">
291
+ <source src="/what-happened.mp4" type="video/mp4">
292
+ </video></div>
293
+ <h1>Your app's backend is down</h1>
294
+ <p class="lead">The workspace server crashed and couldn't restart on its own.</p>
295
+ <p>Ask your agent to fix it — the chat is right here in the corner. Tap below to copy the logs so it can debug faster.</p>
296
+ <div class="actions"><button id="copyBtn">Copy logs for your agent</button></div>
297
+ <div class="sub"><span class="dot"></span><span id="statusText">Watching for recovery…</span></div>
298
+ <span class="badge">Powered by Bloby</span>
299
+ </div>
300
+ <script>
301
+ (function(){
302
+ var LOGS = ${logs};
303
+ var btn = document.getElementById('copyBtn'), statusEl = document.getElementById('statusText');
304
+ btn.addEventListener('click', function(){
305
+ var text = 'My workspace backend crashed and will not start. Find and fix the root cause. Last backend logs:\\n\\n' + LOGS;
306
+ function ok(){ btn.textContent = '✓ Copied — paste it to your agent'; setTimeout(function(){ btn.textContent = 'Copy logs for your agent'; }, 2600); }
307
+ function fallback(){ var ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); try{ document.execCommand('copy'); ok(); }catch(e){ btn.textContent='Copy failed — open the logs manually'; } document.body.removeChild(ta); }
308
+ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(ok).catch(fallback); } else { fallback(); }
309
+ });
310
+ var attempt = 0;
311
+ function retry(){
312
+ attempt++;
313
+ fetch('/__bloby/backend-status', { cache:'no-store' })
314
+ .then(function(r){ return r.json(); })
315
+ .then(function(s){ if (s && s.alive) { location.reload(); } else { schedule(); } })
316
+ .catch(schedule);
317
+ }
318
+ function schedule(){ statusEl.textContent = 'Watching for recovery… (checked ' + attempt + 'x)'; setTimeout(retry, Math.min(4000, 1500 + attempt*250)); }
319
+ setTimeout(retry, 2500);
320
+ })();
321
+ </script>
322
+ <script src="/bloby/widget.js"></script>
323
+ </body></html>`;
324
+ }
325
+
253
326
  /** Kill any stale process holding a port. Ensures clean startup after crashes/updates. */
254
327
  function killPort(port: number): void {
255
328
  try {
@@ -457,6 +530,15 @@ export async function startSupervisor() {
457
530
  return;
458
531
  }
459
532
 
533
+ // Workspace guard — injected by the supervisor into the dashboard HTML (see the Vite proxy
534
+ // below). Auto-reloads into the "backend down" interstitial when the backend gives up, and
535
+ // replaces Vite's raw error overlay with a friendly one. Served here so it's always current.
536
+ if (req.url === '/bloby/workspace-guard.js') {
537
+ res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
538
+ res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'workspace-guard.js')));
539
+ return;
540
+ }
541
+
460
542
  // Service worker — served from embedded constant (supervisor/ is always updated)
461
543
  if (req.url === '/sw.js' || req.url === '/bloby/sw.js') {
462
544
  res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
@@ -464,6 +546,15 @@ export async function startSupervisor() {
464
546
  return;
465
547
  }
466
548
 
549
+ // Backend liveness for the "backend down" interstitial's recovery poll. Supervisor-served
550
+ // (not proxied) so it answers even when the workspace backend is dead, and independent of
551
+ // whatever routes the user's backend happens to define.
552
+ if (req.url === '/__bloby/backend-status') {
553
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
554
+ res.end(JSON.stringify({ alive: isBackendAlive(), dead: isBackendDead() }));
555
+ return;
556
+ }
557
+
467
558
  // App API routes → proxy to user's backend server
468
559
  if (req.url?.startsWith('/app/api')) {
469
560
  const backendPath = req.url.replace(/^\/app/, '');
@@ -1696,13 +1787,57 @@ mint();
1696
1787
  } catch { /* fall through to Vite */ }
1697
1788
  }
1698
1789
 
1790
+ // Workspace backend has crash-looped and given up → serve the "backend down" interstitial
1791
+ // for dashboard DOCUMENT navigations, instead of proxying to Vite (which serves the user's
1792
+ // SPA that then 503s on every /app/api call and, for the common workspace-lock template,
1793
+ // misreads the dead backend as "no password set" and shows the lock-setup screen). Scoped to
1794
+ // top-level navigations only (not assets/HMR/XHR) and only when the backend has truly given
1795
+ // up — never during a normal 1–2s restart. The chat PWA (/bloby/*) is served earlier and is
1796
+ // unaffected.
1797
+ const wantsHtml = req.method === 'GET' && (
1798
+ req.headers['sec-fetch-dest'] === 'document' ||
1799
+ req.headers['sec-fetch-mode'] === 'navigate' ||
1800
+ (!req.headers['sec-fetch-dest'] && String(req.headers['accept'] || '').includes('text/html'))
1801
+ );
1802
+ if (wantsHtml && isBackendDead()) {
1803
+ res.writeHead(503, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
1804
+ res.end(backendDownPage(readBackendLogTail(100)));
1805
+ return;
1806
+ }
1807
+
1699
1808
  // Everything else → proxy to dashboard Vite dev server
1700
1809
  console.log(`[supervisor] → dashboard Vite :${vitePorts.dashboard} | ${req.method} ${(req.url || '').split('?')[0]}`);
1810
+ const GUARD_TAG = '<script defer src="/bloby/workspace-guard.js"></script>';
1701
1811
  const proxy = http.request(
1702
1812
  { host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: req.headers },
1703
1813
  (proxyRes) => {
1704
- res.writeHead(proxyRes.statusCode!, proxyRes.headers);
1705
- proxyRes.pipe(res);
1814
+ const ct = String(proxyRes.headers['content-type'] || '');
1815
+ const enc = String(proxyRes.headers['content-encoding'] || '');
1816
+ // Inject the workspace guard into dashboard HTML *documents* only (and only when
1817
+ // uncompressed — Vite dev serves plain HTML). Assets, HMR, JSON, etc. stream through
1818
+ // untouched. The guard auto-reloads into the "backend down" interstitial and replaces
1819
+ // Vite's raw error overlay — supervisor-side so it reaches every workspace, no edits needed.
1820
+ if (!ct.includes('text/html') || enc) {
1821
+ res.writeHead(proxyRes.statusCode!, proxyRes.headers);
1822
+ proxyRes.pipe(res);
1823
+ return;
1824
+ }
1825
+ const chunks: Buffer[] = [];
1826
+ proxyRes.on('data', (c: Buffer) => chunks.push(c));
1827
+ proxyRes.on('end', () => {
1828
+ let html = Buffer.concat(chunks).toString('utf-8');
1829
+ if (!html.includes('workspace-guard.js')) {
1830
+ html = html.includes('</head>') ? html.replace('</head>', GUARD_TAG + '</head>') : GUARD_TAG + html;
1831
+ }
1832
+ const headers = { ...proxyRes.headers };
1833
+ // Body length changed — drop both framing headers and let Node set content-length
1834
+ // from the single res.end() write.
1835
+ delete headers['content-length'];
1836
+ delete headers['transfer-encoding'];
1837
+ res.writeHead(proxyRes.statusCode!, headers);
1838
+ res.end(html);
1839
+ });
1840
+ proxyRes.on('error', () => { try { res.destroy(); } catch {} });
1706
1841
  },
1707
1842
  );
1708
1843
  proxy.on('error', (e) => {
@@ -0,0 +1,103 @@
1
+ (function () {
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:
4
+ //
5
+ // 1. Backend-down auto-detect — when the workspace backend crash-loops and gives up, the
6
+ // supervisor serves a full "backend down" interstitial on the next navigation. Poll for
7
+ // that state and reload automatically so the user doesn't have to press F5 to discover
8
+ // their app's server broke. We reload ONLY on the terminal "dead" (gave-up) state, never
9
+ // during a normal 1-2s restart (which reports alive:false, dead:false).
10
+ //
11
+ // 2. Friendly frontend-error overlay — Vite's dev server throws a raw red error box on a
12
+ // compile/import error, covering the whole screen (even the chat). We hide Vite's overlay
13
+ // and show a friendly one instead: the error text behind a "copy" button + a pointer to
14
+ // the chat. It auto-clears when the agent fixes the error (Vite removes its overlay).
15
+ if (window.__blobyWorkspaceGuard) return;
16
+ window.__blobyWorkspaceGuard = true;
17
+
18
+ /* ── 1. Backend-down auto-detect ──────────────────────────────────────── */
19
+ var reloading = false;
20
+ function pollBackend() {
21
+ fetch('/__bloby/backend-status', { cache: 'no-store' })
22
+ .then(function (r) { return r.json(); })
23
+ .then(function (s) { if (s && s.dead && !reloading) { reloading = true; location.reload(); } })
24
+ .catch(function () {});
25
+ }
26
+ setInterval(pollBackend, 4000);
27
+ pollBackend();
28
+
29
+ /* ── 2. Friendly frontend-error overlay ───────────────────────────────── */
30
+ // Hide Vite's built-in overlay (kept enabled so we can read the error text from its shadow DOM).
31
+ var hideStyle = document.createElement('style');
32
+ hideStyle.textContent = 'vite-error-overlay{display:none!important}';
33
+ (document.head || document.documentElement).appendChild(hideStyle);
34
+
35
+ var overlay = null;
36
+ var dismissed = false;
37
+ var lastErr = '';
38
+
39
+ function readViteError() {
40
+ var ov = document.querySelector('vite-error-overlay');
41
+ if (!ov || !ov.shadowRoot) return '';
42
+ var sr = ov.shadowRoot, parts = [];
43
+ ['.message-body', '.file', '.frame'].forEach(function (sel) {
44
+ var el = sr.querySelector(sel);
45
+ if (el && el.textContent && el.textContent.trim()) parts.push(el.textContent.trim());
46
+ });
47
+ return parts.join('\n\n');
48
+ }
49
+
50
+ function buildOverlay() {
51
+ var d = document.createElement('div');
52
+ d.id = '__bloby_fe_error';
53
+ // z-index 99990: below the chat widget (99998/99999) so the user can still open chat, above the app.
54
+ d.setAttribute('style', 'position:fixed;inset:0;z-index:99990;background:#0a0a0b;color:#e4e4e7;display:flex;align-items:center;justify-content:center;padding:1.5rem;font-family:system-ui,-apple-system,sans-serif');
55
+ d.innerHTML =
56
+ '<div style="text-align:center;max-width:480px;width:100%">' +
57
+ '<div style="position:relative;width:160px;height:160px;margin:0 auto 1.2rem">' +
58
+ '<div style="position:absolute;inset:-18px;background:radial-gradient(circle,rgba(1,102,255,.18) 0%,transparent 60%);filter:blur(18px)"></div>' +
59
+ '<video autoplay loop muted playsinline style="position:relative;width:100%;height:100%;object-fit:contain;border-radius:50%">' +
60
+ '<source src="/what-happened.webm" type="video/webm"><source src="/what-happened.mp4" type="video/mp4">' +
61
+ '</video>' +
62
+ '</div>' +
63
+ '<h1 style="font-size:1.5rem;font-weight:700;margin:0 0 .6rem;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">A screen in your app has an error</h1>' +
64
+ '<p style="color:#e4e4e7;font-size:1rem;line-height:1.6;margin:0 0 .4rem">Your latest change didn\'t compile.</p>' +
65
+ '<p style="color:#a1a1aa;font-size:.93rem;line-height:1.6;margin:0 0 1.2rem">Ask your agent to fix it — the chat is in the bottom corner. Copy the error so it can debug faster.</p>' +
66
+ '<div><button id="__bloby_fe_copy" style="font:inherit;cursor:pointer;border:none;border-radius:10px;padding:.65rem 1.2rem;font-size:.9rem;font-weight:600;background:linear-gradient(135deg,#0166FF,#0069FE);color:#fff">Copy error for your agent</button> ' +
67
+ '<button id="__bloby_fe_dismiss" style="font:inherit;cursor:pointer;border-radius:10px;padding:.65rem 1.1rem;font-size:.9rem;font-weight:600;border:1px solid #27272a;background:#18181b;color:#e4e4e7">Dismiss</button></div>' +
68
+ '</div>';
69
+ document.body.appendChild(d);
70
+
71
+ var copyBtn = d.querySelector('#__bloby_fe_copy');
72
+ copyBtn.addEventListener('click', function () {
73
+ var text = 'A screen in my app has a frontend build error. Find and fix the root cause. Error:\n\n' + (lastErr || '(no details captured)');
74
+ function ok() { copyBtn.textContent = '✓ Copied — paste it to your agent'; setTimeout(function () { copyBtn.textContent = 'Copy error for your agent'; }, 2600); }
75
+ function fb() { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); ok(); } catch (e) {} document.body.removeChild(ta); }
76
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok).catch(fb); else fb();
77
+ });
78
+ d.querySelector('#__bloby_fe_dismiss').addEventListener('click', function () {
79
+ dismissed = true;
80
+ if (overlay) { overlay.remove(); overlay = null; }
81
+ });
82
+ return d;
83
+ }
84
+
85
+ function sync() {
86
+ var ov = document.querySelector('vite-error-overlay');
87
+ if (ov) {
88
+ var err = readViteError();
89
+ if (err) lastErr = err;
90
+ if (!overlay && !dismissed) overlay = buildOverlay();
91
+ } else {
92
+ // Error cleared (agent fixed it) — drop our overlay and re-arm for the next episode.
93
+ dismissed = false;
94
+ if (overlay) { overlay.remove(); overlay = null; }
95
+ }
96
+ }
97
+
98
+ // Vite appends <vite-error-overlay> directly under <body>; observing body's childList is enough
99
+ // and far lighter than a full-document subtree observer.
100
+ var obs = new MutationObserver(sync);
101
+ obs.observe(document.body, { childList: true });
102
+ sync();
103
+ })();