beehaven 0.8.16

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.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
@@ -0,0 +1,846 @@
1
+ /**
2
+ * Dashboard — Local web dashboard for Beehaven observability.
3
+ *
4
+ * Serves a single-page HTML dashboard on localhost.
5
+ * Accepts WebSocket connections for real-time metric streaming.
6
+ * Accepts console log forwarding from browser clients.
7
+ *
8
+ * const dashboard = new Dashboard({ port: 9800 });
9
+ * dashboard.start();
10
+ * dashboard.pushRadar(radarSnapshot);
11
+ * dashboard.pushThreats(threats);
12
+ * dashboard.pushEvent(event);
13
+ * dashboard.pushConsoleLog(log);
14
+ */
15
+ import { createServer } from 'node:http';
16
+ import { readFileSync, existsSync } from 'node:fs';
17
+ import { join, dirname } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { WebSocketServer } from 'ws';
20
+ // ── Fallback HTML ───────────────────────────────────────────────────────────
21
+ const FALLBACK_HTML = `<!DOCTYPE html>
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="UTF-8">
25
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
26
+ <title>Beehaven Dashboard</title>
27
+ <style>
28
+ body { font-family: 'Inter', system-ui, sans-serif; background: #0A0A12; color: #e0e0e0; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
29
+ .container { text-align: center; max-width: 480px; padding: 2rem; }
30
+ h1 { color: #F5A623; font-family: 'Space Grotesk', system-ui, sans-serif; }
31
+ p { color: #888; line-height: 1.6; }
32
+ code { background: #1a1a2e; padding: 0.2em 0.5em; border-radius: 4px; font-family: 'JetBrains Mono', monospace; color: #7C3AED; }
33
+ </style>
34
+ </head>
35
+ <body>
36
+ <div class="container">
37
+ <h1>Beehaven Dashboard</h1>
38
+ <p>Dashboard HTML not found. Place <code>dashboard.html</code> next to the compiled JS to enable the full UI.</p>
39
+ <p>The API endpoints are still available:</p>
40
+ <ul style="text-align:left;color:#888;">
41
+ <li><code>GET /api/state</code> — full state snapshot</li>
42
+ <li><code>GET /api/events</code> — recent events</li>
43
+ <li><code>GET /api/console</code> — console logs</li>
44
+ <li><code>GET /api/radar</code> — latest radar</li>
45
+ <li><code>GET /api/threats</code> — latest threats</li>
46
+ <li><code>WS /ws</code> — real-time stream</li>
47
+ </ul>
48
+ </div>
49
+ </body>
50
+ </html>`;
51
+ // ── Implementation ──────────────────────────────────────────────────────────
52
+ const __filename = fileURLToPath(import.meta.url);
53
+ const __dirname = dirname(__filename);
54
+ export class Dashboard {
55
+ _config;
56
+ _server = null;
57
+ _wss = null;
58
+ _clients = new Set();
59
+ // Rolling buffers
60
+ _events = [];
61
+ _consoleLogs = [];
62
+ _latestRadar = null;
63
+ _latestThreats = null;
64
+ _latestMetrics = null;
65
+ // Cached HTML
66
+ _htmlContent = null;
67
+ _explorerHtmlContent = null;
68
+ // Stats
69
+ _startedAt = 0;
70
+ _totalEvents = 0;
71
+ _totalConsoleLogs = 0;
72
+ // Browser-runtime buildings — set externally via setBrowserBuildings.
73
+ // Keyed by app name → map of building key → absolute path to compiled JS.
74
+ // The dashboard serves these as text/javascript modules at
75
+ // http://127.0.0.1:9800/apps/<name>/buildings/<key>.js so browser-side
76
+ // hive runtime code can dynamic-import them.
77
+ _browserBuildings = new Map();
78
+ // Installed apps — set externally via setAppFrontends. Keyed by app name.
79
+ // `appRoot` is the .app extract root; `frontendEntry` is the relative
80
+ // path within it to serve when no sub-path is requested.
81
+ _appFrontends = new Map();
82
+ // Registered marketplace feed — optional static catalog of available apps
83
+ // to surface alongside installed ones.
84
+ _marketplaceCatalogProvider = null;
85
+ // Bridge to the IPC bus — set externally via setAppBridge. When present,
86
+ // /apps/ws WebSocket connections are forwarded into the bus as virtual
87
+ // IPC clients so the same pipeline/app-rpc handlers serve browsers too.
88
+ _appBridge = null;
89
+ _appWss = null;
90
+ constructor(config) {
91
+ this._config = {
92
+ port: config?.port ?? 9800,
93
+ host: config?.host ?? '127.0.0.1',
94
+ maxEvents: config?.maxEvents ?? 500,
95
+ maxConsoleLogs: config?.maxConsoleLogs ?? 1000,
96
+ explorerTarget: config?.explorerTarget ?? 'http://127.0.0.1:8787',
97
+ explorerSecret: config?.explorerSecret || this._resolveExplorerSecret(config?.projectDir) || '',
98
+ projectDir: config?.projectDir ?? '',
99
+ };
100
+ }
101
+ /**
102
+ * Resolve the Explorer admin secret from .dev.vars or process.env.
103
+ * Wrangler reads .dev.vars automatically during `wrangler dev` — we mirror
104
+ * that behavior so the dashboard proxy can authenticate to the same Worker.
105
+ */
106
+ _resolveExplorerSecret(projectDir) {
107
+ // 1. Try .dev.vars in the project directory (same file wrangler reads)
108
+ const dirs = [projectDir, process.cwd()].filter(Boolean);
109
+ for (const dir of dirs) {
110
+ const devVarsPath = join(dir, '.dev.vars');
111
+ if (existsSync(devVarsPath)) {
112
+ try {
113
+ const content = readFileSync(devVarsPath, 'utf8');
114
+ for (const line of content.split('\n')) {
115
+ const trimmed = line.trim();
116
+ if (trimmed.startsWith('#') || !trimmed.includes('='))
117
+ continue;
118
+ const eqIdx = trimmed.indexOf('=');
119
+ const key = trimmed.slice(0, eqIdx).trim();
120
+ if (key === 'ADMIN_SECRET') {
121
+ const val = trimmed
122
+ .slice(eqIdx + 1)
123
+ .trim()
124
+ .replace(/^["']|["']$/g, '');
125
+ if (val)
126
+ return val;
127
+ }
128
+ }
129
+ }
130
+ catch {
131
+ /* ignore read errors */
132
+ }
133
+ }
134
+ }
135
+ // 2. Fall back to process.env
136
+ if (process.env.ADMIN_SECRET)
137
+ return process.env.ADMIN_SECRET;
138
+ return '';
139
+ }
140
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
141
+ async start() {
142
+ if (this._server)
143
+ return;
144
+ this._startedAt = Date.now();
145
+ this._loadHTML();
146
+ this._server = createServer((req, res) => this._handleHTTP(req, res));
147
+ // One WebSocketServer for the whole HTTP server. We don't use the
148
+ // { path } filter because attaching a SECOND WSS with a different
149
+ // path causes the ws library to 400 mismatched upgrades from the
150
+ // first WSS's listener — both listeners fire on every upgrade event.
151
+ // Instead we accept all upgrades and route by req.url in the
152
+ // connection handler.
153
+ this._wss = new WebSocketServer({ server: this._server });
154
+ this._wss.on('connection', (ws, req) => {
155
+ const url = req.url ?? '';
156
+ if (url.startsWith('/apps/ws')) {
157
+ this._handleAppWsConnection(ws);
158
+ }
159
+ else if (url.startsWith('/ws')) {
160
+ this._handleWSConnection(ws, req);
161
+ }
162
+ else {
163
+ try {
164
+ ws.close(1008, `unknown path: ${url}`);
165
+ }
166
+ catch { }
167
+ }
168
+ });
169
+ this._wss.on('error', (err) => {
170
+ // Suppress ECONNRESET and similar transient errors
171
+ if (err.code !== 'ECONNRESET') {
172
+ console.error('[dashboard] WebSocket server error:', err.message);
173
+ }
174
+ });
175
+ // `_appWss` is no longer a separate server — routing is unified above.
176
+ this._appWss = this._wss;
177
+ return new Promise((resolve, reject) => {
178
+ const server = this._server;
179
+ const onError = (err) => {
180
+ server.removeListener('listening', onListening);
181
+ this._server = null;
182
+ this._wss = null;
183
+ reject(err);
184
+ };
185
+ const onListening = () => {
186
+ server.removeListener('error', onError);
187
+ resolve();
188
+ };
189
+ server.once('error', onError);
190
+ server.once('listening', onListening);
191
+ server.listen(this._config.port, this._config.host);
192
+ });
193
+ }
194
+ async stop() {
195
+ if (!this._server)
196
+ return;
197
+ // Close all WebSocket clients
198
+ for (const client of this._clients) {
199
+ try {
200
+ client.close(1001, 'Dashboard shutting down');
201
+ }
202
+ catch {
203
+ // Ignore errors during shutdown
204
+ }
205
+ }
206
+ this._clients.clear();
207
+ // Close WebSocket server
208
+ if (this._wss) {
209
+ await new Promise((resolve) => {
210
+ this._wss.close(() => resolve());
211
+ });
212
+ this._wss = null;
213
+ }
214
+ // Close HTTP server
215
+ await new Promise((resolve, reject) => {
216
+ this._server.close((err) => {
217
+ if (err)
218
+ reject(err);
219
+ else
220
+ resolve();
221
+ });
222
+ });
223
+ this._server = null;
224
+ }
225
+ // ── Push methods ──────────────────────────────────────────────────────────
226
+ pushRadar(snapshot) {
227
+ this._latestRadar = snapshot;
228
+ this._broadcast({ type: 'radar', data: snapshot });
229
+ }
230
+ pushThreats(data) {
231
+ this._latestThreats = data;
232
+ this._broadcast({ type: 'threats', data });
233
+ }
234
+ pushMetrics(data) {
235
+ this._latestMetrics = data;
236
+ this._broadcast({ type: 'metrics', data });
237
+ }
238
+ pushEvent(event) {
239
+ this._events.push(event);
240
+ if (this._events.length > this._config.maxEvents) {
241
+ this._events.shift();
242
+ }
243
+ this._totalEvents++;
244
+ this._broadcast({ type: 'event', data: event });
245
+ }
246
+ pushConsoleLog(entry) {
247
+ this._consoleLogs.push(entry);
248
+ if (this._consoleLogs.length > this._config.maxConsoleLogs) {
249
+ this._consoleLogs.shift();
250
+ }
251
+ this._totalConsoleLogs++;
252
+ this._broadcast({ type: 'console-log', data: entry });
253
+ }
254
+ // ── Getters ───────────────────────────────────────────────────────────────
255
+ get url() {
256
+ return `http://${this._config.host}:${this._config.port}`;
257
+ }
258
+ get port() {
259
+ return this._config.port;
260
+ }
261
+ get running() {
262
+ return this._server !== null;
263
+ }
264
+ get clientCount() {
265
+ return this._clients.size;
266
+ }
267
+ // ── HTTP handler ──────────────────────────────────────────────────────────
268
+ _handleHTTP(req, res) {
269
+ const url = req.url ?? '/';
270
+ const method = req.method ?? 'GET';
271
+ // Handle CORS preflight
272
+ if (method === 'OPTIONS') {
273
+ res.writeHead(204, {
274
+ 'Access-Control-Allow-Origin': '*',
275
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
276
+ 'Access-Control-Allow-Headers': 'Content-Type',
277
+ 'Access-Control-Max-Age': '86400',
278
+ });
279
+ res.end();
280
+ return;
281
+ }
282
+ try {
283
+ // Explorer API proxy — forward /api/explorer/* to the Worker
284
+ if (url.startsWith('/api/explorer')) {
285
+ this._proxyExplorer(req, res, url);
286
+ return;
287
+ }
288
+ // Browser-runtime building JS modules: /apps/<name>/buildings/<key>.js
289
+ // These are runtime: 'browser' buildings declared in a hive's
290
+ // app.json — the daemon serves their compiled entry as a static
291
+ // module so the user's browser can dynamic-import + dispatch via
292
+ // the hive runtime (`hive.call(<hive>, <action>, ...)`).
293
+ // Matched BEFORE the general /apps/<name>/... static route so a
294
+ // hive's frontend can't accidentally shadow a building's URL.
295
+ if (method === 'GET') {
296
+ const buildingMatch = url.match(/^\/apps\/([^\/]+)\/buildings\/([^\/]+)\.js$/);
297
+ if (buildingMatch) {
298
+ this._serveBrowserBuilding(res, buildingMatch[1], buildingMatch[2]);
299
+ return;
300
+ }
301
+ }
302
+ // App frontend static files: /apps/<name>/... → <app.frontendDir>/...
303
+ if (method === 'GET' && url.startsWith('/apps/')) {
304
+ if (url === '/hives' || url === '/apps/') {
305
+ this._serveAppIndex(res);
306
+ return;
307
+ }
308
+ this._serveAppStatic(res, url);
309
+ return;
310
+ }
311
+ if (method === 'GET') {
312
+ switch (url) {
313
+ case '/':
314
+ case '/index.html':
315
+ this._serveHTML(res);
316
+ return;
317
+ case '/explorer':
318
+ case '/explorer/':
319
+ this._serveExplorerHTML(res);
320
+ return;
321
+ case '/api/state':
322
+ this._serveJSON(res, this._getState());
323
+ return;
324
+ case '/api/events':
325
+ this._serveJSON(res, this._events);
326
+ return;
327
+ case '/api/console':
328
+ this._serveJSON(res, this._consoleLogs);
329
+ return;
330
+ case '/api/radar':
331
+ this._serveJSON(res, this._latestRadar);
332
+ return;
333
+ case '/api/threats':
334
+ this._serveJSON(res, this._latestThreats);
335
+ return;
336
+ case '/api/metrics':
337
+ this._serveJSON(res, this._latestMetrics);
338
+ return;
339
+ case '/api/marketplace':
340
+ case '/api/marketplace/':
341
+ this._serveMarketplace(res);
342
+ return;
343
+ default:
344
+ res.writeHead(404, {
345
+ 'Content-Type': 'text/plain',
346
+ 'Access-Control-Allow-Origin': '*',
347
+ });
348
+ res.end('Not Found');
349
+ return;
350
+ }
351
+ }
352
+ if (method === 'POST' && url === '/api/console') {
353
+ this._handleConsolePost(req, res);
354
+ return;
355
+ }
356
+ res.writeHead(405, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
357
+ res.end('Method Not Allowed');
358
+ }
359
+ catch (err) {
360
+ console.error('[dashboard] HTTP error:', err);
361
+ res.writeHead(500, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
362
+ res.end('Internal Server Error');
363
+ }
364
+ }
365
+ _handleConsolePost(req, res) {
366
+ const chunks = [];
367
+ req.on('data', (chunk) => {
368
+ chunks.push(chunk);
369
+ // Guard against oversized payloads (1MB limit)
370
+ const totalSize = chunks.reduce((sum, c) => sum + c.length, 0);
371
+ if (totalSize > 1_048_576) {
372
+ res.writeHead(413, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
373
+ res.end('Payload Too Large');
374
+ req.destroy();
375
+ }
376
+ });
377
+ req.on('end', () => {
378
+ try {
379
+ const body = Buffer.concat(chunks).toString('utf8');
380
+ const parsed = JSON.parse(body);
381
+ // Accept single entry or array
382
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
383
+ for (const entry of entries) {
384
+ if (entry && typeof entry.level === 'string' && Array.isArray(entry.args)) {
385
+ const normalized = {
386
+ level: entry.level,
387
+ args: entry.args,
388
+ timestamp: entry.timestamp ?? Date.now(),
389
+ source: entry.source,
390
+ url: entry.url,
391
+ sessionId: entry.sessionId,
392
+ userId: entry.userId,
393
+ };
394
+ this.pushConsoleLog(normalized);
395
+ }
396
+ }
397
+ res.writeHead(200, {
398
+ 'Content-Type': 'application/json',
399
+ 'Access-Control-Allow-Origin': '*',
400
+ });
401
+ res.end(JSON.stringify({ ok: true, accepted: entries.length }));
402
+ }
403
+ catch {
404
+ res.writeHead(400, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
405
+ res.end('Bad Request: invalid JSON');
406
+ }
407
+ });
408
+ req.on('error', () => {
409
+ res.writeHead(400, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
410
+ res.end('Bad Request');
411
+ });
412
+ }
413
+ // ── WebSocket handler ─────────────────────────────────────────────────────
414
+ _handleWSConnection(ws, _req) {
415
+ this._clients.add(ws);
416
+ // Send initial state
417
+ try {
418
+ ws.send(JSON.stringify({ type: 'init', data: this._getState() }));
419
+ }
420
+ catch {
421
+ // Client may have disconnected immediately
422
+ this._clients.delete(ws);
423
+ return;
424
+ }
425
+ ws.on('message', (raw) => {
426
+ try {
427
+ const msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf8'));
428
+ switch (msg.type) {
429
+ case 'console-log': {
430
+ if (msg.data && typeof msg.data.level === 'string' && Array.isArray(msg.data.args)) {
431
+ const entry = {
432
+ level: msg.data.level,
433
+ args: msg.data.args,
434
+ timestamp: msg.data.timestamp ?? Date.now(),
435
+ source: msg.data.source,
436
+ url: msg.data.url,
437
+ sessionId: msg.data.sessionId,
438
+ userId: msg.data.userId,
439
+ };
440
+ this.pushConsoleLog(entry);
441
+ }
442
+ break;
443
+ }
444
+ case 'ping': {
445
+ ws.send(JSON.stringify({ type: 'pong' }));
446
+ break;
447
+ }
448
+ default:
449
+ // Unknown message type — ignore silently
450
+ break;
451
+ }
452
+ }
453
+ catch {
454
+ // Malformed message — ignore
455
+ }
456
+ });
457
+ ws.on('close', () => {
458
+ this._clients.delete(ws);
459
+ });
460
+ ws.on('error', () => {
461
+ this._clients.delete(ws);
462
+ });
463
+ }
464
+ // ── Private helpers ───────────────────────────────────────────────────────
465
+ _broadcast(message) {
466
+ if (this._clients.size === 0)
467
+ return;
468
+ const json = JSON.stringify(message);
469
+ for (const client of this._clients) {
470
+ if (client.readyState === 1) {
471
+ // WebSocket.OPEN
472
+ try {
473
+ client.send(json);
474
+ }
475
+ catch {
476
+ // Remove broken clients
477
+ this._clients.delete(client);
478
+ }
479
+ }
480
+ }
481
+ }
482
+ _getState() {
483
+ return {
484
+ radar: this._latestRadar,
485
+ threats: this._latestThreats,
486
+ metrics: this._latestMetrics,
487
+ events: this._events.slice(-100),
488
+ consoleLogs: this._consoleLogs.slice(-100),
489
+ stats: {
490
+ startedAt: this._startedAt,
491
+ uptime: Date.now() - this._startedAt,
492
+ totalEvents: this._totalEvents,
493
+ totalConsoleLogs: this._totalConsoleLogs,
494
+ dashboardClients: this._clients.size,
495
+ },
496
+ };
497
+ }
498
+ _loadHTML() {
499
+ try {
500
+ const htmlPath = join(__dirname, 'dashboard.html');
501
+ this._htmlContent = readFileSync(htmlPath, 'utf8');
502
+ }
503
+ catch {
504
+ this._htmlContent = null;
505
+ }
506
+ try {
507
+ const explorerPath = join(__dirname, 'explorer.html');
508
+ this._explorerHtmlContent = readFileSync(explorerPath, 'utf8');
509
+ }
510
+ catch {
511
+ this._explorerHtmlContent = null;
512
+ }
513
+ }
514
+ _serveHTML(res) {
515
+ const html = this._htmlContent ?? FALLBACK_HTML;
516
+ res.writeHead(200, {
517
+ 'Content-Type': 'text/html; charset=utf-8',
518
+ 'Cache-Control': 'no-cache',
519
+ 'Access-Control-Allow-Origin': '*',
520
+ });
521
+ res.end(html);
522
+ }
523
+ _serveExplorerHTML(res) {
524
+ if (this._explorerHtmlContent) {
525
+ res.writeHead(200, {
526
+ 'Content-Type': 'text/html; charset=utf-8',
527
+ 'Cache-Control': 'no-cache',
528
+ 'Access-Control-Allow-Origin': '*',
529
+ });
530
+ res.end(this._explorerHtmlContent);
531
+ }
532
+ else {
533
+ res.writeHead(200, {
534
+ 'Content-Type': 'text/html; charset=utf-8',
535
+ 'Cache-Control': 'no-cache',
536
+ 'Access-Control-Allow-Origin': '*',
537
+ });
538
+ res.end(`<!DOCTYPE html><html><head><title>Beehaven Explorer</title>
539
+ <style>body{font-family:Inter,system-ui;background:#0A0A12;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
540
+ .c{text-align:center;max-width:480px;padding:2rem}h1{color:#F5A623}code{background:#1a1a2e;padding:.2em .5em;border-radius:4px;color:#7C3AED}</style>
541
+ </head><body><div class="c"><h1>Beehaven Explorer</h1>
542
+ <p style="color:#888">Explorer HTML not found. Place <code>explorer.html</code> next to the compiled JS.</p>
543
+ </div></body></html>`);
544
+ }
545
+ }
546
+ async _proxyExplorer(req, res, url) {
547
+ // Strip /api/explorer prefix → forward to Worker's /explorer/* endpoints
548
+ const path = url.replace('/api/explorer', '/explorer');
549
+ const target = this._config.explorerTarget + path;
550
+ const method = req.method ?? 'GET';
551
+ // Collect request body for POST/PUT
552
+ let body;
553
+ if (method === 'POST' || method === 'PUT') {
554
+ const chunks = [];
555
+ for await (const chunk of req) {
556
+ chunks.push(chunk);
557
+ }
558
+ body = Buffer.concat(chunks).toString('utf8');
559
+ }
560
+ try {
561
+ const headers = {
562
+ 'Content-Type': 'application/json',
563
+ };
564
+ // Inject admin secret for auth
565
+ if (this._config.explorerSecret) {
566
+ headers['Authorization'] = `Bearer ${this._config.explorerSecret}`;
567
+ }
568
+ const upstream = await fetch(target, {
569
+ method,
570
+ headers,
571
+ body: method !== 'GET' && method !== 'HEAD' ? body : undefined,
572
+ });
573
+ const responseBody = await upstream.text();
574
+ res.writeHead(upstream.status, {
575
+ 'Content-Type': upstream.headers.get('Content-Type') || 'application/json',
576
+ 'Access-Control-Allow-Origin': '*',
577
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
578
+ 'Access-Control-Allow-Headers': 'Content-Type',
579
+ });
580
+ res.end(responseBody);
581
+ }
582
+ catch (err) {
583
+ res.writeHead(502, {
584
+ 'Content-Type': 'application/json',
585
+ 'Access-Control-Allow-Origin': '*',
586
+ });
587
+ res.end(JSON.stringify({
588
+ error: `Cannot reach Explorer API at ${this._config.explorerTarget}: ${err.message}`,
589
+ }));
590
+ }
591
+ }
592
+ _serveJSON(res, data) {
593
+ res.writeHead(200, {
594
+ 'Content-Type': 'application/json',
595
+ 'Access-Control-Allow-Origin': '*',
596
+ });
597
+ res.end(JSON.stringify(data));
598
+ }
599
+ // ── App frontend hosting ──────────────────────────────────────────────
600
+ //
601
+ // The daemon calls setAppFrontends after loadInstalledApps resolves,
602
+ // giving the dashboard the map of app name → absolute frontend dir.
603
+ // Requests to /apps/<name>/<path> are served as static files from that
604
+ // directory (mime-typed, path-escaped, scoped so "../" can't escape).
605
+ /**
606
+ * Register installed apps with the dashboard so their frontends + assets
607
+ * are served under /apps/<name>/. We serve from the app ROOT (not just
608
+ * the frontend dir) so a UI can fetch its own recipe JSONs and assets
609
+ * via stable /apps/<name>/... paths. The default response for a bare
610
+ * /apps/<name>/ hits the declared frontend.entry.
611
+ */
612
+ setAppFrontends(frontends) {
613
+ this._appFrontends.clear();
614
+ for (const f of frontends) {
615
+ if (!f.manifest?.frontend)
616
+ continue;
617
+ const frontendDir = f.manifest.frontend.dir ?? '';
618
+ const entry = f.manifest.frontend.entry ?? 'index.html';
619
+ // Resolve the frontend entry to a path relative to the app root.
620
+ const frontendEntry = frontendDir ? `${frontendDir.replace(/\/$/, '')}/${entry}` : entry;
621
+ this._appFrontends.set(f.name, { appRoot: f.appRoot, frontendEntry, manifest: f.manifest });
622
+ }
623
+ }
624
+ /**
625
+ * Register browser-runtime buildings with the dashboard. The daemon's
626
+ * LocalAppManager calls this after loadInstalledApps finishes, passing
627
+ * absolute file paths for each `runtime: "browser"` building. The
628
+ * dashboard then serves each as a JS module at
629
+ * /apps/<name>/buildings/<key>.js for the user's browser to fetch.
630
+ */
631
+ setBrowserBuildings(items) {
632
+ this._browserBuildings.clear();
633
+ for (const item of items) {
634
+ let appMap = this._browserBuildings.get(item.appName);
635
+ if (!appMap) {
636
+ appMap = new Map();
637
+ this._browserBuildings.set(item.appName, appMap);
638
+ }
639
+ appMap.set(item.buildingKey, item.absolutePath);
640
+ }
641
+ }
642
+ /**
643
+ * Serve a browser-runtime building's JS module. Path validated against
644
+ * registered absolute paths so there's no traversal risk — the URL only
645
+ * looks up an entry that was explicitly registered by the daemon at
646
+ * boot, no arbitrary filesystem read possible.
647
+ */
648
+ _serveBrowserBuilding(res, appName, buildingKey) {
649
+ const appMap = this._browserBuildings.get(appName);
650
+ if (!appMap) {
651
+ res.writeHead(404, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
652
+ res.end(`App "${appName}" has no browser buildings registered`);
653
+ return;
654
+ }
655
+ const filePath = appMap.get(buildingKey);
656
+ if (!filePath) {
657
+ res.writeHead(404, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
658
+ res.end(`Browser building "${buildingKey}" not found in app "${appName}"`);
659
+ return;
660
+ }
661
+ let data;
662
+ try {
663
+ data = readFileSync(filePath);
664
+ }
665
+ catch {
666
+ res.writeHead(500, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
667
+ res.end(`Failed to read browser building file`);
668
+ return;
669
+ }
670
+ res.writeHead(200, {
671
+ 'Content-Type': 'text/javascript; charset=utf-8',
672
+ 'Access-Control-Allow-Origin': '*',
673
+ // Short cache so dev rebuilds picked up quickly.
674
+ 'Cache-Control': 'no-cache',
675
+ });
676
+ res.end(data);
677
+ }
678
+ /**
679
+ * Give the dashboard a bridge to the IPC bus. Once set, /apps/ws
680
+ * WebSocket connections become virtual IPC clients — the browser can
681
+ * send `app-rpc`, `pipeline`, `subscribe`, `ping` messages just like
682
+ * a local Unix-socket client and receive broadcasts the same way.
683
+ *
684
+ * The /apps/ws WebSocketServer itself is started inside start() so
685
+ * connections work even if they arrive before the bridge is set.
686
+ */
687
+ setAppBridge(bridge) {
688
+ this._appBridge = bridge;
689
+ }
690
+ /**
691
+ * Register a provider that returns the full marketplace catalog
692
+ * (installed + available for install). Used by /api/marketplace.
693
+ * Daemon calls this during startup with a closure over the app
694
+ * manager + any cloud catalog it can reach.
695
+ */
696
+ setMarketplaceCatalogProvider(provider) {
697
+ this._marketplaceCatalogProvider = provider;
698
+ }
699
+ async _serveMarketplace(res) {
700
+ const payload = this._marketplaceCatalogProvider
701
+ ? await this._marketplaceCatalogProvider()
702
+ : { installed: [], available: [] };
703
+ this._serveJSON(res, payload);
704
+ }
705
+ _handleAppWsConnection(ws) {
706
+ if (!this._appBridge) {
707
+ ws.close(1011, 'app bridge not configured');
708
+ return;
709
+ }
710
+ const bridge = this._appBridge;
711
+ // Bind a virtual client whose outgoing messages forward to this ws.
712
+ const send = (msg) => {
713
+ if (ws.readyState !== 1)
714
+ return;
715
+ try {
716
+ ws.send(JSON.stringify(msg));
717
+ }
718
+ catch { }
719
+ };
720
+ // Subscribe broadly by default — browser UIs usually want to see
721
+ // everything (pipeline:*, broadcast:*, etc.). Clients can tighten this
722
+ // by sending their own { type: 'subscribe', patterns: [...] } message.
723
+ const virtId = bridge.createVirtualClient(send, ['*']);
724
+ ws.on('message', (raw) => {
725
+ let msg;
726
+ try {
727
+ msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf8'));
728
+ }
729
+ catch {
730
+ send({ type: 'error', error: 'invalid JSON' });
731
+ return;
732
+ }
733
+ try {
734
+ bridge.injectMessage(virtId, msg);
735
+ }
736
+ catch (err) {
737
+ send({ type: 'error', error: err?.message ?? String(err) });
738
+ }
739
+ });
740
+ const close = () => {
741
+ try {
742
+ bridge.releaseVirtualClient(virtId);
743
+ }
744
+ catch { }
745
+ };
746
+ ws.on('close', close);
747
+ ws.on('error', close);
748
+ }
749
+ _serveAppIndex(res) {
750
+ const list = Array.from(this._appFrontends.entries()).map(([name, h]) => ({
751
+ name,
752
+ version: h.manifest.version,
753
+ title: h.manifest.frontend?.title ?? h.manifest.name,
754
+ icon: h.manifest.frontend?.icon ?? '',
755
+ description: h.manifest.description ?? '',
756
+ url: `/apps/${name}/`,
757
+ }));
758
+ const body = `<!doctype html><html><head><meta charset="utf-8"><title>Hives</title>
759
+ <style>body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0a;color:#eee;padding:40px;max-width:720px;margin:auto}
760
+ a{color:#6af;text-decoration:none}h1{font-weight:300;letter-spacing:0.5px}
761
+ ul{list-style:none;padding:0}li{padding:16px;border:1px solid #222;border-radius:8px;margin:12px 0}
762
+ li .title{font-size:18px;font-weight:500}li .meta{color:#888;font-size:13px}</style>
763
+ </head><body><h1>Installed Hives</h1>${list.length === 0
764
+ ? '<p style="color:#888">No apps installed. Run <code>beehaven app install &lt;path&gt;</code>.</p>'
765
+ : `<ul>${list.map((l) => `<li><a href="${l.url}"><div class="title">${l.icon ? l.icon + ' ' : ''}${l.title}</div><div class="meta">${l.name}@${l.version}</div><div>${l.description}</div></a></li>`).join('')}</ul>`}</body></html>`;
766
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
767
+ res.end(body);
768
+ }
769
+ _serveAppStatic(res, url) {
770
+ // /apps/<name>/<...path> — extract name + relative path
771
+ const match = url.match(/^\/hives\/([^\/]+)(\/.*)?$/);
772
+ if (!match) {
773
+ res.writeHead(404);
774
+ res.end('Not Found');
775
+ return;
776
+ }
777
+ const [, name, rawPath] = match;
778
+ const appEntry = this._appFrontends.get(name);
779
+ if (!appEntry) {
780
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
781
+ res.end(`App "${name}" not installed or has no frontend`);
782
+ return;
783
+ }
784
+ // Default to the app's frontend entry file when no sub-path is requested.
785
+ let relPath = rawPath && rawPath !== '/' ? rawPath : `/${appEntry.frontendEntry}`;
786
+ relPath = relPath.replace(/[?#].*$/, '');
787
+ const normalized = relPath
788
+ .split('/')
789
+ .filter((p) => p && p !== '.')
790
+ .join('/');
791
+ if (normalized.split('/').includes('..')) {
792
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
793
+ res.end('Bad Request');
794
+ return;
795
+ }
796
+ const filePath = join(appEntry.appRoot, normalized);
797
+ // Defense against path escape — readFileSync doesn't enforce this for us.
798
+ if (!filePath.startsWith(appEntry.appRoot)) {
799
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
800
+ res.end('Bad Request');
801
+ return;
802
+ }
803
+ let data;
804
+ try {
805
+ data = readFileSync(filePath);
806
+ }
807
+ catch {
808
+ // SPA fallback — unknown path returns the frontend entry so client-side
809
+ // routers work, but this also means 404s for missing assets look like
810
+ // HTML. Acceptable trade-off for dev.
811
+ try {
812
+ data = readFileSync(join(appEntry.appRoot, appEntry.frontendEntry));
813
+ }
814
+ catch {
815
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
816
+ res.end('Not Found');
817
+ return;
818
+ }
819
+ }
820
+ const ext = (normalized.split('.').pop() ?? '').toLowerCase();
821
+ const type = {
822
+ html: 'text/html; charset=utf-8',
823
+ htm: 'text/html; charset=utf-8',
824
+ js: 'text/javascript; charset=utf-8',
825
+ mjs: 'text/javascript; charset=utf-8',
826
+ css: 'text/css; charset=utf-8',
827
+ json: 'application/json; charset=utf-8',
828
+ svg: 'image/svg+xml',
829
+ png: 'image/png',
830
+ jpg: 'image/jpeg',
831
+ jpeg: 'image/jpeg',
832
+ gif: 'image/gif',
833
+ ico: 'image/x-icon',
834
+ webp: 'image/webp',
835
+ woff: 'font/woff',
836
+ woff2: 'font/woff2',
837
+ }[ext] ?? 'application/octet-stream';
838
+ res.writeHead(200, {
839
+ 'Content-Type': type,
840
+ 'Access-Control-Allow-Origin': '*',
841
+ // Short cache so hot reloads pick up edits.
842
+ 'Cache-Control': 'no-cache',
843
+ });
844
+ res.end(data);
845
+ }
846
+ }