bloby-bot 0.52.3 → 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
package/supervisor/backend.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/supervisor/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 });
|