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
package/dist/daemon.js ADDED
@@ -0,0 +1,4471 @@
1
+ // ============================================================================
2
+ // Beehaven Daemon — Clearly's Local Agent
3
+ // Captures dev activity, relays to Clearly cloud, enables remote agent control.
4
+ // No Express server, no browser UI — the office lives at clearly.sh/office.
5
+ // ============================================================================
6
+ import { ClaudeWatcher } from './watcher.js';
7
+ import { devModeAvailable } from './build-channel.js';
8
+ import { Relay } from './relay.js';
9
+ // ⚠ The identity the daemon's OWN in-process tools act as — the same desktop binding its CLI
10
+ // spawns carry. See the `invokeRpc` note at `setClearlyTools`.
11
+ import { daemonToolIdentity, desktopPrincipal, listAgents } from './agent-state.js';
12
+ import { DaemonRepl } from './repl.js';
13
+ import { browsePath } from './file-browser.js';
14
+ import { CloudBeeProxy } from './bee/cloud-proxy.js';
15
+ import { IpcServer, probeIpcSocket } from './ipc-server.js';
16
+ import { mintIpcAuthTokens, clearIpcAuthToken } from './ipc-auth.js';
17
+ /**
18
+ * What THIS daemon minted, so shutdown can avoid scrubbing a successor's token.
19
+ * ⚠ Undefined until the mint runs — a shutdown before that legitimately clears nothing of ours.
20
+ */
21
+ let mintedIpcTokens;
22
+ import { isDaemonFetchUrlAllowed, mayReceiveCredentials } from './daemon-fetch-allowlist.js';
23
+ /**
24
+ * ⚠ STATIC, unlike the `await import` the deleted bootstrap used. `scanInstalledApps` is
25
+ * synchronous and runs on the app-registration path; this module is fs/path only, so there is no
26
+ * load cost to defer.
27
+ */
28
+ import { bundledHiveDir } from './first-party-hives.js';
29
+ import { isPubliclyVisibleApp } from './public-surface.js';
30
+ import { TranscriptTailer } from './transcript-tailer.js';
31
+ import { getLinkedFolders, FolderWatcher } from './folder-sync.js';
32
+ import { LocalAppManager } from './local-app-manager.js';
33
+ import { Dashboard } from './dashboard.js';
34
+ import { logger, setLogLevel } from './logger.js';
35
+ import { HealthMonitor } from './health.js';
36
+ import { keychain } from './keychain.js';
37
+ import { resolveAgentIdentitySecret, verifyAgentToken, mintAgentToken, } from './agent-identity-daemon.js';
38
+ import { AgentSessions } from './agent-sessions.js';
39
+ import { RELAY_PRESETS, relayUrlFromConfig, resolveRelaySelection } from './relay-presets.js';
40
+ import { readFileSync, existsSync, mkdirSync, mkdtempSync, writeFileSync, readdirSync, chmodSync, unlinkSync, rmSync, } from 'fs';
41
+ import { createHash } from 'crypto';
42
+ import { execFile, execSync, spawn } from 'child_process';
43
+ import { homedir, tmpdir } from 'os';
44
+ import { join, dirname, relative } from 'path';
45
+ import { fileURLToPath } from 'url';
46
+ import { compareVersions, usableUpdateDigest } from './version-utils.js';
47
+ import { installKind } from './commands/self-update.js';
48
+ import { collectSystemTelemetry, collectGitStatus } from './telemetry.js';
49
+ const __dirname = dirname(fileURLToPath(import.meta.url));
50
+ const CONFIG_DIR = join(homedir(), '.beehaven');
51
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
52
+ const PID_FILE = join(CONFIG_DIR, 'daemon.pid');
53
+ /**
54
+ * Inspect the app manager's installed catalog for any app that declares
55
+ * `buildings.<name>.hosted: "cloud"` matching the requested building name.
56
+ * Returns the resolved cloudUrl (with template vars still substituted by
57
+ * the caller) + the owning app's name so broadcasts can attribute the
58
+ * forward correctly.
59
+ */
60
+ function findCloudBuildingRoute(appManager, buildingName) {
61
+ if (!appManager || typeof appManager.listInstalledApps !== 'function')
62
+ return null;
63
+ for (const h of appManager.listInstalledApps()) {
64
+ if (!h.cloudBuildings)
65
+ continue;
66
+ const match = h.cloudBuildings.find((c) => c.name === buildingName);
67
+ if (match)
68
+ return { cloudUrl: match.cloudUrl, appName: h.name };
69
+ }
70
+ return null;
71
+ }
72
+ /**
73
+ * Forward a app-rpc to a cloud-hosted building.
74
+ *
75
+ * Two paths, behind the same function:
76
+ *
77
+ * 1. Real relay forwarding — send a cloud-app-rpc message over the
78
+ * existing wss://relay.clearly.sh connection, wait for a matching
79
+ * cloud-app-rpc-result message, return the payload. The Cloudflare
80
+ * Worker (future work) deserializes, routes to a Durable Object,
81
+ * handles the request, sends the result back.
82
+ *
83
+ * 2. Local in-process simulator — when `BEEHAVEN_CLOUD_SIMULATOR=1`
84
+ * (dev default), skip the relay hop and invoke a stub handler right
85
+ * here in the daemon. Lets us prove the whole wire format end-to-end
86
+ * before the Worker side is deployed.
87
+ *
88
+ * The contract is identical either way: the caller gets a Promise that
89
+ * resolves to the result or rejects with an error.
90
+ */
91
+ async function forwardCloudAppRpc(params) {
92
+ const { relay, cloudUrl, app, building, instanceId, action, input, rid } = params;
93
+ // Local simulator path: any app with a :simulator URL, or the global
94
+ // BEEHAVEN_CLOUD_SIMULATOR flag, runs through a canned handler. This
95
+ // is what lets the Mac app demo Shopify without a deployed Worker.
96
+ const simulatorMode = process.env.BEEHAVEN_CLOUD_SIMULATOR !== '0';
97
+ if (simulatorMode) {
98
+ return simulateCloudAppRpc({ app, building, instanceId, action, input });
99
+ }
100
+ // Real relay-forwarded path. Requires the cloud side to be running
101
+ // and the relay WebSocket to be connected. Not wired as default yet.
102
+ if (!relay || !relay.isConnected?.()) {
103
+ throw new Error('Cloud routing unavailable — relay not connected');
104
+ }
105
+ return new Promise((resolve, reject) => {
106
+ const handler = (msg) => {
107
+ if (msg?.type === 'cloud-app-rpc-result' && msg.rid === rid) {
108
+ relay.offBroadcast?.(handler);
109
+ clearTimeout(timer);
110
+ if (msg.error)
111
+ reject(new Error(msg.error));
112
+ else
113
+ resolve(msg.result);
114
+ }
115
+ };
116
+ const timer = setTimeout(() => {
117
+ relay.offBroadcast?.(handler);
118
+ reject(new Error('Cloud app RPC timeout (30s)'));
119
+ }, 30000);
120
+ relay.onBroadcast?.(handler);
121
+ relay.wsSend?.({
122
+ type: 'cloud-app-rpc',
123
+ rid,
124
+ app,
125
+ building,
126
+ instanceId,
127
+ action,
128
+ input,
129
+ cloudUrl,
130
+ });
131
+ });
132
+ }
133
+ /**
134
+ * Local cloud-app simulator. Stands in for a Cloudflare Worker + DO
135
+ * routing layer. Every cloud app installed on this machine can provide
136
+ * its own simulated handlers by exporting a `cloudHandlers` map from a
137
+ * file at <appDir>/cloud.js, but for now we ship a hardcoded Shopify
138
+ * response so the end-to-end demo works.
139
+ */
140
+ async function simulateCloudAppRpc(params) {
141
+ const { app, building, action, input } = params;
142
+ // Shopify stub — canned Admin API responses for the demo UI.
143
+ if (building === 'shopify') {
144
+ switch (action) {
145
+ case 'products-list':
146
+ return {
147
+ products: [
148
+ { id: 9187348231, title: 'Canvas Tote — Ekali Edition', stock: 42 },
149
+ { id: 9187348232, title: 'Studio Notebook, A5, Charcoal', stock: 127 },
150
+ { id: 9187348233, title: 'Monk Strap Hoodie — Black', stock: 8 },
151
+ { id: 9187348234, title: 'Enamel Pin Set (3-pack)', stock: 219 },
152
+ { id: 9187348235, title: 'Vinyl: "Nitepunk Piano" LP', stock: 64 },
153
+ ],
154
+ simulated: true,
155
+ note: 'Local cloud simulator — would hit admin.shopify.com in prod',
156
+ };
157
+ case 'store-info':
158
+ return {
159
+ name: 'cloudhive-demo',
160
+ domain: 'cloudhive-demo.myshopify.com',
161
+ plan: 'development',
162
+ currency: 'USD',
163
+ simulated: true,
164
+ };
165
+ default:
166
+ throw new Error(`shopify stub: unknown action "${action}"`);
167
+ }
168
+ }
169
+ throw new Error(`No simulator for cloud app "${app}" building "${building}"`);
170
+ }
171
+ /**
172
+ * Curated "available to install" app list served by /api/marketplace.
173
+ *
174
+ * This is a stub catalog. Once the cloud marketplace is live, the daemon
175
+ * will fetch from https://clearly.sh/apps/catalog.json instead of this
176
+ * hardcoded array. The installed-app set is subtracted at serve time so
177
+ * users don't see things they already have.
178
+ *
179
+ * Shapes roughly mirror AgentHiveCatalogEntry minus the live "registered"
180
+ * state — a catalog entry is a promise of what installing would give you.
181
+ */
182
+ const CATALOG_URL = 'https://clearly.sh/apps/catalog.json';
183
+ const CATALOG_CACHE_MS = 5 * 60_000; // 5 min
184
+ let _catalogCache = null;
185
+ let _catalogFetchedAt = 0;
186
+ /** Fetch the app catalog from clearly.sh, falling back to the static array. */
187
+ async function getAvailableApps() {
188
+ if (_catalogCache && Date.now() - _catalogFetchedAt < CATALOG_CACHE_MS) {
189
+ return _catalogCache;
190
+ }
191
+ let remote = [];
192
+ try {
193
+ const res = await fetch(CATALOG_URL, { signal: AbortSignal.timeout(2_000) });
194
+ if (res.ok) {
195
+ const data = (await res.json());
196
+ if (data.apps?.length)
197
+ remote = data.apps;
198
+ }
199
+ }
200
+ catch { }
201
+ // Merge: static fallback fills gaps the remote catalog doesn't have yet.
202
+ // Useful during development when new apps aren't deployed to clearly.sh.
203
+ const names = new Set(remote.map((a) => a.name));
204
+ const merged = [...remote, ...STATIC_FALLBACK_APPS.filter((a) => !names.has(a.name))];
205
+ _catalogCache = merged;
206
+ _catalogFetchedAt = Date.now();
207
+ return merged;
208
+ }
209
+ const STATIC_FALLBACK_APPS = [
210
+ {
211
+ name: 'ableton',
212
+ version: '1.0.0',
213
+ description: 'Ableton Live 12 controller — 140+ actions for transport, tracks, clips, devices, composition via AbletonOSC. Claude as your music producer.',
214
+ vendor: 'Clearly',
215
+ tier: 'free',
216
+ tags: ['creative', 'audio', 'local'],
217
+ kind: 'local',
218
+ install: { source: 'bundled' },
219
+ },
220
+ {
221
+ name: 'memory-cloud',
222
+ version: '0.1.0',
223
+ description: 'Persistent memory and notes for AI agents — remember, recall, search, tag, and time-travel through version history.',
224
+ vendor: 'Clearly',
225
+ tier: 'free',
226
+ tags: ['memory', 'notes', 'local'],
227
+ kind: 'local',
228
+ install: { source: 'bundled' },
229
+ },
230
+ {
231
+ name: 'shopify',
232
+ version: '0.1.0',
233
+ description: 'Your Shopify store as a beehaven app — products, inventory, orders, customers, Admin API. Cloud-hosted: the building runs in a Cloudflare DO and talks to your store via OAuth. Install once, use from Claude and the browser.',
234
+ vendor: 'Clearly',
235
+ tier: 'pro',
236
+ tags: ['commerce', 'cloud', 'api'],
237
+ kind: 'cloud',
238
+ install: { source: 'url', url: 'https://clearly.sh/apps/shopify/shopify-0.1.0.hive' },
239
+ frontend: { title: 'Shopify', icon: '🛍️' },
240
+ },
241
+ {
242
+ name: 'blender',
243
+ version: '0.1.0',
244
+ description: 'Blender 4.x controller over its scripting bridge. Create scenes, add objects, set materials, render frames. Claude as a technical director.',
245
+ vendor: 'Clearly',
246
+ tier: 'free',
247
+ tags: ['creative', '3d', 'local'],
248
+ kind: 'local',
249
+ install: { source: 'url', url: 'https://clearly.sh/apps/blender/blender-0.1.0.hive' },
250
+ frontend: { title: 'Blender', icon: '🧊' },
251
+ },
252
+ {
253
+ name: 'figma',
254
+ version: '0.1.0',
255
+ description: 'Figma plugin bridge — list files, read frames, generate variants, run layout operations. Cloud app — uses your Figma API token stored in Clearly.',
256
+ vendor: 'Clearly',
257
+ tier: 'pro',
258
+ tags: ['design', 'cloud', 'api'],
259
+ kind: 'cloud',
260
+ install: { source: 'url', url: 'https://clearly.sh/apps/figma/figma-0.1.0.hive' },
261
+ frontend: { title: 'Figma', icon: '🎨' },
262
+ },
263
+ {
264
+ name: 'logic-pro',
265
+ version: '0.1.0',
266
+ description: 'Logic Pro via MainStage / Core MIDI. Similar action surface to the ableton app — tracks, regions, plugins, mixer. Requires Logic 11 and the Clearly Script MIDI device enabled.',
267
+ vendor: 'Clearly',
268
+ tier: 'pro',
269
+ tags: ['creative', 'audio', 'local'],
270
+ kind: 'local',
271
+ install: { source: 'url', url: 'https://clearly.sh/apps/logic-pro/logic-pro-0.1.0.hive' },
272
+ frontend: { title: 'Logic Pro', icon: '🎚️' },
273
+ },
274
+ {
275
+ name: 'hue',
276
+ version: '0.1.0',
277
+ description: 'Philips Hue + Matter lights — scene control, color, brightness, rooms. "Claude, set the vibe for dinner" territory.',
278
+ vendor: 'Clearly',
279
+ tier: 'free',
280
+ tags: ['smart-home', 'local'],
281
+ kind: 'local',
282
+ install: { source: 'url', url: 'https://clearly.sh/apps/hue/hue-0.1.0.hive' },
283
+ frontend: { title: 'Philips Hue', icon: '💡' },
284
+ },
285
+ {
286
+ name: 'jira',
287
+ version: '0.1.0',
288
+ description: 'Jira Cloud — list issues, transition status, sprint triage, backlog grooming. Cloud-hosted, OAuth install flow.',
289
+ vendor: 'Clearly',
290
+ tier: 'pro',
291
+ tags: ['productivity', 'cloud', 'api'],
292
+ kind: 'cloud',
293
+ install: { source: 'url', url: 'https://clearly.sh/apps/jira/jira-0.1.0.hive' },
294
+ frontend: { title: 'Jira', icon: '📋' },
295
+ },
296
+ {
297
+ name: 'clearly-voice',
298
+ version: '0.1.0',
299
+ description: 'System-wide voice-to-text. Press Cmd+Shift+E anywhere to dictate — auto-pastes into the focused text field. Powered by Gemini STT.',
300
+ vendor: 'Clearly',
301
+ tier: 'free',
302
+ tags: ['voice', 'stt', 'dictation', 'local'],
303
+ kind: 'local',
304
+ install: { source: 'bundled' },
305
+ frontend: { title: 'Clearly Voice', icon: '🎙️' },
306
+ },
307
+ {
308
+ name: 'recorder',
309
+ version: '0.1.0',
310
+ description: 'System audio recorder — capture desktop audio, mic, or both to WAV/MP3. Tag, trim, and send recordings to other apps. Built on BlackHole virtual audio.',
311
+ vendor: 'Clearly',
312
+ tier: 'free',
313
+ tags: ['audio', 'recording', 'local'],
314
+ kind: 'local',
315
+ install: { source: 'bundled' },
316
+ frontend: { title: 'Recorder', icon: '🎤' },
317
+ },
318
+ // Internal SEO / marketing hives — used by scheduled routines on the
319
+ // operator's machine. `internal: true` keeps them out of the user-facing
320
+ // catalog unless HIVE_DEV_MODE=1 is set on the daemon. They're also
321
+ // excluded from the prod electron-builder bundle, so even if a prod
322
+ // user toggles dev mode the bundled-install path errors out cleanly.
323
+ {
324
+ name: 'gsc',
325
+ version: '0.1.0',
326
+ description: 'Google Search Console client — Search Analytics queries (top queries, deltas, page mix) via service-account or ADC auth. Used by SEO routines for daily pulse, weekly keyword refresh, monthly audit.',
327
+ vendor: 'Clearly',
328
+ tier: 'free',
329
+ tags: ['seo', 'analytics', 'gsc', 'local', 'internal'],
330
+ kind: 'local',
331
+ install: { source: 'bundled' },
332
+ frontend: { title: 'Search Console', icon: '🔎' },
333
+ internal: true,
334
+ },
335
+ {
336
+ name: 'marketing',
337
+ version: '0.1.0',
338
+ description: 'Marketing analytics — funnel + DAU + revenue from Cloudflare Analytics Engine + Stripe. Installs / connect / link / subscription / conversion event aggregator for SEO and growth routines.',
339
+ vendor: 'Clearly',
340
+ tier: 'free',
341
+ tags: ['analytics', 'marketing', 'funnel', 'revenue', 'local', 'internal'],
342
+ kind: 'local',
343
+ install: { source: 'bundled' },
344
+ frontend: { title: 'Marketing', icon: '📈' },
345
+ internal: true,
346
+ },
347
+ {
348
+ name: 'backlinks',
349
+ version: '0.1.0',
350
+ description: 'Backlink audit + diff — pluggable provider (DataForSEO / Ahrefs / Moz / Majestic) plus a no-API manual-crawl path. Snapshots persist locally for week-over-week diffs.',
351
+ vendor: 'Clearly',
352
+ tier: 'free',
353
+ tags: ['seo', 'backlinks', 'analytics', 'local', 'internal'],
354
+ kind: 'local',
355
+ install: { source: 'bundled' },
356
+ frontend: { title: 'Backlinks', icon: '🔗' },
357
+ internal: true,
358
+ },
359
+ ];
360
+ /** Scan ~/.beehaven/apps/ (canonical) and ~/.beehaven/hives/ (legacy) for installed apps.
361
+ * Accepts app.json or hive.json as manifest.
362
+ *
363
+ * ⚠⚠ ONE APP PER MANIFEST NAME. This deduped by DIRECTORY NAME, which is not the same question:
364
+ * `avatar/` and `avatar@0.1.0/` are two different directory names carrying the same
365
+ * `"name": "avatar"`, and both shapes exist in the wild — the bare one is the legacy install
366
+ * layout, the versioned one is what `ensureFirstPartyHives` writes. Observed on a real machine
367
+ * with both present: the daemon registered the same hive TWICE, and whichever `readdir` happened
368
+ * to yield first won — while the Mac app's `resolveUiDir` matched only the versioned shape and
369
+ * could therefore be reading the other copy entirely.
370
+ *
371
+ * That is the split this repo has already paid for once, when a `.bak-` directory shadowed a real
372
+ * install and "the action answered correctly while the UI that renders it did not exist". Nothing
373
+ * breaks loudly; the two halves simply run different code.
374
+ *
375
+ * Newest manifest version wins, and `apps/` beats legacy `hives/` at equal versions because it is
376
+ * the canonical root. Losers are LOGGED, never deleted — this is a user's disk, and a wrong guess
377
+ * about which copy mattered is not recoverable. */
378
+ /**
379
+ * The hives the product itself needs, loaded from the bundle rather than installed.
380
+ *
381
+ * `avatar` is included because its daemon building is the CLI/agent route for the Mac app's
382
+ * on-screen companion. The overlay UI and the building both resolve from the read-only bundle;
383
+ * nothing is copied into ~/.beehaven.
384
+ */
385
+ // The avatar building is part of the Mac app's first-party surface. Register it from the bundle
386
+ // in place so `beehaven connect avatar` and per-agent `say` work immediately after app install;
387
+ // this does not copy the hive into ~/.beehaven.
388
+ const FIRST_PARTY_HIVES = ['avatar', 'vector', 'image', 'bee-canvas'];
389
+ function bundledFirstPartyRoots() {
390
+ return FIRST_PARTY_HIVES
391
+ .map((name) => bundledHiveDir(name, __dirname))
392
+ .filter((dir) => !!dir);
393
+ }
394
+ function scanInstalledApps() {
395
+ const roots = [join(CONFIG_DIR, 'apps'), join(CONFIG_DIR, 'hives')];
396
+ const byName = new Map();
397
+ const dropped = [];
398
+ const versionRank = (v) => {
399
+ const p = String(v ?? '0').split('-')[0].split('.').map((n) => Number(n) || 0);
400
+ return (p[0] ?? 0) * 1e6 + (p[1] ?? 0) * 1e3 + (p[2] ?? 0);
401
+ };
402
+ for (let i = 0; i < roots.length; i += 1) {
403
+ const root = roots[i];
404
+ if (!existsSync(root))
405
+ continue;
406
+ try {
407
+ for (const entry of readdirSync(root)) {
408
+ if (entry.startsWith('.'))
409
+ continue;
410
+ const dir = join(root, entry);
411
+ const am = join(dir, 'app.json');
412
+ const hm = join(dir, 'hive.json');
413
+ const mp = existsSync(am) ? am : existsSync(hm) ? hm : null;
414
+ if (!mp)
415
+ continue;
416
+ let manifest;
417
+ try {
418
+ manifest = JSON.parse(readFileSync(mp, 'utf8'));
419
+ }
420
+ catch {
421
+ continue;
422
+ }
423
+ // The manifest's own name is the identity. Falling back to the directory name stops a
424
+ // manifest with no name from colliding with every other nameless one.
425
+ const name = typeof manifest?.name === 'string' && manifest.name ? manifest.name : entry;
426
+ // `roots.length - i` keeps apps/ ahead of legacy hives/ when versions tie.
427
+ const rank = versionRank(manifest?.version) * 10 + (roots.length - i);
428
+ const prev = byName.get(name);
429
+ if (!prev) {
430
+ byName.set(name, { dir, entry, manifest, rank });
431
+ continue;
432
+ }
433
+ const candidate = { dir, entry, manifest, rank };
434
+ const winner = rank > prev.rank ? candidate : prev;
435
+ const loser = rank > prev.rank ? prev : candidate;
436
+ byName.set(name, winner);
437
+ dropped.push(`${name}: using ${winner.dir}, ignoring ${loser.dir}`);
438
+ }
439
+ }
440
+ catch { }
441
+ }
442
+ /**
443
+ * ⚠⚠ FIRST-PARTY HIVES ARE READ FROM THE BUNDLE, IN PLACE — NEVER COPIED IN (2026-08-28).
444
+ *
445
+ * These used to be installed into `~/.beehaven/apps` by `ensureFirstPartyHives`, which copies a
446
+ * hive's WHOLE project directory: `src/`, `scripts/`, `tsconfig.json`, `package.json`, and for
447
+ * the avatar a `ui/` that the Mac app then RENDERED FROM. Three things followed, all observed:
448
+ * first-party source lived outside version control where a `cpSync(..., { force: true })` could
449
+ * destroy edits made to it; which copy the product ran depended on startup timing; and the
450
+ * daemon and the window could resolve different copies of one hive with nothing reporting it.
451
+ *
452
+ * Reading the bundle directly is the fix, and it is strictly better than a smarter copy: there
453
+ * is one location, it ships with the app, it is not writable by accident, and `~/.beehaven`
454
+ * goes back to holding only runtime state — config, credentials, logs.
455
+ *
456
+ * ⚠ ADDED LAST AND RANKED LOWEST, so a genuinely user-installed hive of the same name still
457
+ * wins. This adds a fallback; it takes nothing away.
458
+ *
459
+ * ⚠ AN EXPLICIT ALLOWLIST, NOT THE WHOLE DIRECTORY. In the packaged app the bundled hives dir
460
+ * holds two entries, but in a monorepo dev layout the same resolver finds `clearly-hives/`,
461
+ * which has twenty-one — scanning it wholesale would silently register every experiment in the
462
+ * repo as a live building.
463
+ */
464
+ for (const name of FIRST_PARTY_HIVES) {
465
+ if (byName.has(name))
466
+ continue;
467
+ try {
468
+ const dir = bundledHiveDir(name, __dirname);
469
+ if (!dir)
470
+ continue;
471
+ const mp = existsSync(join(dir, 'app.json')) ? join(dir, 'app.json') : join(dir, 'hive.json');
472
+ if (!existsSync(mp))
473
+ continue;
474
+ const manifest = JSON.parse(readFileSync(mp, 'utf8'));
475
+ byName.set(name, { dir, entry: name, manifest, rank: 0 });
476
+ }
477
+ catch { /* a bundled hive that cannot be read is simply absent */ }
478
+ }
479
+ if (dropped.length > 0) {
480
+ console.warn(` [app] duplicate installs resolved by version:\n ${dropped.join('\n ')}`);
481
+ }
482
+ return [...byName.values()].map(({ dir, entry, manifest }) => ({ dir, entry, manifest }));
483
+ }
484
+ function loadConfig() {
485
+ if (!existsSync(CONFIG_FILE))
486
+ return { onboarded: false, tier: 'local' };
487
+ try {
488
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
489
+ }
490
+ catch {
491
+ return { onboarded: false, tier: 'local' };
492
+ }
493
+ }
494
+ // `relayUrlFromConfig` / `RELAY_PRESETS` / `resolveRelaySelection` moved to the
495
+ // dependency-free ./relay-presets.js so read surfaces (whoami, admin console,
496
+ // auth-origin selection) can derive "which relay / which env" WITHOUT importing
497
+ // this heavy daemon module. Re-exported here for back-compat with existing
498
+ // `import { … } from './daemon.js'` callers.
499
+ export { RELAY_PRESETS, relayUrlFromConfig, resolveRelaySelection };
500
+ /**
501
+ * Resolve the relay URL for this daemon launch. Order of precedence:
502
+ * 1. BEEHAVEN_RELAY_URL env var (set by the Mac app's daemon-manager,
503
+ * or by a one-shot override like `BEEHAVEN_RELAY_URL=… beehaven start`)
504
+ * 2. config.endpoint → config.relayUrl (via relayUrlFromConfig)
505
+ * 3. Hard-coded prod default
506
+ */
507
+ export function resolveRelayUrlForDaemon() {
508
+ const fromEnv = process.env.BEEHAVEN_RELAY_URL;
509
+ if (fromEnv && fromEnv.length > 0)
510
+ return fromEnv;
511
+ return relayUrlFromConfig(loadConfig()) ?? 'wss://relay.clearly.sh';
512
+ }
513
+ /** Persist a chosen env (and the resolved URL) into ~/.beehaven/config.json. */
514
+ export function saveEnvChoice(name, url) {
515
+ // Write the canonical dial target `endpoint` alongside the `relayUrl`/
516
+ // `envName` aliases so all three stay in lockstep. Previously only
517
+ // relayUrl/envName were written, so `endpoint` (what the relay client
518
+ // actually dials) drifted — `beehaven env` reported one env while the
519
+ // daemon stayed connected to another.
520
+ saveConfig({ envName: name, relayUrl: url, endpoint: url });
521
+ }
522
+ function saveConfig(update) {
523
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
524
+ const existing = loadConfig();
525
+ // Pass mode to writeFileSync so the file is created with 0600 from the
526
+ // start — no race where another process could read it before the
527
+ // subsequent chmodSync lands. The chmod stays as defense-in-depth.
528
+ writeFileSync(CONFIG_FILE, JSON.stringify({ ...existing, ...update }, null, 2), { mode: 0o600 });
529
+ try {
530
+ chmodSync(CONFIG_FILE, 0o600);
531
+ }
532
+ catch { }
533
+ }
534
+ /** Redact secrets from text before relaying to cloud */
535
+ export function redactSecrets(text) {
536
+ return (text
537
+ // API keys, tokens, passwords in key=value or key: value format
538
+ .replace(/(api[_-]?key|token|secret|password|auth)\s*[:=]\s*['"]?[a-zA-Z0-9_\-./+]{8,}['"]?/gi, '$1=[REDACTED]')
539
+ // Bearer/Basic auth headers
540
+ .replace(/(Bearer|Basic)\s+[a-zA-Z0-9_\-./+=]{8,}/gi, '$1 [REDACTED]')
541
+ // Likely AWS access keys (20+ uppercase alphanumeric)
542
+ .replace(/\b(AKIA[A-Z0-9]{16})\b/g, '[REDACTED]'));
543
+ }
544
+ // ── Session Sync ──────────────────────────────────────────────────────────
545
+ const SYNCED_SESSIONS_FILE = join(CONFIG_DIR, 'synced-sessions.json');
546
+ const SESSIONS_DIR = join(CONFIG_DIR, 'sessions');
547
+ function loadSyncedSessions() {
548
+ if (!existsSync(SYNCED_SESSIONS_FILE))
549
+ return {};
550
+ try {
551
+ return JSON.parse(readFileSync(SYNCED_SESSIONS_FILE, 'utf8'));
552
+ }
553
+ catch {
554
+ return {};
555
+ }
556
+ }
557
+ function saveSyncedSessions(synced) {
558
+ mkdirSync(CONFIG_DIR, { recursive: true });
559
+ writeFileSync(SYNCED_SESSIONS_FILE, JSON.stringify(synced));
560
+ }
561
+ /** Upload un-synced local sessions to Clearly cloud. Batched, throttled, crash-safe. */
562
+ export async function syncHistoricalSessions(relay) {
563
+ if (!existsSync(SESSIONS_DIR))
564
+ return { uploaded: 0, skipped: 0, failed: 0 };
565
+ const synced = loadSyncedSessions();
566
+ let files;
567
+ try {
568
+ files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json'));
569
+ }
570
+ catch {
571
+ return { uploaded: 0, skipped: 0, failed: 0 };
572
+ }
573
+ const unsynced = files.filter((f) => !synced[f.replace('.json', '')]);
574
+ if (unsynced.length === 0)
575
+ return { uploaded: 0, skipped: files.length, failed: 0 };
576
+ let uploaded = 0;
577
+ let failed = 0;
578
+ const CONCURRENCY = 2;
579
+ const STAGGER_MS = 500;
580
+ for (let i = 0; i < unsynced.length; i += CONCURRENCY) {
581
+ if (!relay.isConnected())
582
+ break;
583
+ const batch = unsynced.slice(i, i + CONCURRENCY);
584
+ await Promise.all(batch.map(async (f) => {
585
+ const id = f.replace('.json', '');
586
+ const ok = await relay.uploadSession(join(SESSIONS_DIR, f), id);
587
+ if (ok) {
588
+ synced[id] = Date.now();
589
+ uploaded++;
590
+ }
591
+ else {
592
+ failed++;
593
+ }
594
+ }));
595
+ saveSyncedSessions(synced);
596
+ if (i + CONCURRENCY < unsynced.length) {
597
+ await new Promise((r) => setTimeout(r, STAGGER_MS));
598
+ }
599
+ }
600
+ return { uploaded, skipped: files.length - unsynced.length, failed };
601
+ }
602
+ // Event deduplication ring buffer
603
+ const recentEventKeys = new Set();
604
+ const DEDUP_MAX = 200;
605
+ function isDuplicate(event) {
606
+ const key = `${event.session_id}:${event.hook_event_name}:${event.tool_use_id || ''}:${event.timestamp}`;
607
+ if (recentEventKeys.has(key))
608
+ return true;
609
+ recentEventKeys.add(key);
610
+ if (recentEventKeys.size > DEDUP_MAX) {
611
+ const keep = Array.from(recentEventKeys).slice(-DEDUP_MAX + 50);
612
+ recentEventKeys.clear();
613
+ for (const k of keep)
614
+ recentEventKeys.add(k);
615
+ }
616
+ return false;
617
+ }
618
+ // ── Agent Metrics ─────────────────────────────────────────────────────────
619
+ class AgentMetrics {
620
+ toolCalls = 0;
621
+ toolErrors = 0;
622
+ consecutiveErrors = 0;
623
+ lastErrorFile = '';
624
+ filesChanged = new Map(); // path → timestamp
625
+ lastEventType = '';
626
+ lastEventAt = 0;
627
+ activeSessions = new Set();
628
+ windowStart = Date.now();
629
+ /** Process a raw event and update metrics */
630
+ track(event) {
631
+ this.lastEventType = event.hook_event_name;
632
+ this.lastEventAt = Date.now();
633
+ if (event.hook_event_name === 'SessionStart') {
634
+ this.activeSessions.add(event.session_id);
635
+ }
636
+ if (event.hook_event_name === 'SessionEnd') {
637
+ this.activeSessions.delete(event.session_id);
638
+ }
639
+ if (event.hook_event_name === 'PostToolUse') {
640
+ this.toolCalls++;
641
+ this.consecutiveErrors = 0;
642
+ // Track file changes from Edit/Write
643
+ if (event.tool_name === 'Edit' || event.tool_name === 'Write') {
644
+ const filePath = event.tool_input?.file_path;
645
+ if (filePath)
646
+ this.filesChanged.set(filePath, Date.now());
647
+ }
648
+ }
649
+ if (event.hook_event_name === 'PostToolUseFailure') {
650
+ this.toolCalls++;
651
+ this.toolErrors++;
652
+ this.consecutiveErrors++;
653
+ const filePath = event.tool_input?.file_path;
654
+ if (filePath)
655
+ this.lastErrorFile = filePath;
656
+ }
657
+ if (event.hook_event_name === 'PreToolUse') {
658
+ // Track file reads too for velocity
659
+ if (event.tool_name === 'Read' || event.tool_name === 'Glob' || event.tool_name === 'Grep') {
660
+ this.toolCalls++;
661
+ }
662
+ }
663
+ }
664
+ /** Get current metrics snapshot */
665
+ snapshot() {
666
+ const now = Date.now();
667
+ const windowMs = now - this.windowStart;
668
+ const windowMin = Math.max(1, windowMs / 60_000);
669
+ // Prune old file changes (older than 5 min)
670
+ const fiveMinAgo = now - 5 * 60_000;
671
+ for (const [path, ts] of this.filesChanged) {
672
+ if (ts < fiveMinAgo)
673
+ this.filesChanged.delete(path);
674
+ }
675
+ const recentFiles = [...this.filesChanged.keys()];
676
+ return {
677
+ activeSessions: this.activeSessions.size,
678
+ totalToolCalls: this.toolCalls,
679
+ toolCallsPerMinute: +(this.toolCalls / windowMin).toFixed(1),
680
+ errorRate: this.toolCalls > 0 ? +(this.toolErrors / this.toolCalls).toFixed(3) : 0,
681
+ filesChanged: recentFiles.slice(0, 20),
682
+ fileChangeVelocity: +(recentFiles.length / Math.min(5, windowMin)).toFixed(1),
683
+ consecutiveErrors: this.consecutiveErrors,
684
+ loopDetected: this.consecutiveErrors >= 5,
685
+ lastErrorFile: this.lastErrorFile,
686
+ lastEventType: this.lastEventType,
687
+ lastEventAge: this.lastEventAt > 0 ? Math.floor((now - this.lastEventAt) / 1000) : -1,
688
+ };
689
+ }
690
+ /** Reset window (called periodically) */
691
+ resetWindow() {
692
+ this.toolCalls = 0;
693
+ this.toolErrors = 0;
694
+ this.windowStart = Date.now();
695
+ }
696
+ }
697
+ /** Relay ref — set in startDaemon() */
698
+ let daemonRelay = null;
699
+ /** CloudBeeProxy ref — set in startDaemon() */
700
+ let daemonAgent = null;
701
+ /** REPL ref — set in startDaemon() */
702
+ let daemonRepl = null;
703
+ /** Config ref — set in startDaemon() */
704
+ let daemonConfig = null;
705
+ /** LocalAppManager ref — set in startDaemon() */
706
+ let daemonAppManager = null;
707
+ // ── Auto-Update ───────────────────────────────────────────────────────────
708
+ // curl-only distribution — the CLI is NOT published to npm. Updates fetch the
709
+ // versioned tarball from downloads.clearly.sh via the public installer script.
710
+ let announcedManagedUpdates = false;
711
+ const INSTALL_URL = process.env.BEEHAVEN_INSTALL_URL || 'https://clearly.sh/install.sh';
712
+ /**
713
+ * ⚠ THE SAME OVERRIDE ITS SIBLING ALREADY HAD. `min-version.ts` reads `BEEHAVEN_CLI_FEED` so the
714
+ * force-update gate can be exercised against a scratch channel; the auto-updater — the half that
715
+ * DOWNLOADS AND INSTALLS CODE — was hardcoded, so the only way to rehearse it was against the live
716
+ * channel, which is the one thing `docs/FORCE_UPDATE.md` says never to do.
717
+ *
718
+ * ⚠ IT CHANGES NOTHING IN PRODUCTION: unset, this is byte-identical to the literal it replaces.
719
+ * What it buys is that the verify-then-install path can be run end to end with a sandboxed HOME,
720
+ * including the case that matters — a tarball whose digest does not match being REFUSED.
721
+ */
722
+ const CLI_FEED = process.env.BEEHAVEN_CLI_FEED || 'https://downloads.clearly.sh/cli/';
723
+ const LATEST_MANIFEST = `${CLI_FEED.replace(/\/?$/, '/')}latest.json`;
724
+ /**
725
+ * ⚠⚠ THE DOWNLOAD IS PINNED TO THIS BASE AND NEVER TO `manifest.tarball` — see the note at the
726
+ * fetch. That is a security property, and moving the base with `BEEHAVEN_CLI_FEED` does not weaken
727
+ * it: the URL is still built from the VALIDATED version against a base this process was configured
728
+ * with, rather than followed from a remote document. A manifest still cannot redirect the fetch.
729
+ *
730
+ * ⚠ IT MOVES WITH THE MANIFEST, DELIBERATELY. A scratch feed whose tarball still resolved to the
731
+ * production host is not a rehearsal — it is a 404, which is what the first attempt at this
732
+ * measured, and which silently skips the digest check the rehearsal exists to exercise.
733
+ */
734
+ const TARBALL_BASE = CLI_FEED.replace(/\/+$/, '');
735
+ const UPDATE_CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour
736
+ const ROLLBACK_FILE = join(CONFIG_DIR, 'previous-version.json');
737
+ /**
738
+ * This daemon's own version — the left-hand side of the auto-update comparison.
739
+ *
740
+ * TWO CANDIDATE PATHS, because there are two layouts and only one used to be checked:
741
+ * • repo / npm install → `__dirname` is `packages/cli/dist`, so the manifest is `../`.
742
+ * • Mac app bundle → electron-builder FLATTENS `dist/` into `Resources/beehaven/`, so
743
+ * `__dirname` IS that folder and the manifest sits BESIDE the code, not above it.
744
+ *
745
+ * The bundle copies `package.json` in for exactly this reason ("so getVersion() can find it
746
+ * after the bundle flattens dist/") — but the lookup only ever went UP, so the copy was never
747
+ * read and the bundled daemon reported `0.0.0`. Consequences, on every single Mac app launch:
748
+ * it concluded it was years out of date, downloaded the published tarball, overwrote the
749
+ * bundled daemon with it, and exited 75 to restart — a network install per launch, the
750
+ * bundled build silently replaced by whatever is published, and (observed) a window where
751
+ * nothing is listening on the IPC socket at all.
752
+ *
753
+ * `0.0.0` is retained as the last resort, and it still means "assume ancient, update" — that
754
+ * is the right behaviour for a genuinely unidentifiable install. It just must not be reached
755
+ * by a layout we ship.
756
+ */
757
+ function getCurrentVersion() {
758
+ for (const candidate of [join(__dirname, 'package.json'), join(__dirname, '..', 'package.json')]) {
759
+ try {
760
+ const v = JSON.parse(readFileSync(candidate, 'utf8')).version;
761
+ if (v)
762
+ return v;
763
+ }
764
+ catch {
765
+ /* try the next layout */
766
+ }
767
+ }
768
+ return '0.0.0';
769
+ }
770
+ /** Save current version before an auto-update so we can rollback if needed. */
771
+ function savePreUpdateState(version) {
772
+ try {
773
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
774
+ writeFileSync(ROLLBACK_FILE, JSON.stringify({
775
+ version,
776
+ timestamp: Date.now(),
777
+ reason: 'pre-update-backup',
778
+ }));
779
+ }
780
+ catch {
781
+ /* non-critical — update proceeds without rollback safety */
782
+ }
783
+ }
784
+ /** After startup, check if we just upgraded and verify health. */
785
+ function runPostUpdateHealthCheck(relay) {
786
+ if (!existsSync(ROLLBACK_FILE))
787
+ return;
788
+ let prev;
789
+ try {
790
+ prev = JSON.parse(readFileSync(ROLLBACK_FILE, 'utf-8'));
791
+ }
792
+ catch {
793
+ // Corrupt file — clean up
794
+ try {
795
+ unlinkSync(ROLLBACK_FILE);
796
+ }
797
+ catch { }
798
+ return;
799
+ }
800
+ const current = getCurrentVersion();
801
+ if (prev.version === current) {
802
+ // Same version — not a fresh upgrade, nothing to check
803
+ return;
804
+ }
805
+ console.log(` [update] Upgraded ${prev.version} → ${current} — verifying health in 30s...`);
806
+ setTimeout(() => {
807
+ if (!relay.isConnected()) {
808
+ console.error(` [update] New version ${current} failed health check — relay not connected.`);
809
+ console.error(` [update] To rollback: beehaven rollback`);
810
+ }
811
+ else {
812
+ // Update succeeded, clean up rollback file
813
+ try {
814
+ unlinkSync(ROLLBACK_FILE);
815
+ }
816
+ catch { }
817
+ console.log(` [update] Upgraded ${prev.version} → ${current} — healthy`);
818
+ }
819
+ }, 30_000);
820
+ }
821
+ /** Install a specific version (used by rollback command). */
822
+ export function rollbackToVersion(version) {
823
+ const tarball = `${TARBALL_BASE}/beehaven-${version}.tgz`;
824
+ console.log(` [rollback] Installing v${version} from ${tarball}...`);
825
+ try {
826
+ // curl-only distribution (the CLI is NOT on npm): re-run the installer with
827
+ // BEEHAVEN_TARBALL pinned to the target version's tarball.
828
+ execSync(`curl -fsSL "${INSTALL_URL}" | BEEHAVEN_TARBALL="${tarball}" sh`, {
829
+ timeout: 120_000,
830
+ stdio: 'inherit',
831
+ });
832
+ // Clean up rollback file after successful rollback
833
+ try {
834
+ if (existsSync(ROLLBACK_FILE))
835
+ unlinkSync(ROLLBACK_FILE);
836
+ }
837
+ catch { }
838
+ console.log(` [rollback] Rolled back to v${version}.`);
839
+ console.log(` [rollback] Restart the daemon: quit and relaunch the Clearly Dev Mac app.`);
840
+ return true;
841
+ }
842
+ catch (err) {
843
+ console.error(` [rollback] Failed: ${err.message}`);
844
+ return false;
845
+ }
846
+ }
847
+ /** Read saved previous version info (for rollback command). */
848
+ export function getPreviousVersion() {
849
+ if (!existsSync(ROLLBACK_FILE))
850
+ return null;
851
+ try {
852
+ return JSON.parse(readFileSync(ROLLBACK_FILE, 'utf-8'));
853
+ }
854
+ catch {
855
+ return null;
856
+ }
857
+ }
858
+ /**
859
+ * Download the update tarball and prove it is the artifact the manifest
860
+ * describes, BEFORE any of it is executed.
861
+ *
862
+ * Returns the verified local path, or null with a logged reason.
863
+ *
864
+ * Why this exists: this function used to not. `checkAndAutoUpdate` read a
865
+ * version number off a JSON file and immediately ran `curl … | sh` — an
866
+ * unattended, hourly, unverified code-execution path. Whatever the bucket
867
+ * served became code on every user's machine, with no digest, no signature
868
+ * and no prompt.
869
+ */
870
+ async function fetchVerifiedUpdateTarball(manifest) {
871
+ // The version string reaches BOTH a URL and a filesystem path below, and
872
+ // it arrives from a remote JSON document — so it is untrusted input.
873
+ // Unvalidated, a manifest advertising `../../../../.bashrc` would make
874
+ // `join(tmpdir(), …)` escape tmpdir and write wherever it liked, turning
875
+ // "check for updates" into an arbitrary-file-write. Constrain it to the
876
+ // shape a version can actually have before it is used as either.
877
+ const version = String(manifest?.version ?? '');
878
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
879
+ console.error(` [update] Ignoring update: "${version}" is not a valid version string.`);
880
+ return null;
881
+ }
882
+ const expected = usableUpdateDigest(manifest);
883
+ if (!expected) {
884
+ // FAIL CLOSED. Publishing without the digest is a release-process bug
885
+ // (publish-beehaven-cli.sh always writes it). The manual updater also
886
+ // refuses an unverifiable release, so only a corrected feed can repair it.
887
+ console.error(` [update] ${manifest.version} is available but its manifest carries no usable sha256 — refusing to auto-update.`);
888
+ console.error(' [update] The published feed must be repaired before this release can be installed.');
889
+ return null;
890
+ }
891
+ // Pin the download to the expected host rather than following whatever
892
+ // `tarball` says: a manifest that can redirect the fetch anywhere is a
893
+ // second injection point for the same attack.
894
+ // Both built from the VALIDATED `version`, never the raw manifest field.
895
+ const url = `${TARBALL_BASE}/beehaven-${version}.tgz`;
896
+ const tempDir = mkdtempSync(join(tmpdir(), 'beehaven-update-'));
897
+ const dest = join(tempDir, `beehaven-${version}.tgz`);
898
+ let verified = false;
899
+ try {
900
+ const controller = new AbortController();
901
+ const t = setTimeout(() => controller.abort(), 120_000);
902
+ let res;
903
+ try {
904
+ res = await fetch(url, { signal: controller.signal });
905
+ }
906
+ finally {
907
+ clearTimeout(t);
908
+ }
909
+ if (!res.ok) {
910
+ console.error(` [update] Download failed: ${res.status} ${url}`);
911
+ return null;
912
+ }
913
+ const bytes = Buffer.from(await res.arrayBuffer());
914
+ const actual = createHash('sha256').update(bytes).digest('hex');
915
+ if (actual !== expected) {
916
+ // Do not retry, do not fall back — a digest mismatch is either
917
+ // corruption or tampering, and both mean "don't run this".
918
+ console.error(` [update] REFUSING ${version}: sha256 mismatch (expected ${expected}, got ${actual}).`);
919
+ return null;
920
+ }
921
+ writeFileSync(dest, bytes, { mode: 0o600 });
922
+ verified = true;
923
+ return dest;
924
+ }
925
+ catch (err) {
926
+ console.error(` [update] Download/verify failed: ${err?.message ?? err}`);
927
+ return null;
928
+ }
929
+ finally {
930
+ if (!verified) {
931
+ try {
932
+ rmSync(tempDir, { recursive: true, force: true });
933
+ }
934
+ catch { }
935
+ }
936
+ }
937
+ }
938
+ /**
939
+ * Is this daemon's version owned by something else?
940
+ *
941
+ * ⚠⚠ SET BY THE MAC APP WHEN IT SPAWNS THE BUNDLED DAEMON. That daemon lives inside a signed
942
+ * `.app` and is shipped with it, so its version is the app's to decide — and self-updating it is
943
+ * not merely redundant, it is destructive: `install.sh` overwrites `~/.local/bin/beehaven` with a
944
+ * shim that carries no ownership header, and `cli-installer` then (correctly) refuses to take the
945
+ * path back. The app's own CLI ends up permanently unreachable from the shell while the app keeps
946
+ * respawning the bundled daemon from `Resources`, so the "update" never even takes effect.
947
+ *
948
+ * ⚠ THE VALUE IS IGNORED — presence is the signal. Anything that spawns a daemon it intends to
949
+ * keep at a fixed version says so by setting this; there is no registry of legal owners to keep
950
+ * in step.
951
+ */
952
+ function updatesAreManagedExternally() {
953
+ const owner = (process.env.BEEHAVEN_MANAGED_BY ?? '').trim();
954
+ return owner ? owner : null;
955
+ }
956
+ /**
957
+ * ⚠ EXPORTED FOR THE SAME REASON `refreshMinVersion` IS: the update path downloads and installs
958
+ * code, and the only way to rehearse it was to run a whole daemon against the LIVE channel — which
959
+ * is the one thing the force-update doc says never to do. Exported, it can be driven with
960
+ * `BEEHAVEN_CLI_FEED` pointed at a scratch feed and `HOME` sandboxed, so the case that matters
961
+ * (a tarball whose digest does not match is REFUSED) is actually exercised rather than assumed.
962
+ *
963
+ * ⚠ NOTHING ELSE CALLS IT. The daemon's timer is still the only production caller.
964
+ */
965
+ export async function checkAndAutoUpdate() {
966
+ const owner = updatesAreManagedExternally();
967
+ const runningKind = installKind(join(__dirname, 'daemon-entry.js'));
968
+ if (owner || runningKind !== 'user') {
969
+ // Said once at startup, not hourly — a line every hour about a thing that is working as
970
+ // designed is how a log stops being read.
971
+ if (!announcedManagedUpdates) {
972
+ announcedManagedUpdates = true;
973
+ console.log(` [update] skipped — this daemon is ${owner ? `managed by ${owner}` : `a ${runningKind} build`}`);
974
+ }
975
+ return;
976
+ }
977
+ let verifiedTarball = null;
978
+ let installerPath = null;
979
+ try {
980
+ const controller = new AbortController();
981
+ const timeout = setTimeout(() => controller.abort(), 5000);
982
+ const res = await fetch(LATEST_MANIFEST, {
983
+ signal: controller.signal,
984
+ });
985
+ clearTimeout(timeout);
986
+ if (!res.ok)
987
+ return;
988
+ const data = await res.json();
989
+ const latest = data.version;
990
+ if (!latest)
991
+ return;
992
+ const current = getCurrentVersion();
993
+ if (compareVersions(latest, current) <= 0)
994
+ return;
995
+ // Verify BEFORE executing anything. Returns null (and explains) when
996
+ // the artifact can't be proven, in which case we do not update.
997
+ verifiedTarball = await fetchVerifiedUpdateTarball(data);
998
+ if (!verifiedTarball)
999
+ return;
1000
+ // Save current version for rollback before updating
1001
+ savePreUpdateState(current);
1002
+ console.log(` [update] Updating ${current} → ${latest} (sha256 verified)...`);
1003
+ // Download before execution. In `curl | sh`, a failed curl still leaves sh exiting 0;
1004
+ // the daemon would announce an update and exit while still running the old install.
1005
+ installerPath = join(dirname(verifiedTarball), 'install.sh');
1006
+ await new Promise((resolve, reject) => {
1007
+ execFile('curl', ['-fsSL', '--max-time', '30', '-o', installerPath, INSTALL_URL], { timeout: 35_000 }, (err) => err ? reject(err) : resolve());
1008
+ });
1009
+ await new Promise((resolve, reject) => {
1010
+ execFile('/bin/sh', [installerPath], {
1011
+ timeout: 120_000,
1012
+ env: { ...process.env, BEEHAVEN_TARBALL: `file://${verifiedTarball}` },
1013
+ }, (err) => err ? reject(err) : resolve());
1014
+ });
1015
+ if (getCurrentVersion() !== latest) {
1016
+ throw new Error(`installer did not install ${latest}`);
1017
+ }
1018
+ console.log(` [update] Updated to v${latest} — restarting...`);
1019
+ if (process.env.BEEHAVEN_CLI_OWNED_DAEMON === '1') {
1020
+ // A CLI-spawned headless daemon is detached, with no supervisor to interpret exit 75.
1021
+ // Start the new entry in a helper that waits until this process releases the IPC socket.
1022
+ const child = spawn(process.execPath, [join(__dirname, 'daemon-entry.js'), '--restart-after-update', String(process.pid)], {
1023
+ detached: true,
1024
+ stdio: 'ignore',
1025
+ env: process.env,
1026
+ });
1027
+ child.on('error', (error) => console.error(` [update] Restart helper failed: ${error.message}`));
1028
+ if (!child.pid)
1029
+ throw new Error('could not start update restart helper');
1030
+ child.unref();
1031
+ }
1032
+ // Drop the temp tarball BEFORE exiting. `process.exit` skips `finally`,
1033
+ // so cleaning up only there would leak the whole release into /tmp on every
1034
+ // SUCCESSFUL update — the one path that always runs.
1035
+ // ⚠ That used to read "~500 KB" and is now ~5 MB: the published tarball carries its own
1036
+ // node_modules (scripts/vendor-cli-deps.mjs), so the installer runs no npm install. Net
1037
+ // this makes the 120s timeout above SAFER, not tighter — a 4 MB download replaces a
1038
+ // registry resolution of ~105 packages, which is the step that could actually blow it.
1039
+ cleanupVerifiedTarball();
1040
+ if (installerPath) {
1041
+ try {
1042
+ if (existsSync(installerPath))
1043
+ unlinkSync(installerPath);
1044
+ }
1045
+ catch { }
1046
+ installerPath = null;
1047
+ }
1048
+ // Give relay a moment to flush
1049
+ if (daemonRelay?.isConnected()) {
1050
+ await new Promise((r) => setTimeout(r, 500));
1051
+ }
1052
+ // Exit with non-zero so launchd/systemd restarts with the new version
1053
+ // (KeepAlive > SuccessfulExit > false means exit 0 stays down)
1054
+ process.exit(75);
1055
+ }
1056
+ catch (error) {
1057
+ console.error(` [update] Update failed: ${error?.message ?? error}`);
1058
+ }
1059
+ finally {
1060
+ // Covers every failure path (download ok, install threw, etc).
1061
+ cleanupVerifiedTarball();
1062
+ if (installerPath) {
1063
+ try {
1064
+ if (existsSync(installerPath))
1065
+ unlinkSync(installerPath);
1066
+ }
1067
+ catch { }
1068
+ }
1069
+ }
1070
+ function cleanupVerifiedTarball() {
1071
+ if (!verifiedTarball)
1072
+ return;
1073
+ try {
1074
+ rmSync(dirname(verifiedTarball), { recursive: true, force: true });
1075
+ }
1076
+ catch { }
1077
+ verifiedTarball = null;
1078
+ }
1079
+ }
1080
+ /**
1081
+ * Check if a PID is actually a beehaven daemon (not a recycled PID, and not
1082
+ * some other process whose arguments merely CONTAIN the beehaven path as
1083
+ * a substring — e.g. a shell running a heredoc whose command ends up
1084
+ * including `node .../beehaven/cli.js start`).
1085
+ *
1086
+ * The test requires:
1087
+ * 1. The process's argv[0] is a node/tsx/beehaven binary — not a shell,
1088
+ * an editor, a grep, etc.
1089
+ * 2. A subsequent argument is a beehaven cli script (cli.ts or cli.js
1090
+ * under a beehaven* directory OR under a Clearly*.app bundle).
1091
+ *
1092
+ * Matching both conditions catches every known launch shape:
1093
+ * - monorepo src: node|tsx .../beehaven(_sdk)?/.../cli.ts
1094
+ * - monorepo dist: node .../beehaven(_sdk)?/.../cli.js
1095
+ * - packaged Electron: node .../Clearly*.app/Contents/Resources/beehaven/cli.js
1096
+ * - global wrapper: .../bin/beehaven start (argv[0] ends with /beehaven)
1097
+ */
1098
+ function isBeehavenProcess(pid) {
1099
+ try {
1100
+ let cmd;
1101
+ if (process.platform === 'darwin') {
1102
+ cmd = execSync(`ps -p ${pid} -o command=`, {
1103
+ encoding: 'utf8',
1104
+ stdio: ['pipe', 'pipe', 'pipe'],
1105
+ timeout: 3000,
1106
+ }).trim();
1107
+ }
1108
+ else {
1109
+ // Linux: /proc/PID/cmdline uses NULs; normalize to spaces
1110
+ cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
1111
+ }
1112
+ if (!cmd)
1113
+ return false;
1114
+ // Condition 1: argv[0] must be a node/tsx/beehaven-wrapper binary.
1115
+ // Anchoring with ^ so a shell running a heredoc that contains
1116
+ // "node .../cli.js" deeper in its argv doesn't false-positive.
1117
+ const argv0Ok = /^(\S*\/)?node(\s|$)/.test(cmd) ||
1118
+ /^(\S*\/)?tsx(\s|$)/.test(cmd) ||
1119
+ /^(\S*\/)?beehaven(\s|$)/.test(cmd);
1120
+ if (!argv0Ok)
1121
+ return false;
1122
+ // Condition 2: a subsequent argument must reference a beehaven script.
1123
+ //
1124
+ // NOTE THE `\.?` — the directory may be `beehaven/` OR the DOTFILE
1125
+ // `.beehaven/`. `public/install.sh` installs to `~/.beehaven/cli/`, which is
1126
+ // the canonical location for every `curl … | sh` user, and these patterns
1127
+ // used to require a path segment of exactly "beehaven". So the CLI could not
1128
+ // recognise its OWN standard install: `stop` reported "No daemon running"
1129
+ // while one was running, `restart` printed a fresh PID whose process then
1130
+ // died with "Refusing to start — IPC socket already serving connections",
1131
+ // and the real daemon was effectively unkillable from the CLI. Only
1132
+ // monorepo (`beehaven_sdk/`) and Mac-app installs were ever detected.
1133
+ const scriptOk =
1134
+ // Dedicated daemon entry — what the Mac app tray spawns.
1135
+ /[/\\]\.?beehaven(_sdk)?[/\\].*daemon-entry\.(js|ts)\b/.test(cmd) ||
1136
+ /Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]daemon-entry\.js\b/.test(cmd) ||
1137
+ // Legacy `cli.js start` — for old bundles pre-dating daemon-entry.js.
1138
+ /[/\\]\.?beehaven(_sdk)?[/\\].*cli\.(js|ts)\b/.test(cmd) ||
1139
+ /Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]cli\.js\b/.test(cmd) ||
1140
+ // Bare `beehaven start` — wrapper already matched argv[0].
1141
+ /^(\S*\/)?beehaven\s+start\b/.test(cmd);
1142
+ return scriptOk;
1143
+ }
1144
+ catch {
1145
+ return false; // Can't read → not our process or already dead
1146
+ }
1147
+ }
1148
+ /**
1149
+ * Find any running beehaven daemon process on this machine, excluding the
1150
+ * current process. Returns PIDs discovered via pgrep, filtered by
1151
+ * isBeehavenProcess for safety.
1152
+ */
1153
+ export function findLiveDaemons() {
1154
+ const found = new Set();
1155
+ // 1. Check PID file first — the canonical source.
1156
+ if (existsSync(PID_FILE)) {
1157
+ try {
1158
+ const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
1159
+ if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) {
1160
+ try {
1161
+ process.kill(pid, 0);
1162
+ if (isBeehavenProcess(pid))
1163
+ found.add(pid);
1164
+ }
1165
+ catch {
1166
+ /* dead */
1167
+ }
1168
+ }
1169
+ }
1170
+ catch { }
1171
+ }
1172
+ // 2. pgrep — catches orphans the PID file doesn't track.
1173
+ // Patterns mirror isBeehavenProcess() above. Each candidate is
1174
+ // re-validated by isBeehavenProcess so a broad pgrep hit still has
1175
+ // to pass the strict regex check before being considered ours.
1176
+ const patterns = [
1177
+ 'beehaven.*daemon-entry\\.(js|ts)',
1178
+ 'beehaven.*cli\\.(js|ts)',
1179
+ 'Clearly.*Resources/beehaven',
1180
+ ];
1181
+ for (const pattern of patterns) {
1182
+ try {
1183
+ const out = execSync(`pgrep -f "${pattern}"`, {
1184
+ encoding: 'utf8',
1185
+ stdio: ['pipe', 'pipe', 'pipe'],
1186
+ }).trim();
1187
+ for (const line of out.split('\n')) {
1188
+ const pid = parseInt(line, 10);
1189
+ if (Number.isFinite(pid) && pid !== process.pid && isBeehavenProcess(pid)) {
1190
+ found.add(pid);
1191
+ }
1192
+ }
1193
+ }
1194
+ catch {
1195
+ /* pgrep returns non-zero if no matches */
1196
+ }
1197
+ }
1198
+ return [...found];
1199
+ }
1200
+ /** Wait for a PID to exit, with SIGKILL escalation. Returns true if process exited. */
1201
+ function waitForExit(pid, timeoutMs) {
1202
+ const start = Date.now();
1203
+ while (Date.now() - start < timeoutMs) {
1204
+ try {
1205
+ process.kill(pid, 0); // Still alive
1206
+ const end = Date.now() + 100;
1207
+ while (Date.now() < end) {
1208
+ /* spin */
1209
+ }
1210
+ }
1211
+ catch {
1212
+ return true; // Process exited
1213
+ }
1214
+ }
1215
+ return false; // Still alive after timeout
1216
+ }
1217
+ /**
1218
+ * Stop all beehaven daemon processes — PID file + any orphans.
1219
+ *
1220
+ * ⚠⚠ `quiet` EXISTS BECAUSE THIS FUNCTION'S NARRATION LEAKED INTO `login`'s. `beehaven stop` is a
1221
+ * command ABOUT stopping, so "Stopping daemon (PID …)" / "Daemon stopped." is the answer there.
1222
+ * Inside `login` the restart is an IMPLEMENTATION DETAIL of picking up new credentials, and these
1223
+ * lines landed mid-sentence in an unterminated `Restarting daemon… ` prompt:
1224
+ *
1225
+ * Restarting daemon… Stopping daemon (PID 72928)...
1226
+ * Daemon stopped.
1227
+ * ✓ (PID 73532)
1228
+ *
1229
+ * — three lines of machinery and a tick attached to nothing, immediately after a sign-in.
1230
+ *
1231
+ * ⚠ A FAILURE IS STILL LOUD. `quiet` suppresses the ROUTINE narration only; a force-kill still
1232
+ * prints, because a daemon that would not die is something the caller must hear about whatever it
1233
+ * happened to be doing at the time.
1234
+ */
1235
+ export function stopDaemonProcess(opts = {}) {
1236
+ const say = opts.quiet ? (_m) => { } : (m) => console.log(m);
1237
+ let killed = false;
1238
+ // findLiveDaemons() walks both the PID file and pgrep patterns, and
1239
+ // filters via isBeehavenProcess so recycled PIDs never get signaled.
1240
+ const pidsToKill = findLiveDaemons();
1241
+ // Always clear the PID file regardless of what we found — next start
1242
+ // will write a fresh one.
1243
+ if (existsSync(PID_FILE)) {
1244
+ try {
1245
+ unlinkSync(PID_FILE);
1246
+ }
1247
+ catch { }
1248
+ }
1249
+ if (pidsToKill.length === 0) {
1250
+ say(' No daemon running.');
1251
+ return false;
1252
+ }
1253
+ // 3. Send SIGTERM to all
1254
+ for (const pid of pidsToKill) {
1255
+ try {
1256
+ process.kill(pid, 'SIGTERM');
1257
+ say(` Stopping daemon (PID ${pid})…`);
1258
+ killed = true;
1259
+ }
1260
+ catch { }
1261
+ }
1262
+ // 4. Wait up to 3s for graceful exit, then SIGKILL stragglers
1263
+ for (const pid of pidsToKill) {
1264
+ if (!waitForExit(pid, 3000)) {
1265
+ try {
1266
+ process.kill(pid, 'SIGKILL');
1267
+ console.log(` Force-killed daemon (PID ${pid}) — did not exit after SIGTERM.`);
1268
+ }
1269
+ catch { }
1270
+ waitForExit(pid, 1000); // Brief wait for SIGKILL to take effect
1271
+ }
1272
+ }
1273
+ say(' Daemon stopped.');
1274
+ return killed;
1275
+ }
1276
+ export async function startDaemon(opts) {
1277
+ // ── Single-instance enforcement ─────────────────────────────────────
1278
+ // At most one beehaven daemon per machine. This refuses to start if a
1279
+ // live daemon is already running; the CLI's `start` command calls
1280
+ // stopDaemonProcess() first to do a clean replacement, so user-facing
1281
+ // `beehaven start` still "just works" — this guard only fires when the
1282
+ // daemon is invoked directly (launchd race, double-spawn, etc).
1283
+ const existingDaemons = findLiveDaemons();
1284
+ if (existingDaemons.length > 0) {
1285
+ const existingList = existingDaemons.join(', ');
1286
+ console.error(`\n ✗ Another beehaven daemon is already running (PID ${existingList}).`);
1287
+ console.error(` ✗ Quit the Clearly Dev Mac app tray to stop the existing daemon, then restart.\n`);
1288
+ try {
1289
+ logger.error('daemon', `Refusing to start — daemon already running at PID ${existingList}`);
1290
+ }
1291
+ catch { }
1292
+ process.exit(1);
1293
+ }
1294
+ // Defense-in-depth: even if no PID was found, probe the IPC socket. A
1295
+ // live server answering here means some daemon is alive that findLiveDaemons
1296
+ // missed (e.g. different binary path, permissions edge case).
1297
+ if (await probeIpcSocket()) {
1298
+ console.error(`\n ✗ Another beehaven daemon is listening on /tmp/beehaven-ipc.sock.`);
1299
+ console.error(` ✗ Quit the Clearly Dev Mac app tray to stop the existing daemon, then restart.\n`);
1300
+ try {
1301
+ logger.error('daemon', 'Refusing to start — IPC socket already serving connections');
1302
+ }
1303
+ catch { }
1304
+ process.exit(1);
1305
+ }
1306
+ // Require login — Beehaven needs a Clearly account to relay.
1307
+ // When started by Electron (BEEHAVEN_NO_REPL=1), skip interactive login —
1308
+ // Electron will provision the token via provisionDaemon() after sign-in.
1309
+ let config = loadConfig();
1310
+ // Auto-migrate: move plaintext token to OS keychain if available
1311
+ if (config.token && keychain.available && config.tokenStorage !== 'keychain') {
1312
+ if (keychain.setToken(config.token)) {
1313
+ config.tokenStorage = 'keychain';
1314
+ delete config.token;
1315
+ saveConfig({ tokenStorage: 'keychain', token: undefined });
1316
+ logger.info('daemon', 'Migrated token from config file to OS keychain');
1317
+ }
1318
+ }
1319
+ // Resolve token: keychain or plaintext
1320
+ const hasToken = config.tokenStorage === 'keychain' ? !!keychain.getToken() : !!config.token;
1321
+ if (!hasToken && !process.env.BEEHAVEN_NO_REPL) {
1322
+ const { login } = await import('./setup-relay.js');
1323
+ const loggedIn = await login();
1324
+ if (!loggedIn) {
1325
+ process.exit(1);
1326
+ }
1327
+ config = loadConfig();
1328
+ }
1329
+ // Initialize core components
1330
+ const watcher = new ClaudeWatcher();
1331
+ const relay = new Relay();
1332
+ const agent = new CloudBeeProxy(relay);
1333
+ const metrics = new AgentMetrics();
1334
+ // Live workspace sessions for CLI-driven AgentIdentities (`agent:connect`).
1335
+ const presenceScope = `${String(config.userId ?? config.user?.uid ?? config.user?.userId ?? 'local')}|${relay.wsBase()}`;
1336
+ const agentSessions = new AgentSessions(() => relay.wsBase(), presenceScope);
1337
+ /**
1338
+ * Keep the desktop presence lease coupled to real attributed work. A daemon restart can leave
1339
+ * the in-memory AgentSessions map empty while the signed identity and workspace session remain
1340
+ * valid; `touch()` alone intentionally no-ops in that state. Reattach from the verified token
1341
+ * (or the local identity record) before touching so the first post-restart action starts the
1342
+ * 30-minute lease from the actual interaction time.
1343
+ */
1344
+ const ensureAgentPresence = (agentId, token = '') => {
1345
+ const id = String(agentId || '').trim();
1346
+ if (!id)
1347
+ return;
1348
+ let sessionToken = String(token || '').trim();
1349
+ if (!sessionToken) {
1350
+ sessionToken = String(listAgents().find((entry) => entry.record.agentId === id)?.record.token || '').trim();
1351
+ }
1352
+ if (!sessionToken)
1353
+ return;
1354
+ agentSessions.ensureConnected(id, sessionToken);
1355
+ agentSessions.touch(id);
1356
+ };
1357
+ const currentVersion = getCurrentVersion();
1358
+ const health = new HealthMonitor(() => relay.isConnected(), () => relay.pendingRequests.size, currentVersion);
1359
+ // Configure log level from config
1360
+ if (config.logLevel)
1361
+ setLogLevel(config.logLevel);
1362
+ logger.info('daemon', `Starting beehaven v${currentVersion} (pid ${process.pid})`);
1363
+ // #105 — rehydrate stored Claude credential on startup. Without this,
1364
+ // users have to re-connect their Anthropic API key every daemon
1365
+ // restart (every Mac app relaunch). For api-key creds, we surface the
1366
+ // secret to process.env.ANTHROPIC_API_KEY so the Agent SDK picks it
1367
+ // up automatically on the next query() call. For plan-OAuth creds,
1368
+ // the Claude CLI manages ~/.claude/.credentials.json directly and
1369
+ // the SDK reads from there — nothing to rehydrate. Failures are
1370
+ // silent + non-fatal: daemon should still start fine without a
1371
+ // Claude credential (user can connect later via the onboarding gate).
1372
+ //
1373
+ // #111 — also remember the rehydrate metadata so the relay.onConnected
1374
+ // handler (assigned further down) can fire a `canvas-credential-rehydrated`
1375
+ // message to the worker the moment the WS opens. This unhides the
1376
+ // Send button on the web UI immediately after a daemon restart
1377
+ // instead of waiting up to 8s for the next claude-credential-status poll.
1378
+ let rehydratedCredential = null;
1379
+ // Claude credential rehydration removed with the daemon Claude Code integration
1380
+ // (destroy-the-treehouse). `rehydratedCredential` stays null.
1381
+ // Set module-level refs
1382
+ daemonRelay = relay;
1383
+ daemonAgent = agent;
1384
+ daemonConfig = config;
1385
+ // ── Tool-gate tier enforcement ──────────────────────────────────────
1386
+ // `setTierResolver` was exported and called by NOBODY, so the
1387
+ // `tier-restrict` policy resolved its stub ('pro') on every tool call
1388
+ // and every user — free included — got Write/Edit/Bash/KillShell. This
1389
+ // is the wiring that makes the gate mean something.
1390
+ //
1391
+ // Source of truth is the subscription plan the relay reports in its
1392
+ // `welcome`/`heartbeat-ack` frames (worker-side `X-Auth-Tier` →
1393
+ // subscriptionPlan). Persisted under its OWN key: `config.tier` is a
1394
+ // different vocabulary entirely (BeeHavenTier = local|connected|team,
1395
+ // a CONNECTION state) and is why `beehaven status` once printed
1396
+ // "Tier: connected". Two meanings in one field is what created that;
1397
+ // don't reuse it.
1398
+ try {
1399
+ const { setTierResolver, planToPolicyTier } = await import('./daemon-omni-policies.js');
1400
+ // The resolver runs on EVERY tool call, so it must not touch the disk
1401
+ // each time — a tool-heavy agent turn would otherwise issue a
1402
+ // synchronous read+parse of config.json per call. Seed the last-known
1403
+ // plan once at startup, then keep it in memory and only WRITE when it
1404
+ // actually changes.
1405
+ let knownPlan = String(loadConfig().productPlan ?? '');
1406
+ setTierResolver(() => {
1407
+ // Connected ⇒ the live plan is authoritative; persist only on change.
1408
+ if (relay.isConnected?.()) {
1409
+ const live = relay.getTier?.();
1410
+ if (live) {
1411
+ if (live !== knownPlan) {
1412
+ knownPlan = live;
1413
+ try {
1414
+ saveConfig({ productPlan: live });
1415
+ }
1416
+ catch {
1417
+ /* cache write is best-effort — never block a tool call */
1418
+ }
1419
+ }
1420
+ return planToPolicyTier(live);
1421
+ }
1422
+ }
1423
+ // Offline ⇒ last plan we actually saw, so a paying user working on
1424
+ // a plane keeps the tools they bought. Never seen one ⇒ 'free':
1425
+ // we enforce what we can prove, and absence of evidence is not
1426
+ // evidence of a subscription.
1427
+ return planToPolicyTier(knownPlan);
1428
+ });
1429
+ }
1430
+ catch (err) {
1431
+ logger.warn('daemon', `tier resolver not installed: ${err?.message ?? err}`);
1432
+ }
1433
+ // Write PID file for `beehaven stop`
1434
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
1435
+ writeFileSync(PID_FILE, String(process.pid));
1436
+ chmodSync(PID_FILE, 0o600);
1437
+ // Write the running daemon's version to ~/.beehaven/daemon.version
1438
+ // so the Mac app (or any orchestrator) can compare the bundled CLI
1439
+ // version against what's actually live and prompt a restart when
1440
+ // they drift. Updated on every startup; removed on graceful shutdown.
1441
+ const VERSION_FILE = join(CONFIG_DIR, 'daemon.version');
1442
+ try {
1443
+ writeFileSync(VERSION_FILE, currentVersion, { mode: 0o600 });
1444
+ }
1445
+ catch (err) {
1446
+ logger.warn('daemon', `Could not write version file: ${err.message}`);
1447
+ }
1448
+ // Graceful shutdown
1449
+ let shuttingDown = false;
1450
+ let repl;
1451
+ let dashboard = null;
1452
+ const shutdown = (signal) => {
1453
+ if (shuttingDown) {
1454
+ process.exit(1);
1455
+ }
1456
+ shuttingDown = true;
1457
+ try {
1458
+ repl?.stop();
1459
+ }
1460
+ catch { }
1461
+ logger.info('daemon', `${signal} — shutting down`);
1462
+ console.log(`\n [daemon] ${signal} — shutting down...`);
1463
+ try {
1464
+ health.stop();
1465
+ }
1466
+ catch { }
1467
+ try {
1468
+ agent.stop();
1469
+ }
1470
+ catch { }
1471
+ // The local agent presence projection must be cleared before the daemon exits. Without this,
1472
+ // the Mac overlay keeps rendering identities from a dead daemon until the next launch or the
1473
+ // 30-minute idle timeout, which makes an explicit daemon quit look like a live session.
1474
+ try {
1475
+ // A daemon replacement is not an agent logout. Keep the durable interaction clock so the
1476
+ // next process can restore the 30-minute lease; an explicit agent:disconnect still clears it.
1477
+ agentSessions.disconnectAll(true);
1478
+ }
1479
+ catch { }
1480
+ try {
1481
+ if (daemonAppManager?.running)
1482
+ daemonAppManager.stop();
1483
+ }
1484
+ catch { }
1485
+ try {
1486
+ watcher.stop();
1487
+ }
1488
+ catch { }
1489
+ try {
1490
+ relay.stop();
1491
+ }
1492
+ catch { }
1493
+ try {
1494
+ dashboard?.stop();
1495
+ }
1496
+ catch { }
1497
+ try {
1498
+ if (existsSync(PID_FILE))
1499
+ unlinkSync(PID_FILE);
1500
+ }
1501
+ catch { }
1502
+ try {
1503
+ const VERSION_FILE = join(CONFIG_DIR, 'daemon.version');
1504
+ if (existsSync(VERSION_FILE))
1505
+ unlinkSync(VERSION_FILE);
1506
+ }
1507
+ catch { }
1508
+ // Clean shutdown also clears the tray-readable status file so the
1509
+ // next launch isn't judged by a stale snapshot.
1510
+ try {
1511
+ const STATUS_FILE = join(CONFIG_DIR, 'status.json');
1512
+ if (existsSync(STATUS_FILE))
1513
+ unlinkSync(STATUS_FILE);
1514
+ }
1515
+ catch { }
1516
+ // Scrub the IPC auth token on shutdown. A stale file on disk
1517
+ // after crash is mostly harmless (new daemon mints a fresh token)
1518
+ // but clearing it tightens the window in which an attacker could
1519
+ // pair a captured token with a live socket.
1520
+ try {
1521
+ // ⚠ ONLY OURS — see `clearIpcAuthToken`. Passing nothing here is what deleted a newer
1522
+ // daemon's token during the restart `daemon.install()` performs on every sign-in.
1523
+ clearIpcAuthToken(mintedIpcTokens);
1524
+ }
1525
+ catch { }
1526
+ // Terminate every Supervised subprocess (vtracer / sam3 server /
1527
+ // ffmpeg / flux / whisper / claude / codex) before we exit. Without
1528
+ // this, the OS reparents descendants to launchd → orphan processes
1529
+ // accumulate across daemon restarts. Each Supervised has its own
1530
+ // SIGTERM → SIGKILL ladder; killAll caps total wait at 2 s.
1531
+ //
1532
+ // Dynamic import: @clearly-sh/beehaven-core is an *optional* peer
1533
+ // dep on the cli package. Pre-migration daemons (no core in their
1534
+ // node_modules) skip this block; migrated daemons get orphan-free
1535
+ // shutdown automatically.
1536
+ (async () => {
1537
+ try {
1538
+ const mod = await import('@clearly-sh/beehaven-core/server');
1539
+ if (mod && typeof mod.SupervisedRegistry?.killAll === 'function') {
1540
+ await mod.SupervisedRegistry.killAll(2000);
1541
+ }
1542
+ }
1543
+ catch (e) {
1544
+ logger.warn('daemon', `SupervisedRegistry.killAll skipped: ${e?.message ?? e}`);
1545
+ }
1546
+ })().finally(() => {
1547
+ setTimeout(() => process.exit(0), 500);
1548
+ });
1549
+ };
1550
+ process.on('SIGINT', () => shutdown('SIGINT'));
1551
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1552
+ // ── Don't die of someone else's rejection ───────────────────────────
1553
+ //
1554
+ // The daemon had NO handler for either of these, so Node's default applied:
1555
+ // one stray rejection anywhere in the process exits it — taking the relay,
1556
+ // every loaded hive, the avatar overlay's backend and any in-flight work
1557
+ // with it. That is the wrong trade for a host process. A single request
1558
+ // failing is a request failing; the host surviving it is the whole point of
1559
+ // being a host.
1560
+ //
1561
+ // Found the hard way: cancelling an in-flight daemon-claude turn made the
1562
+ // Agent SDK's own disposal reject from inside an async generator's `finally`
1563
+ // (`Query closed before response received`) — a place no caller can catch,
1564
+ // because by then nobody is awaiting it. The daemon died on a supported
1565
+ // user action.
1566
+ //
1567
+ // Logged LOUDLY rather than swallowed: this is still a bug every time it
1568
+ // fires, and a silent guard would turn crashes into mysteries.
1569
+ process.on('unhandledRejection', (reason) => {
1570
+ const msg = reason?.stack ?? reason?.message ?? String(reason);
1571
+ console.error(`[daemon] UNHANDLED REJECTION (surviving):\n${String(msg).slice(0, 2000)}`);
1572
+ });
1573
+ process.on('uncaughtException', (err) => {
1574
+ console.error(`[daemon] UNCAUGHT EXCEPTION (surviving):\n${String(err?.stack ?? err).slice(0, 2000)}`);
1575
+ });
1576
+ // Interactive REPL
1577
+ repl = new DaemonRepl(relay, agent, () => shutdown('quit'));
1578
+ daemonRepl = repl;
1579
+ // Cloud bee state → REPL
1580
+ relay.onBeeStatus = (msg) => {
1581
+ agent.updateFromCloud(msg);
1582
+ repl.scheduleRedraw();
1583
+ };
1584
+ relay.onBeeThought = (thought) => {
1585
+ repl.logBeeThought(thought);
1586
+ };
1587
+ agent.on('thought', (thought) => {
1588
+ repl.logBeeThought(thought);
1589
+ });
1590
+ // Profile updates → save to config (including settings flags) + notify REPL
1591
+ relay.onProfileUpdate = (profile) => {
1592
+ const update = { user: profile };
1593
+ // Apply settings flags from cloud profile if present
1594
+ if ('shareTerminal' in profile)
1595
+ update.shareTerminal = !!profile.shareTerminal;
1596
+ if ('shareTelemetry' in profile)
1597
+ update.shareTelemetry = !!profile.shareTelemetry;
1598
+ saveConfig(update);
1599
+ // Update live config ref so flags take effect immediately
1600
+ if (daemonConfig)
1601
+ Object.assign(daemonConfig, update);
1602
+ repl.logProfileUpdate(profile);
1603
+ };
1604
+ // Handle file browse requests from the web UI
1605
+ relay.onBrowseFiles = (path, rid) => {
1606
+ const result = browsePath(path);
1607
+ relay.sendBrowseFilesResult(path, result.entries, rid, result.error);
1608
+ };
1609
+ // ── Transcript tailer — streams full Claude Code conversations ──
1610
+ const transcriptTailer = new TranscriptTailer();
1611
+ // Stream transcript entries to DO and IPC clients
1612
+ transcriptTailer.on('entry', (entry) => {
1613
+ // Send to DO for persistent storage
1614
+ if (relay.isConnected()) {
1615
+ relay.wsSend({
1616
+ type: 'transcript-entry',
1617
+ sessionId: entry.sessionId,
1618
+ entryType: entry.type,
1619
+ role: entry.role,
1620
+ content: entry.content?.slice(0, 5000), // Cap content size
1621
+ toolName: entry.toolName,
1622
+ uuid: entry.uuid,
1623
+ timestamp: entry.timestamp,
1624
+ });
1625
+ }
1626
+ // Forward to IPC clients (Electron avatar, office viz)
1627
+ ipc.broadcast({
1628
+ type: 'transcript-entry',
1629
+ ...entry,
1630
+ raw: undefined, // Don't send raw JSONL to IPC clients
1631
+ });
1632
+ });
1633
+ // Auto-tunnel feature removed: it watched Bash output for
1634
+ // localhost:PORT and silently exposed any dev server to the public
1635
+ // internet via *.trycloudflare.com. Even with a random URL, that's
1636
+ // a major scope leak — anyone who learns the URL (DO broadcast
1637
+ // recipients, log scrapers, IPC subscribers) can hit the dev
1638
+ // server. Tunnels are now opt-in: run `cloudflared tunnel` yourself
1639
+ // when you actually want to share a port.
1640
+ // Wire events: watcher → relay + REPL + transcript tailer
1641
+ watcher.on('event', async (event) => {
1642
+ if (isDuplicate(event))
1643
+ return;
1644
+ health.recordEvent();
1645
+ // Send raw event to DO for office state computation
1646
+ relay.sendRawEvent(event);
1647
+ metrics.track(event);
1648
+ repl.logEvent(event);
1649
+ repl.trackEvent(event);
1650
+ const cwdProject = event.cwd
1651
+ ? event.cwd.replace(/\/+$/, '').split('/').filter(Boolean).pop() || undefined
1652
+ : undefined;
1653
+ // Start tailing transcript on SessionStart
1654
+ if (event.hook_event_name === 'SessionStart' && event.transcript_path) {
1655
+ transcriptTailer.startTailing(event.session_id, event.transcript_path);
1656
+ }
1657
+ // (Auto-tunnel hook removed — dev-server URLs are no longer
1658
+ // exposed publicly by the daemon. See note above.)
1659
+ // Stop tailing on SessionEnd
1660
+ if (event.hook_event_name === 'SessionEnd') {
1661
+ transcriptTailer.stopTailing(event.session_id);
1662
+ repl.resetStats();
1663
+ }
1664
+ });
1665
+ // ── IPC server for local clients (Electron, CLI tools) ──
1666
+ //
1667
+ // Auth model: socket file permissions are the security boundary.
1668
+ // The daemon chmods /tmp/beehaven-ipc.sock to 0600 on bind, so only
1669
+ // the same UNIX user can connect. Kernel-enforced; no shared-secret
1670
+ // handshake needed.
1671
+ //
1672
+ // The previous model minted a token in ~/.beehaven/ipc-auth that
1673
+ // both sides read — a symmetric shared-secret pattern that (a) didn't
1674
+ // add any real security for same-user trust and (b) created a brittle
1675
+ // failure mode where deleting the file killed all IPC until the Mac
1676
+ // app was relaunched. Removed in favor of "the OS already knows who
1677
+ // you are."
1678
+ //
1679
+ // Scope-aware IPC auth. Mints a consumer token always; mints a
1680
+ // developer token only when HIVE_DEV_MODE=1. Consumer-scope clients
1681
+ // (mwah, web bridge) get the everyday verb surface. Developer-scope
1682
+ // clients (the full beehaven CLI) get get-token, daemon-fetch,
1683
+ // shell, sql, marketplace:install, admin:* RPCs in addition.
1684
+ //
1685
+ // When dev mode is off, the developer token doesn't exist on disk
1686
+ // and beehaven-side privileged verbs fail closed with FORBIDDEN_SCOPE.
1687
+ // That's the intended security default for the Hive consumer build.
1688
+ /**
1689
+ * ⚠ ONE PREDICATE, AND IT ALSO ANSWERS *MAY* THIS BUILD. A released tarball compiles the
1690
+ * capability out entirely — see `build-channel.ts`; the env var is inert there.
1691
+ */
1692
+ const developerMode = devModeAvailable();
1693
+ const tokens = mintIpcAuthTokens({ developerMode });
1694
+ /**
1695
+ * ⚠ REMEMBERED SO SHUTDOWN CAN TELL ITS OWN TOKEN FROM A SUCCESSOR'S. See `clearIpcAuthToken` —
1696
+ * an unconditional scrub during an overlapping restart deletes the LIVE daemon's token and
1697
+ * strands it without one.
1698
+ */
1699
+ mintedIpcTokens = { consumer: tokens.consumer, developer: tokens.developer };
1700
+ if (developerMode) {
1701
+ logger.info('ipc', 'IPC auth — developer mode ENABLED; dev-scope token issued at ~/.beehaven/ipc-auth-dev');
1702
+ }
1703
+ else {
1704
+ logger.debug('ipc', 'IPC auth — consumer mode; developer-scope verbs are unreachable');
1705
+ }
1706
+ const ipc = new IpcServer({
1707
+ consumerAuthToken: tokens.consumer,
1708
+ developerAuthToken: tokens.developer,
1709
+ });
1710
+ ipc.start();
1711
+ // Forward the authenticated cloud-session projection to local consumers (Mac avatar, office,
1712
+ // etc.). They must not watch the daemon's recovery file as a presence source.
1713
+ agentSessions.setPresenceBroadcast((snapshot) => {
1714
+ ipc.broadcast({ type: 'agent:presence', ...snapshot });
1715
+ });
1716
+ // ── Daemon Omni — Claude Code, hosted here, beehaven-native ─────────
1717
+ //
1718
+ // The Claude Agent SDK runtime (daemon-omni.ts) survived
1719
+ // "destroy-the-treehouse" but lost every door: its `omni:prompt` IPC verb
1720
+ // was documented in its own header and registered nowhere, so the only way
1721
+ // in was `beehaven claude`, which runs the SDK in the CLI's OWN process. A
1722
+ // daemon-hosted runtime that nothing daemon-hosted can reach is a library,
1723
+ // not a runtime. These verbs are the doors.
1724
+ //
1725
+ // ⚠ LOCAL IPC ONLY, and that is a compliance boundary, not a shortcut.
1726
+ // Cloud→daemon Claude orchestration is deliberately disabled (see relay.ts
1727
+ // "Anthropic credential use policy"). The IPC socket is chmod 0600, so the
1728
+ // kernel guarantees the caller is this UNIX user driving their own
1729
+ // subscription. Do NOT bridge these onto `relay.onDaemonRpcRequest` or any
1730
+ // other inbound relay path — that is precisely the shape that was switched
1731
+ // off.
1732
+ {
1733
+ const omni = await import('./daemon-omni.js');
1734
+ /**
1735
+ * ⚠⚠ THE IN-PROCESS BRIDGE FOR HIVES. Buildings are loaded with
1736
+ * `await import(pathToFileURL(entry))` INSIDE this process, so the avatar hive and this module
1737
+ * share a runtime — but not a module graph: the hive is its own package and cannot resolve a
1738
+ * path into the CLI's `src/`. A global registry is the standard bridge for that shape, and it
1739
+ * is explicit about what is shared rather than exposing the module wholesale.
1740
+ *
1741
+ * ⚠⚠ WHY IT EXISTS AT ALL: the avatar chat believed it was already talking to daemon Claude.
1742
+ * Its own comment says *"Omni IS the daemon's Claude"* — but `replyWithOmni` calls the
1743
+ * workspace action `agent-prompt`, which refuses desktop agents outright ("headless
1744
+ * agent-prompt runs the cloud agent (omni) only"). So the local Claude Code session was removed
1745
+ * in favour of something that could never reach it, and every message in that panel went to the
1746
+ * cloud on the workspace's AI pool. This is how the panel reaches the real one.
1747
+ *
1748
+ * ⚠ ABSENT MEANS "not running inside the daemon", which is a legitimate state (a hive loaded by
1749
+ * a test, or a future out-of-process host). Consumers MUST degrade rather than throw.
1750
+ */
1751
+ globalThis.__beehavenOmni = {
1752
+ version: 1,
1753
+ prompt: omni.promptOmni,
1754
+ subscribe: omni.subscribe,
1755
+ listSessions: omni.listSessions,
1756
+ setSessionCwd: omni.setSessionCwd,
1757
+ interrupt: omni.interruptSession,
1758
+ end: omni.endSession,
1759
+ };
1760
+ // Every session's events, fanned out to IPC subscribers as one envelope.
1761
+ // Subscribing once at boot rather than per-prompt: a subscriber attached
1762
+ // per turn would stack, and events that arrive between turns (a late tool
1763
+ // result, a compaction notice) would land on a listener nobody re-added.
1764
+ // Broadcast the OmniEvent AS-IS — its own `type` is the discriminator
1765
+ // (`agent:text-delta`, `agent:tool-call`, `agent:done`, …), which is the
1766
+ // channel mwah's CloudBridge already listens on. Wrapping it in an
1767
+ // envelope would have been the obvious move and is wrong twice: the spread
1768
+ // would overwrite the envelope's own `type`, and it would fork a second
1769
+ // event vocabulary for the same stream.
1770
+ omni.subscribe((event) => ipc.broadcast({ ...event, omni: true }));
1771
+ ipc.on('omni:prompt', async ({ clientId, rid, input }) => {
1772
+ try {
1773
+ const prompt = String(input?.prompt ?? '').trim();
1774
+ if (!prompt)
1775
+ throw new Error('prompt is required');
1776
+ const res = await omni.promptOmni({
1777
+ prompt,
1778
+ // One session per caller-chosen id. Defaults to a single shared
1779
+ // 'default' thread so a client that doesn't care about session
1780
+ // management still gets continuity instead of amnesia per message.
1781
+ sessionId: String(input?.sessionId ?? 'default'),
1782
+ userId: String(input?.userId ?? 'local'),
1783
+ /**
1784
+ * ⚠⚠ DEFAULTS TO THE DESKTOP BINDING, AND WITHOUT IT ONE TURN WORE TWO NAMES.
1785
+ *
1786
+ * `agentName` sets `BEEHAVEN_AGENT` for the spawned Claude, so it governs every
1787
+ * `beehaven` call that Claude makes through Bash. It was optional and `beehaven omni`
1788
+ * passes none — so the shell half of the session had NO identity, and Claude did the
1789
+ * reasonable thing: it ran `beehaven agent login <a name it chose>` and carried on.
1790
+ *
1791
+ * Observed in one turn, after the `invokeRpc` fix: `canvas-list-compositions` filed under
1792
+ * `claude-sep` (the MCP tools, via the desktop binding) and `document-create` under
1793
+ * `jinri-claude` (the shell, via Claude's own login). One caller, one workspace, two
1794
+ * identities in the activity log — which is WORSE than being unattributed, because the
1795
+ * log then asserts something false about who did what.
1796
+ *
1797
+ * ⚠ AN EXPLICIT `agentName` STILL WINS. The avatar passes its own agent so `beehaven
1798
+ * speak` uses that avatar's voice; this only supplies the install's identity when the
1799
+ * caller expressed no opinion.
1800
+ */
1801
+ agentName: input?.agentName || desktopPrincipal(),
1802
+ model: input?.model,
1803
+ appendSystemPrompt: input?.appendSystemPrompt,
1804
+ cwd: input?.cwd,
1805
+ compositionId: input?.compositionId,
1806
+ compositionTitle: input?.compositionTitle,
1807
+ allowedTools: input?.allowedTools,
1808
+ continueSession: input?.continueSession,
1809
+ resumeSessionId: input?.resumeSessionId,
1810
+ additionalDirectories: input?.additionalDirectories,
1811
+ });
1812
+ // Resolves when the turn is ACCEPTED. The answer arrives as
1813
+ // `omni:event` frames — a prompt that blocked until completion would
1814
+ // hold the IPC response open for the length of a coding task.
1815
+ ipc.sendRpcResult(clientId, rid, { ...res, accepted: true });
1816
+ }
1817
+ catch (err) {
1818
+ ipc.sendRpcError(clientId, rid, err?.message ?? String(err));
1819
+ }
1820
+ });
1821
+ /**
1822
+ * Answer a question the turn is BLOCKED on.
1823
+ *
1824
+ * ⚠ NOT ROUTED THROUGH A SESSION. The ask registry is keyed by a random id,
1825
+ * so an answer resolves the right promise regardless of which client asked —
1826
+ * which matters because the Mac app and the avatar panel can both be
1827
+ * attached, and either may be the one showing the card.
1828
+ */
1829
+ ipc.on('omni:answer', ({ clientId, rid, input }) => {
1830
+ try {
1831
+ const ok = omni.answerOmniAsk(String(input?.askId ?? ''), String(input?.value ?? ''), input?.outcome === 'dismissed' ? 'dismissed' : 'answered');
1832
+ ipc.sendRpcResult(clientId, rid, { ok });
1833
+ }
1834
+ catch (err) {
1835
+ ipc.sendRpcError(clientId, rid, err?.message ?? String(err));
1836
+ }
1837
+ });
1838
+ /**
1839
+ * A surface declaring what it can render.
1840
+ *
1841
+ * ⚠ RELEASED WHEN THE CLIENT DISCONNECTS, which is why the release function
1842
+ * is held per client rather than being a bare counter. A Mac app that quits
1843
+ * without releasing would leave the daemon believing windows are available
1844
+ * forever, and Omni would go on reporting that it opened them.
1845
+ */
1846
+ ipc.on('omni:surface', ({ clientId, rid, input }) => {
1847
+ try {
1848
+ if (input?.windows) {
1849
+ const release = omni.declareWindowSurface();
1850
+ ipc.onClientDisconnect(clientId, release);
1851
+ }
1852
+ ipc.sendRpcResult(clientId, rid, { ok: true, windows: omni.hasWindowSurface() });
1853
+ }
1854
+ catch (err) {
1855
+ ipc.sendRpcError(clientId, rid, err?.message ?? String(err));
1856
+ }
1857
+ });
1858
+ /** Interrupt the turn in flight — the SDK's interrupt, not a process kill. */
1859
+ ipc.on('omni:interrupt', async ({ clientId, rid, input }) => {
1860
+ try {
1861
+ await omni.interruptSession(String(input?.sessionId ?? 'default'));
1862
+ ipc.sendRpcResult(clientId, rid, { interrupted: true });
1863
+ }
1864
+ catch (err) {
1865
+ ipc.sendRpcError(clientId, rid, err?.message ?? String(err));
1866
+ }
1867
+ });
1868
+ ipc.on('omni:end', ({ clientId, rid, input }) => {
1869
+ try {
1870
+ omni.endSession(String(input?.sessionId ?? 'default'));
1871
+ ipc.sendRpcResult(clientId, rid, { ended: true });
1872
+ }
1873
+ catch (err) {
1874
+ ipc.sendRpcError(clientId, rid, err?.message ?? String(err));
1875
+ }
1876
+ });
1877
+ ipc.on('omni:sessions', ({ clientId, rid }) => {
1878
+ try {
1879
+ ipc.sendRpcResult(clientId, rid, {
1880
+ sessions: omni.listSessions(),
1881
+ beehavenTools: omni.clearlyToolsMounted(),
1882
+ });
1883
+ }
1884
+ catch (err) {
1885
+ ipc.sendRpcError(clientId, rid, err?.message ?? String(err));
1886
+ }
1887
+ });
1888
+ /**
1889
+ * ⚠⚠ COMPANY MODE'S DIAGNOSTICS, AND THEY REPORT STATE RATHER THAN EVENTS. Two failures here
1890
+ * produce no error and no output at all: the env var never reached the DAEMON (an operator set
1891
+ * it on the shell that talks to the daemon, not on the daemon), and the daemon drifted onto
1892
+ * another workspace because `beehaven connect` is machine-global. A log cannot see either —
1893
+ * it only records turns somebody ran. Same reasoning `thought-stats` exists for.
1894
+ *
1895
+ * ⚠ IT RESOLVES ITS OWN INPUTS HERE, where they are actually known: the identity registry, the
1896
+ * connected building and the claude binary all live on this side. The diagnostic function is
1897
+ * pure and takes them, so it cannot stop working at exactly the moment the thing it inspects
1898
+ * is broken.
1899
+ *
1900
+ * beehaven call --ipc omni:company-status (or the tray's dev menu)
1901
+ */
1902
+ ipc.on('omni:company-status', async ({ clientId, rid }) => {
1903
+ try {
1904
+ const { companyDiagnostics } = await import('./daemon-omni-company.js');
1905
+ let claudeBinary = null;
1906
+ try {
1907
+ const { findClaudeBinary } = await import('./claude-credential-store.js');
1908
+ claudeBinary = await findClaudeBinary();
1909
+ }
1910
+ catch {
1911
+ claudeBinary = null;
1912
+ }
1913
+ ipc.sendRpcResult(clientId, rid, companyDiagnostics({
1914
+ identity: omni.omniIdentityText?.() ?? null,
1915
+ // ⚠ THE ID, WHICH IS ALL THIS SIDE HAS. `companyDiagnostics` matches it against
1916
+ // COMPANY_WORKSPACE_ID and answers `null` — unknown — rather than "wrong" when that is
1917
+ // not configured. Reporting a mismatch it cannot actually check is the worse failure.
1918
+ connectedWorkspace: connectedBuilding?.buildingId ?? null,
1919
+ claudeBinary,
1920
+ }));
1921
+ }
1922
+ catch (err) {
1923
+ ipc.sendRpcError(clientId, rid, err?.message ?? String(err));
1924
+ }
1925
+ });
1926
+ /**
1927
+ * ⚠⚠ SEE AND SET THE WORKING DIRECTORY. `omni:sessions` reports `cwd` now — it did not, so the
1928
+ * single most consequential property of a session (which files it reads, which SDK
1929
+ * conversation resumes) was invisible to every caller.
1930
+ *
1931
+ * ⚠ THE THREAD DOES NOT MOVE WITH IT. Claude Code keys a conversation to a project directory
1932
+ * derived from the cwd, so the SDK session id is cleared and the next turn starts fresh. The
1933
+ * reply carries `threadReset` precisely so a UI can SAY that, rather than letting someone
1934
+ * discover it by finding an agent with no memory of the last five minutes.
1935
+ *
1936
+ * beehaven call --ipc omni:set-cwd '{"sessionId":"…","cwd":"~/projects/clearly"}'
1937
+ */
1938
+ ipc.on('omni:set-cwd', ({ clientId, rid, input }) => {
1939
+ try {
1940
+ ipc.sendRpcResult(clientId, rid, omni.setSessionCwd(String(input?.sessionId ?? ''), String(input?.cwd ?? '')));
1941
+ }
1942
+ catch (err) {
1943
+ ipc.sendRpcError(clientId, rid, err?.message ?? String(err));
1944
+ }
1945
+ });
1946
+ ipc.on('omni:branch', ({ clientId, rid, input }) => {
1947
+ try {
1948
+ ipc.sendRpcResult(clientId, rid, omni.branchSession({
1949
+ sourceSessionId: String(input?.sourceSessionId ?? 'default'),
1950
+ compositionId: input?.compositionId,
1951
+ }));
1952
+ }
1953
+ catch (err) {
1954
+ ipc.sendRpcError(clientId, rid, err?.message ?? String(err));
1955
+ }
1956
+ });
1957
+ }
1958
+ // Local client RPCs → forward to DO via relay.
1959
+ //
1960
+ // The relay's default request timeout is 10s, which is too short for
1961
+ // long-running agent actions like `chat-message` (8-15s) and
1962
+ // `admin:store-rpc → chat-message` (which adds a hop). Allow IPC
1963
+ // clients to override the timeout by passing `_timeout` (ms) in the
1964
+ // input object. Sensible default: 60s for arbitrary admin RPCs.
1965
+ // ── Building session state ─────────────────────────────────────
1966
+ // `beehaven connect type/id` stores the active building here.
1967
+ // Subsequent `call`, `ask`, `actions` without --building route through
1968
+ // the Home DO's FocusManager to this building.
1969
+ let connectedBuilding = null;
1970
+ ipc.on('rpc', ({ clientId, rid, action, input }) => {
1971
+ // `beehaven call` sends ordinary workspace work as an agent-invoke envelope over this
1972
+ // transport. Refresh local desktop presence before forwarding it, including when the envelope
1973
+ // is nested inside a workspace/team proxy.
1974
+ const nestedAgentInvoke = action === 'agent-invoke'
1975
+ ? input
1976
+ : (input && typeof input === 'object' && input.action === 'agent-invoke'
1977
+ ? input.input
1978
+ : null);
1979
+ if (nestedAgentInvoke && typeof nestedAgentInvoke === 'object') {
1980
+ ensureAgentPresence(String(nestedAgentInvoke.agentId || ''));
1981
+ }
1982
+ // ── Daemon-local building session commands ──────────────────
1983
+ if (action === 'building:connect') {
1984
+ const { buildingType, buildingId } = input;
1985
+ if (!buildingType || !buildingId) {
1986
+ ipc.sendRpcError(clientId, rid, 'buildingType and buildingId are required');
1987
+ return;
1988
+ }
1989
+ connectedBuilding = { buildingType, buildingId };
1990
+ logger.info('ipc', `Building session: connected to ${buildingType}/${buildingId}`);
1991
+ // Tell the Home DO to focus on this building via FocusManager
1992
+ if (relay.isConnected()) {
1993
+ const buildingKey = `${buildingType}:${buildingId}`;
1994
+ relay
1995
+ .sendRequest({ type: 'focus', buildings: [buildingKey] }, 10_000)
1996
+ .then((result) => {
1997
+ ipc.sendRpcResult(clientId, rid, {
1998
+ connected: true,
1999
+ buildingType,
2000
+ buildingId,
2001
+ actions: result?.actions ?? null,
2002
+ });
2003
+ ipc.broadcast({ type: 'building:connected', buildingType, buildingId });
2004
+ })
2005
+ .catch((err) => {
2006
+ // Focus failed but we still store the connection for proxy routing
2007
+ ipc.sendRpcResult(clientId, rid, {
2008
+ connected: true,
2009
+ buildingType,
2010
+ buildingId,
2011
+ warning: `Focus failed (${err.message}) — commands will use proxy routing`,
2012
+ });
2013
+ });
2014
+ }
2015
+ else {
2016
+ ipc.sendRpcResult(clientId, rid, {
2017
+ connected: true,
2018
+ buildingType,
2019
+ buildingId,
2020
+ warning: 'Not connected to cloud — session stored locally',
2021
+ });
2022
+ }
2023
+ return;
2024
+ }
2025
+ if (action === 'building:disconnect') {
2026
+ const prev = connectedBuilding;
2027
+ connectedBuilding = null;
2028
+ logger.info('ipc', 'Building session: disconnected');
2029
+ // Tell Home DO to unfocus
2030
+ if (relay.isConnected() && prev) {
2031
+ relay
2032
+ .sendRequest({ type: 'unfocus', buildings: [`${prev.buildingType}:${prev.buildingId}`] }, 5_000)
2033
+ .catch(() => { }); // Best-effort
2034
+ }
2035
+ ipc.sendRpcResult(clientId, rid, { disconnected: true });
2036
+ ipc.broadcast({ type: 'building:disconnected' });
2037
+ return;
2038
+ }
2039
+ if (action === 'building:connection') {
2040
+ if (connectedBuilding) {
2041
+ ipc.sendRpcResult(clientId, rid, {
2042
+ connected: true,
2043
+ buildingType: connectedBuilding.buildingType,
2044
+ buildingId: connectedBuilding.buildingId,
2045
+ });
2046
+ }
2047
+ else {
2048
+ ipc.sendRpcResult(clientId, rid, { connected: false });
2049
+ }
2050
+ return;
2051
+ }
2052
+ // ── Agent identity ──────────────────────────────────────────
2053
+ // Mint a signed AgentIdentity token for the CLI's `agent login`
2054
+ // (and `rebind`, which re-mints with a new avatarId). The daemon
2055
+ // is the only holder of the signing secret. Owner = the signed-in
2056
+ // user (falls back to 'local' so the local demo works signed-out).
2057
+ if (action === 'agent:mint') {
2058
+ const { agentId, kind, displayName, avatarId, permissions, ttlMs, meta } = (input ||
2059
+ {});
2060
+ if (typeof agentId !== 'string' || !agentId.trim()) {
2061
+ ipc.sendRpcError(clientId, rid, 'agentId (string) is required');
2062
+ return;
2063
+ }
2064
+ const cfg = loadConfig();
2065
+ const userId = cfg.userId ?? cfg.user?.uid ?? cfg.user?.userId ?? 'local';
2066
+ (async () => {
2067
+ try {
2068
+ const token = await mintAgentToken({
2069
+ agentId: agentId.trim(),
2070
+ kind,
2071
+ displayName,
2072
+ avatarId,
2073
+ userId,
2074
+ permissions: Array.isArray(permissions) ? permissions : undefined,
2075
+ ttlMs: typeof ttlMs === 'number' ? ttlMs : undefined,
2076
+ ...(meta && typeof meta === 'object' ? { meta } : {}),
2077
+ });
2078
+ const claims = await verifyAgentToken(token);
2079
+ if (!claims) {
2080
+ ipc.sendRpcError(clientId, rid, 'minted token failed self-verification');
2081
+ return;
2082
+ }
2083
+ ipc.sendRpcResult(clientId, rid, {
2084
+ token,
2085
+ agentId: claims.agentId,
2086
+ kind: claims.kind,
2087
+ displayName: claims.displayName,
2088
+ avatarId: claims.avatarId,
2089
+ userId: claims.userId,
2090
+ issuedAt: claims.iat,
2091
+ expiresAt: claims.exp,
2092
+ });
2093
+ }
2094
+ catch (err) {
2095
+ ipc.sendRpcError(clientId, rid, err?.message || 'agent:mint failed');
2096
+ }
2097
+ })();
2098
+ return;
2099
+ }
2100
+ // ── Agent workspace sessions ─────────────────────────────────
2101
+ // Open a live `role:'agent'` session against the cloud workspace using a
2102
+ // CLOUD-signed token (from the worker's agent-mint-token RPC). This is what
2103
+ // makes a CLI-driven agent show up as its own session in my-devices.
2104
+ if (action === 'agent:connect') {
2105
+ const { agentId, token } = (input || {});
2106
+ if (typeof agentId !== 'string' || !agentId.trim() || typeof token !== 'string' || !token) {
2107
+ ipc.sendRpcError(clientId, rid, 'agentId + token are required');
2108
+ return;
2109
+ }
2110
+ agentSessions
2111
+ .connect(agentId.trim(), token)
2112
+ .then((res) => res.ok
2113
+ ? ipc.sendRpcResult(clientId, rid, { ok: true, agentId: agentId.trim() })
2114
+ : ipc.sendRpcError(clientId, rid, res.error || 'agent:connect failed'))
2115
+ .catch((e) => ipc.sendRpcError(clientId, rid, e?.message || 'agent:connect failed'));
2116
+ return;
2117
+ }
2118
+ if (action === 'agent:disconnect') {
2119
+ const { agentId } = (input || {});
2120
+ agentSessions.disconnect(String(agentId || ''));
2121
+ ipc.sendRpcResult(clientId, rid, { ok: true });
2122
+ return;
2123
+ }
2124
+ // ── Session status — returns full picture for Claude + tools ──
2125
+ if (action === 'daemon:session-status') {
2126
+ const sessions = ipc.getSessionInfo();
2127
+ const appStatus = daemonAppManager?.running ? daemonAppManager.status() : null;
2128
+ ipc.sendRpcResult(clientId, rid, {
2129
+ connectedBuilding,
2130
+ // Relay health — the ONLY authoritative answer to "can I actually call
2131
+ // the cloud right now". `beehaven status` used to answer that from the
2132
+ // on-disk token alone, so it printed "Account: Connected" while every
2133
+ // RPC failed with "relay WebSocket is down". Status is the command you
2134
+ // run when things are broken; it must not be the one that lies.
2135
+ relay: {
2136
+ connected: relay.isConnected(),
2137
+ configured: relay.isConfigured(),
2138
+ endpoint: relay.config?.endpoint ?? null,
2139
+ },
2140
+ agentPresence: agentSessions.snapshot(),
2141
+ sessions,
2142
+ localApps: appStatus
2143
+ ? {
2144
+ running: appStatus.running,
2145
+ port: appStatus.port,
2146
+ buildingTypes: appStatus.buildingTypes,
2147
+ activeInstances: appStatus.activeInstances,
2148
+ }
2149
+ : null,
2150
+ clientCount: ipc.clientCount,
2151
+ });
2152
+ return;
2153
+ }
2154
+ // ── Ensure local apps are loaded + running ──────────────────
2155
+ if (action === 'daemon:app-ensure') {
2156
+ if (!daemonAppManager) {
2157
+ ipc.sendRpcError(clientId, rid, 'App manager not initialized');
2158
+ return;
2159
+ }
2160
+ (async () => {
2161
+ try {
2162
+ if (!daemonAppManager.running) {
2163
+ if (daemonAppManager.status().buildingTypes.length === 0) {
2164
+ await daemonAppManager.loadInstalledApps();
2165
+ await daemonAppManager.loadInstalledApps(bundledFirstPartyRoots());
2166
+ }
2167
+ await daemonAppManager.start();
2168
+ }
2169
+ const status = daemonAppManager.status();
2170
+ ipc.sendRpcResult(clientId, rid, {
2171
+ running: status.running,
2172
+ port: status.port,
2173
+ buildingTypes: status.buildingTypes,
2174
+ activeInstances: status.activeInstances,
2175
+ });
2176
+ }
2177
+ catch (err) {
2178
+ ipc.sendRpcError(clientId, rid, err?.message ?? 'Failed to start apps');
2179
+ }
2180
+ })();
2181
+ return;
2182
+ }
2183
+ // ── App management RPCs (for Claude + CLI) ──────────────────
2184
+ // ── App list: installed + available catalog ──────────────────
2185
+ if (action === 'daemon:app-list') {
2186
+ (async () => {
2187
+ const all = scanInstalledApps();
2188
+ const installed = all
2189
+ .filter(({ manifest: m }) => isPubliclyVisibleApp(m))
2190
+ .map(({ entry, manifest: m }) => ({
2191
+ name: m.name,
2192
+ version: m.version,
2193
+ description: m.description,
2194
+ buildings: m.buildings ? Object.keys(m.buildings) : [],
2195
+ tier: m.license?.tier ?? 'free',
2196
+ firstParty: !!m.firstParty,
2197
+ agent: !!m.agent,
2198
+ frontend: m.frontend,
2199
+ dir: entry,
2200
+ }));
2201
+ const installedNames = new Set(installed.map((a) => a.name));
2202
+ const catalog = await getAvailableApps();
2203
+ const available = catalog
2204
+ .filter((a) => !installedNames.has(a.name))
2205
+ .filter((a) => isPubliclyVisibleApp(a))
2206
+ .map((a) => ({
2207
+ name: a.name,
2208
+ version: a.version,
2209
+ description: a.description,
2210
+ tier: a.tier,
2211
+ kind: a.kind,
2212
+ firstParty: a.firstParty ?? a.vendor === 'Clearly',
2213
+ agent: a.agent ?? false,
2214
+ frontend: a.frontend,
2215
+ }));
2216
+ ipc.sendRpcResult(clientId, rid, { installed, available });
2217
+ })().catch((err) => ipc.sendRpcError(clientId, rid, err?.message ?? 'List failed'));
2218
+ return;
2219
+ }
2220
+ // ── App install: by name (from catalog) or by path/URL ─────
2221
+ if (action === 'daemon:app-install') {
2222
+ (async () => {
2223
+ try {
2224
+ const { name, source } = input;
2225
+ if (!name && !source) {
2226
+ ipc.sendRpcError(clientId, rid, 'name or source is required. Use name to install from catalog, source for a local path or URL.');
2227
+ return;
2228
+ }
2229
+ const appsDir = join(CONFIG_DIR, 'apps');
2230
+ mkdirSync(appsDir, { recursive: true });
2231
+ let resolvedSource = source;
2232
+ // Resolve name → source from catalog
2233
+ if (name && !source) {
2234
+ const catalog = await getAvailableApps();
2235
+ const catalogEntry = catalog.find((a) => a.name === name);
2236
+ if (!catalogEntry) {
2237
+ ipc.sendRpcError(clientId, rid, `App "${name}" not found in catalog. Available: ${catalog.map((a) => a.name).join(', ')}`);
2238
+ return;
2239
+ }
2240
+ if (!isPubliclyVisibleApp(catalogEntry)) {
2241
+ ipc.sendRpcError(clientId, rid, `App "${name}" is not publicly available.`);
2242
+ return;
2243
+ }
2244
+ if (catalogEntry.install.source === 'bundled') {
2245
+ // Bundled apps — resolve the hive source directory (catalog name IS
2246
+ // the hive directory name now that the mwah → avatar alias is gone).
2247
+ const hiveName = name;
2248
+ // Hives were extracted from beehaven_sdk/hives/ to the
2249
+ // sibling repo `clearly-hives/`. Both old (in-tree) and
2250
+ // new (sibling) paths are checked so older checkouts
2251
+ // keep working during the transition. The Mac app's
2252
+ // packaging script bundles clearly-hives into the .app
2253
+ // at `beehaven/hives/<name>` so the runtime path inside
2254
+ // the .app stays unchanged (no change needed there).
2255
+ const hiveDirs = [
2256
+ // Mac app bundled: beehaven/hives/<name> (same dir as cli.js)
2257
+ join(__dirname, 'hives', hiveName),
2258
+ // NEW canonical: clearly-hives sibling repo
2259
+ // packages/cli/dist → ../../../clearly-hives/<name>
2260
+ join(dirname(dirname(dirname(__dirname))), 'clearly-hives', hiveName),
2261
+ // Or relative to clearly/ root (4 levels up from dist/)
2262
+ join(dirname(dirname(dirname(dirname(__dirname)))), 'clearly-hives', hiveName),
2263
+ // LEGACY (pre-extraction): beehaven_sdk/hives/<name>.
2264
+ // Kept temporarily for stale checkouts; safe to remove
2265
+ // once all dev environments have re-cloned.
2266
+ join(dirname(dirname(__dirname)), 'hives', hiveName),
2267
+ join(__dirname, '..', '..', 'hives', hiveName),
2268
+ join(dirname(dirname(dirname(dirname(__dirname)))), 'hives', hiveName),
2269
+ // Already installed — reinstall from existing
2270
+ ...(() => {
2271
+ const appsDir = join(CONFIG_DIR, 'apps');
2272
+ const hivesDir = join(CONFIG_DIR, 'hives');
2273
+ const candidates = [];
2274
+ for (const root of [appsDir, hivesDir]) {
2275
+ if (!existsSync(root))
2276
+ continue;
2277
+ try {
2278
+ for (const e of readdirSync(root)) {
2279
+ if (e.startsWith(`${hiveName}@`) || e === hiveName) {
2280
+ candidates.push(join(root, e));
2281
+ }
2282
+ }
2283
+ }
2284
+ catch { }
2285
+ }
2286
+ return candidates;
2287
+ })(),
2288
+ ];
2289
+ let hiveDir = null;
2290
+ for (const d of hiveDirs) {
2291
+ if (existsSync(join(d, 'app.json')) || existsSync(join(d, 'hive.json'))) {
2292
+ hiveDir = d;
2293
+ break;
2294
+ }
2295
+ }
2296
+ if (!hiveDir) {
2297
+ ipc.sendRpcError(clientId, rid, `Bundled app "${name}" not found in hives/. This app ships with the Clearly Mac app.`);
2298
+ return;
2299
+ }
2300
+ // Copy to install dir
2301
+ const manifest = JSON.parse(readFileSync(join(hiveDir, 'app.json'), 'utf8'));
2302
+ const installDir = join(appsDir, `${manifest.name}@${manifest.version}`);
2303
+ if (existsSync(installDir)) {
2304
+ rmSync(installDir, { recursive: true, force: true });
2305
+ }
2306
+ execSync(`cp -R "${hiveDir}" "${installDir}"`);
2307
+ // Run setup if declared
2308
+ if (manifest.setup) {
2309
+ const scriptPath = join(installDir, manifest.setup);
2310
+ if (existsSync(scriptPath)) {
2311
+ try {
2312
+ execSync(`chmod +x "${scriptPath}" && "${scriptPath}"`, {
2313
+ cwd: installDir,
2314
+ env: {
2315
+ ...process.env,
2316
+ APP_DIR: installDir,
2317
+ APP_NAME: manifest.name,
2318
+ APP_VERSION: manifest.version,
2319
+ },
2320
+ timeout: 300_000,
2321
+ });
2322
+ }
2323
+ catch (e) {
2324
+ logger.warn('daemon', `Setup: ${e.message}`);
2325
+ }
2326
+ }
2327
+ }
2328
+ // Reload + sync to cloud
2329
+ if (daemonAppManager) {
2330
+ try {
2331
+ await daemonAppManager.loadInstalledApps();
2332
+ }
2333
+ catch { }
2334
+ }
2335
+ syncInstalledAppsToCloud();
2336
+ ipc.broadcast({
2337
+ type: 'app:installed',
2338
+ name: manifest.name,
2339
+ version: manifest.version,
2340
+ buildings: manifest.buildings ? Object.keys(manifest.buildings) : [],
2341
+ });
2342
+ ipc.sendRpcResult(clientId, rid, {
2343
+ installed: `${manifest.name}@${manifest.version}`,
2344
+ buildings: manifest.buildings ? Object.keys(manifest.buildings) : [],
2345
+ });
2346
+ return;
2347
+ }
2348
+ if (catalogEntry.install.url) {
2349
+ resolvedSource = catalogEntry.install.url;
2350
+ }
2351
+ else {
2352
+ ipc.sendRpcError(clientId, rid, `App "${name}" has no install source`);
2353
+ return;
2354
+ }
2355
+ }
2356
+ // Download if URL
2357
+ let archivePath = resolvedSource;
2358
+ let cleanup = false;
2359
+ if (/^https?:\/\//.test(archivePath)) {
2360
+ const tmpPath = join('/tmp', `bh-app-dl-${process.pid}.hive`);
2361
+ cleanup = true;
2362
+ execSync(`curl -fsSL "${archivePath}" -o "${tmpPath}"`, { timeout: 60_000 });
2363
+ archivePath = tmpPath;
2364
+ }
2365
+ // Peek at manifest
2366
+ const manifestJson = execSync(`unzip -p "${archivePath}" app.json`, { encoding: 'utf8' });
2367
+ const manifest = JSON.parse(manifestJson);
2368
+ if (!manifest.name || !manifest.version)
2369
+ throw new Error('Invalid app.json');
2370
+ // Extract
2371
+ const installDir = join(appsDir, `${manifest.name}@${manifest.version}`);
2372
+ if (existsSync(installDir)) {
2373
+ rmSync(installDir, { recursive: true, force: true });
2374
+ }
2375
+ mkdirSync(installDir, { recursive: true });
2376
+ execSync(`unzip -q "${archivePath}" -d "${installDir}"`);
2377
+ if (cleanup)
2378
+ try {
2379
+ unlinkSync(archivePath);
2380
+ }
2381
+ catch { }
2382
+ // Run setup
2383
+ if (manifest.setup) {
2384
+ const scriptPath = join(installDir, manifest.setup);
2385
+ if (existsSync(scriptPath)) {
2386
+ try {
2387
+ execSync(`chmod +x "${scriptPath}" && "${scriptPath}"`, {
2388
+ cwd: installDir,
2389
+ env: {
2390
+ ...process.env,
2391
+ APP_DIR: installDir,
2392
+ APP_NAME: manifest.name,
2393
+ APP_VERSION: manifest.version,
2394
+ },
2395
+ timeout: 300_000,
2396
+ });
2397
+ }
2398
+ catch (e) {
2399
+ logger.warn('daemon', `Setup: ${e.message}`);
2400
+ }
2401
+ }
2402
+ }
2403
+ // Reload + sync to cloud
2404
+ if (daemonAppManager) {
2405
+ try {
2406
+ await daemonAppManager.loadInstalledApps();
2407
+ }
2408
+ catch { }
2409
+ }
2410
+ syncInstalledAppsToCloud();
2411
+ ipc.broadcast({
2412
+ type: 'app:installed',
2413
+ name: manifest.name,
2414
+ version: manifest.version,
2415
+ buildings: manifest.buildings ? Object.keys(manifest.buildings) : [],
2416
+ });
2417
+ ipc.sendRpcResult(clientId, rid, {
2418
+ installed: `${manifest.name}@${manifest.version}`,
2419
+ buildings: manifest.buildings ? Object.keys(manifest.buildings) : [],
2420
+ });
2421
+ }
2422
+ catch (err) {
2423
+ ipc.sendRpcError(clientId, rid, err?.message ?? 'Install failed');
2424
+ }
2425
+ })();
2426
+ return;
2427
+ }
2428
+ // ── App uninstall: remove locally + sync to cloud ──────────
2429
+ if (action === 'daemon:app-uninstall') {
2430
+ try {
2431
+ const { name } = input;
2432
+ if (!name) {
2433
+ ipc.sendRpcError(clientId, rid, 'name is required');
2434
+ return;
2435
+ }
2436
+ const removed = [];
2437
+ const all = scanInstalledApps();
2438
+ for (const { dir, entry, manifest } of all) {
2439
+ if (manifest.name === name || entry.startsWith(`${name}@`)) {
2440
+ rmSync(dir, { recursive: true, force: true });
2441
+ removed.push(entry);
2442
+ }
2443
+ }
2444
+ if (removed.length === 0) {
2445
+ ipc.sendRpcError(clientId, rid, `App "${name}" not installed`);
2446
+ return;
2447
+ }
2448
+ for (const r of removed) {
2449
+ // Extract app name from dir entry (e.g. "avatar@0.1.0" → "avatar")
2450
+ const appName = r.includes('@') ? r.split('@')[0] : r;
2451
+ syncUninstallToCloud(appName);
2452
+ ipc.broadcast({ type: 'app:uninstalled', name: appName });
2453
+ }
2454
+ ipc.sendRpcResult(clientId, rid, { removed });
2455
+ }
2456
+ catch (err) {
2457
+ ipc.sendRpcError(clientId, rid, err?.message ?? 'Uninstall failed');
2458
+ }
2459
+ return;
2460
+ }
2461
+ // ── Standard cloud RPC forwarding ──────────────────────────
2462
+ if (!relay.isConnected()) {
2463
+ const configured = relay.isConfigured();
2464
+ // Say WHICH failure it is. "token may have expired or the connection
2465
+ // dropped" names both causes and neither fix, and the two fixes differ:
2466
+ // an expired token needs `beehaven login` (a restart will NOT refresh it),
2467
+ // a dropped socket needs `beehaven restart`. The daemon knows which — it
2468
+ // holds the token — so it should answer rather than make the caller guess.
2469
+ let expiredToken = false;
2470
+ try {
2471
+ const cfg = Relay.loadConfig();
2472
+ if (cfg?.token) {
2473
+ const payload = JSON.parse(Buffer.from(cfg.token.split('.')[1], 'base64').toString());
2474
+ expiredToken = !payload.exp || Date.now() / 1000 > payload.exp - 60;
2475
+ }
2476
+ }
2477
+ catch {
2478
+ expiredToken = true;
2479
+ }
2480
+ const reason = !configured
2481
+ ? 'Daemon has no saved relay credentials — run `beehaven login`.'
2482
+ : expiredToken
2483
+ ? 'Relay is down: your token is EXPIRED. Run `beehaven login` (a restart will not refresh it).'
2484
+ : 'Relay is down but your token is VALID — the WebSocket dropped. Run `beehaven restart`.';
2485
+ ipc.sendRpcError(clientId, rid, reason);
2486
+ return;
2487
+ }
2488
+ // Pull out the optional _timeout flag so it doesn't pollute the
2489
+ // forwarded request body. Spread payload first so the envelope
2490
+ // `type: action` always wins — otherwise a payload field named
2491
+ // `type` (e.g. team kind) silently overrides the dispatch key.
2492
+ const { _timeout, ...payload } = (input || {});
2493
+ const timeoutMs = typeof _timeout === 'number' && _timeout > 0 ? _timeout : 60_000;
2494
+ logger.debug('ipc', `RPC forward: action=${action} rid=${rid} timeout=${timeoutMs}ms`);
2495
+ relay
2496
+ .sendRequest({ ...payload, type: action }, timeoutMs)
2497
+ .then((result) => {
2498
+ ipc.sendRpcResult(clientId, rid, result);
2499
+ })
2500
+ .catch((err) => {
2501
+ ipc.sendRpcError(clientId, rid, err.message || 'RPC failed');
2502
+ });
2503
+ });
2504
+ // Local client raw sends → forward to DO via relay
2505
+ ipc.on('send', (msg) => {
2506
+ if (relay.isConnected()) {
2507
+ relay.wsSend(msg);
2508
+ }
2509
+ });
2510
+ // Local client HTTP proxy: make a fetch with the daemon's Firebase ID
2511
+ // token as bearer auth. The token never leaves this process. Used by
2512
+ // beehaven CLI admin commands so they don't need their own credentials.
2513
+ ipc.on('daemon-fetch', async ({ clientId, rid, method, url, body, headers }) => {
2514
+ try {
2515
+ // Allowlist check BEFORE touching the token. A disallowed URL
2516
+ // should never see the Firebase bearer attached — refuse early
2517
+ // so a log leak or downstream parser error can't expose it.
2518
+ const allow = isDaemonFetchUrlAllowed(String(url || ''));
2519
+ if (!allow.ok) {
2520
+ logger.warn('ipc', `daemon-fetch rejected: ${allow.reason} (${method} ${url})`);
2521
+ ipc.sendRpcError(clientId, rid, `daemon-fetch: ${allow.reason}`);
2522
+ return;
2523
+ }
2524
+ // Being ALLOWED and being trusted with the user's credential are
2525
+ // separate questions. Four allowlist entries (*.myshopify.com,
2526
+ // *.firebaseapp.com, *.run.app, *.cloudfunctions.net) are
2527
+ // registrable by third parties, and every one of them used to get
2528
+ // a live Firebase ID token attached — the confused-deputy leak the
2529
+ // *.workers.dev comment in the allowlist warns about. Hosts
2530
+ // outside CREDENTIALED_HOSTS stay fetchable, just uncredentialed.
2531
+ const credentialed = mayReceiveCredentials(String(url || ''));
2532
+ const token = relay.config?.token;
2533
+ if (!token && credentialed) {
2534
+ ipc.sendRpcError(clientId, rid, 'Daemon has no Firebase token — sign in from the Clearly Dev Mac app tray.');
2535
+ return;
2536
+ }
2537
+ // Refresh if expired (mirrors what relay does for its own connection).
2538
+ // Only worth doing when we're actually going to send the token.
2539
+ let liveToken = token;
2540
+ if (credentialed && token) {
2541
+ try {
2542
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
2543
+ if (payload.exp && payload.exp * 1000 < Date.now() + 60_000) {
2544
+ const refreshed = await relay.refreshIdToken?.();
2545
+ if (refreshed)
2546
+ liveToken = relay.config?.token || token;
2547
+ }
2548
+ }
2549
+ catch {
2550
+ /* ignore parse errors */
2551
+ }
2552
+ }
2553
+ const callerHeaders = { ...(headers || {}) };
2554
+ if (!credentialed) {
2555
+ // A caller must not be able to smuggle the credential back in by
2556
+ // passing its own Authorization header for an untrusted host.
2557
+ for (const k of Object.keys(callerHeaders)) {
2558
+ if (k.toLowerCase() === 'authorization')
2559
+ delete callerHeaders[k];
2560
+ }
2561
+ logger.debug('ipc', `daemon-fetch: sending UNCREDENTIALED to ${url}`);
2562
+ }
2563
+ const init = {
2564
+ method,
2565
+ headers: {
2566
+ ...callerHeaders,
2567
+ ...(credentialed ? { Authorization: `Bearer ${liveToken}` } : {}),
2568
+ },
2569
+ };
2570
+ if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
2571
+ // body comes as a string from the IPC client (already JSON-encoded
2572
+ // by the caller, or raw text for non-JSON content)
2573
+ init.body = typeof body === 'string' ? body : JSON.stringify(body);
2574
+ if (!init.headers['Content-Type'] && typeof body === 'string') {
2575
+ init.headers['Content-Type'] = 'application/json';
2576
+ }
2577
+ }
2578
+ logger.debug('ipc', `daemon-fetch: ${method} ${url}`);
2579
+ const res = await fetch(url, init);
2580
+ const resBody = await res.text();
2581
+ ipc.sendRpcResult(clientId, rid, {
2582
+ status: res.status,
2583
+ ok: res.ok,
2584
+ headers: Object.fromEntries(res.headers.entries()),
2585
+ body: resBody,
2586
+ });
2587
+ }
2588
+ catch (err) {
2589
+ ipc.sendRpcError(clientId, rid, err?.message || 'daemon-fetch failed');
2590
+ }
2591
+ });
2592
+ // Local client wants the current Firebase ID token (for tools that
2593
+ // need to call third-party services). Use `daemon-fetch` instead when
2594
+ // calling worker routes — it keeps the token inside the daemon.
2595
+ ipc.on('get-token', ({ clientId, rid }) => {
2596
+ try {
2597
+ const token = relay.config?.token;
2598
+ if (!token) {
2599
+ ipc.sendRpcError(clientId, rid, 'Daemon has no Firebase token — sign in from the Clearly Dev Mac app tray.');
2600
+ return;
2601
+ }
2602
+ ipc.sendRpcResult(clientId, rid, { token });
2603
+ }
2604
+ catch (err) {
2605
+ ipc.sendRpcError(clientId, rid, err?.message || 'get-token failed');
2606
+ }
2607
+ });
2608
+ // Local app RPC — route to the local building's WebSocket handler.
2609
+ // This is the fast path: IPC socket → daemon → app workspace (no cloud roundtrip).
2610
+ // Used by `app` CLI for controlling local buildings like AbletonWorkspace.
2611
+ ipc.on('app-rpc', async ({ clientId, rid, action, input, building, instanceId, agent }) => {
2612
+ if (!daemonAppManager || !daemonAppManager.running) {
2613
+ // When an agent identity is present but the local app server isn't
2614
+ // running, the avatar hive is almost certainly gated off because the
2615
+ // daemon is signed out (requiresAuth). Surface that explicitly rather
2616
+ // than a generic "not running" so the CLI can tell the user to sign in.
2617
+ if (agent && !relay.isConfigured()) {
2618
+ ipc.sendRpcError(clientId, rid, 'AGENT_UNAVAILABLE_SIGNED_OUT: the avatar hive is offline because the daemon is signed out. Sign in via the Hive Mac app.');
2619
+ return;
2620
+ }
2621
+ ipc.sendRpcError(clientId, rid, 'Local app not running');
2622
+ return;
2623
+ }
2624
+ // ── Agent identity (optional) ──────────────────────────────────
2625
+ // When the CLI carries a signed AgentIdentity token, verify it
2626
+ // daemon-side and build a CallerContext the building can trust
2627
+ // (same-process boundary). For avatar-directed calls, route to the
2628
+ // agent's OWN avatar instance (instanceId = avatarId) unless the
2629
+ // client explicitly picked an instance — this is what gives N
2630
+ // signed-in agents N distinct on-screen avatars.
2631
+ // Local shape (mirrors core's CallerContext — core is an optional peer
2632
+ // dep loaded via dynamic import, so we don't statically reference its type).
2633
+ let caller;
2634
+ if (agent) {
2635
+ const claims = await verifyAgentToken(agent);
2636
+ if (!claims) {
2637
+ ipc.sendRpcError(clientId, rid, 'AGENT_INVALID: agent identity could not be verified (expired or bad signature). Run `beehaven agent login <name>` to refresh.');
2638
+ return;
2639
+ }
2640
+ caller = {
2641
+ role: 'agent',
2642
+ agentId: claims.agentId,
2643
+ avatarId: claims.avatarId,
2644
+ userId: claims.userId,
2645
+ displayName: claims.displayName,
2646
+ permissions: claims.permissions,
2647
+ voice: claims.meta?.voice,
2648
+ };
2649
+ // Presence follows real, verified work. Reattach a local session after a daemon restart
2650
+ // before touching it; otherwise workspace attribution succeeds while the desktop lease
2651
+ // silently remains asleep because AgentSessions has no in-memory spec.
2652
+ // `agent` is the DEVICE-LOCAL avatar credential. AgentSessions connects to the cloud
2653
+ // workspace and therefore must use the stored WORKER-signed token instead; presenting the
2654
+ // local token to the relay briefly wakes the avatar, then fails authentication and removes
2655
+ // the session again.
2656
+ ensureAgentPresence(caller.agentId);
2657
+ if (building === 'avatar' &&
2658
+ (!instanceId || instanceId === 'live' || instanceId === 'default')) {
2659
+ instanceId = claims.avatarId;
2660
+ }
2661
+ }
2662
+ // ── Cloud routing path ─────────────────────────────────────────
2663
+ // If the target building is declared as cloud-hosted in any
2664
+ // installed app manifest, the daemon doesn't try to instantiate
2665
+ // a local class for it — instead it forwards the RPC over the
2666
+ // existing relay WebSocket using the declared cloudUrl. The cloud
2667
+ // side (Cloudflare Worker + DO router) unpacks the message and
2668
+ // hits the right building instance.
2669
+ //
2670
+ // We broadcast a `cloud-app-rpc:forward` event so the browser
2671
+ // and Claude both see "this is going to the cloud" as part of a
2672
+ // pipeline narrative, and so dashboards can time cloud latency.
2673
+ const cloudRoute = findCloudBuildingRoute(daemonAppManager, building);
2674
+ if (cloudRoute) {
2675
+ ipc.broadcast({
2676
+ type: 'cloud-app-rpc:forward',
2677
+ building,
2678
+ instanceId,
2679
+ action,
2680
+ rid,
2681
+ cloudUrl: cloudRoute.cloudUrl,
2682
+ app: cloudRoute.appName,
2683
+ });
2684
+ try {
2685
+ const result = await forwardCloudAppRpc({
2686
+ relay,
2687
+ cloudUrl: cloudRoute.cloudUrl,
2688
+ app: cloudRoute.appName,
2689
+ building,
2690
+ instanceId,
2691
+ action,
2692
+ input,
2693
+ rid,
2694
+ });
2695
+ ipc.broadcast({ type: 'cloud-app-rpc:result', building, action, rid, ok: true });
2696
+ ipc.sendRpcResult(clientId, rid, result);
2697
+ }
2698
+ catch (err) {
2699
+ ipc.broadcast({
2700
+ type: 'cloud-app-rpc:result',
2701
+ building,
2702
+ action,
2703
+ rid,
2704
+ ok: false,
2705
+ error: err?.message,
2706
+ });
2707
+ ipc.sendRpcError(clientId, rid, err?.message || 'Cloud app RPC failed');
2708
+ }
2709
+ return;
2710
+ }
2711
+ // ── Local building path ────────────────────────────────────────
2712
+ const instance = daemonAppManager.getBuilding(building, instanceId);
2713
+ if (!instance) {
2714
+ ipc.sendRpcError(clientId, rid, `Building "${building}/${instanceId}" not found`);
2715
+ return;
2716
+ }
2717
+ const { instance: workspace } = instance;
2718
+ // Prefer direct .call() — simpler, synchronous result, always works.
2719
+ // The virtual WebSocket path is kept as fallback for buildings that
2720
+ // override webSocketMessage but don't expose .call().
2721
+ if (typeof workspace.call === 'function') {
2722
+ // Ensure the building has a broadcast function — some buildings
2723
+ // (e.g. AvatarWorkspace) expect it to be set by the host. Wire
2724
+ // it to the IPC bus so building events reach all connected clients.
2725
+ if (typeof workspace.broadcast !== 'function') {
2726
+ workspace.broadcast = (msg) => {
2727
+ ipc.broadcast({ ...msg, building });
2728
+ };
2729
+ }
2730
+ try {
2731
+ // Pass the verified CallerContext as the 3rd arg. Buildings that
2732
+ // ignore it (the 2-arg majority) are unaffected; the avatar hive
2733
+ // reads it to attribute the call to a specific agent + avatar.
2734
+ const result = await workspace.call(action, input, caller);
2735
+ ipc.sendRpcResult(clientId, rid, result);
2736
+ // Broadcast post-action event for subscribers
2737
+ ipc.broadcast({
2738
+ type: `${building}:${action}`,
2739
+ building,
2740
+ action,
2741
+ input,
2742
+ rid,
2743
+ /**
2744
+ * ⚠⚠ IDENTITY IS ATTACHED ONLY WHEN `verifyAgentToken` SUCCEEDED. `caller` is built a few
2745
+ * hundred lines up and ONLY inside `if (agent)` after the token verifies — an invalid or
2746
+ * expired token returns `AGENT_INVALID` and never reaches here. So the presence of
2747
+ * `agentId` on this frame is a claim the daemon has checked, not one the caller asserted.
2748
+ * Subscribers can and do gate on it (the Mac app reacts on screen), which is why it must
2749
+ * stay impossible to set from an unverified path.
2750
+ *
2751
+ * ⚠ `displayName` IS CARRIED so consumers can ATTRIBUTE rather than print a raw id. It
2752
+ * comes off the same verified claims as `agentId` — never from the input.
2753
+ */
2754
+ ...(caller?.agentId
2755
+ ? { agentId: caller.agentId, avatarId: caller.avatarId, displayName: caller.displayName }
2756
+ : {}),
2757
+ });
2758
+ }
2759
+ catch (err) {
2760
+ ipc.sendRpcError(clientId, rid, err.message || 'App RPC failed');
2761
+ }
2762
+ return;
2763
+ }
2764
+ // Fallback: virtual WebSocket dispatch
2765
+ try {
2766
+ const rpcMsg = JSON.stringify({ action, params: input, id: rid });
2767
+ let responded = false;
2768
+ const virtualWs = {
2769
+ readyState: 1,
2770
+ send(data) {
2771
+ if (responded)
2772
+ return;
2773
+ try {
2774
+ const parsed = JSON.parse(data);
2775
+ if (parsed.id === rid) {
2776
+ responded = true;
2777
+ if (parsed.error) {
2778
+ ipc.sendRpcError(clientId, rid, parsed.error);
2779
+ }
2780
+ else {
2781
+ ipc.sendRpcResult(clientId, rid, parsed.result);
2782
+ }
2783
+ }
2784
+ }
2785
+ catch { }
2786
+ },
2787
+ close() { },
2788
+ accept() { },
2789
+ _attachment: { userId: 'ipc' },
2790
+ serializeAttachment(d) {
2791
+ virtualWs._attachment = d;
2792
+ },
2793
+ deserializeAttachment() {
2794
+ return virtualWs._attachment;
2795
+ },
2796
+ };
2797
+ if (!workspace._ipcConnected) {
2798
+ workspace._ipcConnected = true;
2799
+ if (typeof workspace.onConnect === 'function') {
2800
+ await workspace.onConnect('ipc', virtualWs);
2801
+ }
2802
+ }
2803
+ if (typeof workspace.webSocketMessage === 'function') {
2804
+ await workspace.webSocketMessage(virtualWs, rpcMsg);
2805
+ }
2806
+ setTimeout(() => {
2807
+ if (responded) {
2808
+ ipc.broadcast({
2809
+ type: `${building}:${action}`,
2810
+ building,
2811
+ action,
2812
+ input,
2813
+ rid,
2814
+ });
2815
+ }
2816
+ }, 0);
2817
+ setTimeout(() => {
2818
+ if (!responded) {
2819
+ ipc.sendRpcError(clientId, rid, 'App RPC timeout');
2820
+ }
2821
+ }, 30000);
2822
+ }
2823
+ catch (err) {
2824
+ ipc.sendRpcError(clientId, rid, err.message || 'App RPC failed');
2825
+ }
2826
+ });
2827
+ // ── Pipeline RPC — sequential chained app-rpcs with variable binding ──
2828
+ //
2829
+ // Lets any client (Claude over IPC, the human UI over its own transport)
2830
+ // submit a single `pipeline` message that fans out into N sequential
2831
+ // app-rpcs against one or more buildings. Each step's result is bound
2832
+ // to `$<id>` and subsequent steps can reference prior outputs via
2833
+ // "$id.path.to.field" strings anywhere inside their input object.
2834
+ //
2835
+ // Every step transition emits a `pipeline:step` broadcast on the IPC
2836
+ // socket so other clients (e.g. an open browser UI) can see the chain
2837
+ // executing in real time — this is what makes "Claude and humans
2838
+ // watching each other" work.
2839
+ ipc.on('pipeline', async ({ clientId, rid, title, steps, defaultBuilding, defaultInstance, }) => {
2840
+ if (!daemonAppManager || !daemonAppManager.running) {
2841
+ ipc.sendRpcError(clientId, rid, 'Local app not running');
2842
+ return;
2843
+ }
2844
+ const bindings = {};
2845
+ // Resolve $id.path references recursively inside a value. Strings of
2846
+ // the form "$id" or "$id.a.b.c" are looked up; objects and arrays are
2847
+ // walked; everything else is returned as-is.
2848
+ const resolveRefs = (value) => {
2849
+ if (typeof value === 'string') {
2850
+ const m = value.match(/^\$([a-zA-Z_][\w]*)((?:\.[\w\-]+)*)$/);
2851
+ if (!m)
2852
+ return value;
2853
+ const [, key, pathStr] = m;
2854
+ if (!(key in bindings))
2855
+ throw new Error(`Pipeline reference "$${key}" — no prior step bound that name`);
2856
+ let cur = bindings[key];
2857
+ if (pathStr) {
2858
+ const parts = pathStr.slice(1).split('.');
2859
+ for (const p of parts) {
2860
+ if (cur == null)
2861
+ throw new Error(`Pipeline reference "$${key}${pathStr}" — null at "${p}"`);
2862
+ cur = cur[p];
2863
+ }
2864
+ }
2865
+ return cur;
2866
+ }
2867
+ if (Array.isArray(value))
2868
+ return value.map(resolveRefs);
2869
+ if (value && typeof value === 'object') {
2870
+ const out = {};
2871
+ for (const [k, v] of Object.entries(value))
2872
+ out[k] = resolveRefs(v);
2873
+ return out;
2874
+ }
2875
+ return value;
2876
+ };
2877
+ // Run a single app-rpc step. Prefers direct .call() when available,
2878
+ // falls back to virtual WebSocket dispatch.
2879
+ const runOneStep = (stepBuilding, stepInstance, action, input, stepRid) => new Promise(async (resolve, reject) => {
2880
+ const instance = daemonAppManager.getBuilding(stepBuilding, stepInstance);
2881
+ if (!instance) {
2882
+ reject(new Error(`Building "${stepBuilding}/${stepInstance}" not found`));
2883
+ return;
2884
+ }
2885
+ const { instance: workspace } = instance;
2886
+ // Direct .call() path — always preferred
2887
+ if (typeof workspace.call === 'function') {
2888
+ try {
2889
+ resolve(await workspace.call(action, input));
2890
+ }
2891
+ catch (err) {
2892
+ reject(err);
2893
+ }
2894
+ return;
2895
+ }
2896
+ // Fallback: virtual WebSocket
2897
+ let responded = false;
2898
+ const virtualWs = {
2899
+ readyState: 1,
2900
+ send(data) {
2901
+ if (responded)
2902
+ return;
2903
+ try {
2904
+ const parsed = JSON.parse(data);
2905
+ if (parsed.id !== stepRid)
2906
+ return;
2907
+ responded = true;
2908
+ if (parsed.error)
2909
+ reject(new Error(parsed.error));
2910
+ else
2911
+ resolve(parsed.result);
2912
+ }
2913
+ catch { }
2914
+ },
2915
+ close() { },
2916
+ accept() { },
2917
+ _attachment: { userId: 'ipc' },
2918
+ serializeAttachment(d) {
2919
+ virtualWs._attachment = d;
2920
+ },
2921
+ deserializeAttachment() {
2922
+ return virtualWs._attachment;
2923
+ },
2924
+ };
2925
+ try {
2926
+ if (!workspace._ipcConnected) {
2927
+ workspace._ipcConnected = true;
2928
+ if (typeof workspace.onConnect === 'function') {
2929
+ await workspace.onConnect('ipc', virtualWs);
2930
+ }
2931
+ }
2932
+ if (typeof workspace.webSocketMessage === 'function') {
2933
+ await workspace.webSocketMessage(virtualWs, JSON.stringify({ action, params: input, id: stepRid }));
2934
+ }
2935
+ setTimeout(() => {
2936
+ if (!responded)
2937
+ reject(new Error(`step "${action}" timeout`));
2938
+ }, 30_000);
2939
+ }
2940
+ catch (err) {
2941
+ reject(err);
2942
+ }
2943
+ });
2944
+ // Announce pipeline start to all subscribers.
2945
+ ipc.broadcast({ type: 'pipeline:start', rid, title: title ?? null, stepCount: steps.length });
2946
+ const results = [];
2947
+ try {
2948
+ for (let i = 0; i < steps.length; i++) {
2949
+ const step = steps[i];
2950
+ const stepBuilding = step.building || defaultBuilding;
2951
+ const stepInstance = step.instanceId || defaultInstance;
2952
+ const stepId = step.id ?? `step${i}`;
2953
+ const label = step.label ?? `${stepBuilding}.${step.action}`;
2954
+ // Emit "about to run" event so UIs can show the next step ticking.
2955
+ ipc.broadcast({
2956
+ type: 'pipeline:step',
2957
+ rid,
2958
+ index: i,
2959
+ id: stepId,
2960
+ label,
2961
+ action: step.action,
2962
+ building: stepBuilding,
2963
+ phase: 'start',
2964
+ });
2965
+ let resolvedInput;
2966
+ try {
2967
+ resolvedInput = resolveRefs(step.input ?? {});
2968
+ }
2969
+ catch (err) {
2970
+ ipc.broadcast({
2971
+ type: 'pipeline:step',
2972
+ rid,
2973
+ index: i,
2974
+ id: stepId,
2975
+ label,
2976
+ phase: 'error',
2977
+ error: err.message,
2978
+ });
2979
+ throw err;
2980
+ }
2981
+ let result;
2982
+ try {
2983
+ result = await runOneStep(stepBuilding, stepInstance, step.action, resolvedInput, `${rid}_step${i}`);
2984
+ }
2985
+ catch (err) {
2986
+ ipc.broadcast({
2987
+ type: 'pipeline:step',
2988
+ rid,
2989
+ index: i,
2990
+ id: stepId,
2991
+ label,
2992
+ phase: 'error',
2993
+ error: err?.message ?? String(err),
2994
+ });
2995
+ results.push({
2996
+ id: stepId,
2997
+ action: step.action,
2998
+ ok: false,
2999
+ error: err?.message ?? String(err),
3000
+ });
3001
+ throw new Error(`Step ${i} (${label}) failed: ${err?.message ?? err}`);
3002
+ }
3003
+ bindings[stepId] = result;
3004
+ results.push({ id: stepId, action: step.action, ok: true, result });
3005
+ ipc.broadcast({
3006
+ type: 'pipeline:step',
3007
+ rid,
3008
+ index: i,
3009
+ id: stepId,
3010
+ label,
3011
+ phase: 'ok',
3012
+ result,
3013
+ });
3014
+ }
3015
+ ipc.broadcast({ type: 'pipeline:end', rid, ok: true, stepCount: steps.length });
3016
+ ipc.sendRpcResult(clientId, rid, { ok: true, steps: results, bindings });
3017
+ }
3018
+ catch (err) {
3019
+ ipc.broadcast({ type: 'pipeline:end', rid, ok: false, error: err?.message ?? String(err) });
3020
+ ipc.sendRpcError(clientId, rid, err?.message ?? 'Pipeline failed');
3021
+ }
3022
+ });
3023
+ // ── Session lifecycle → update cloud agent context ──────────────
3024
+ // When clients join/leave sessions, push updated status to the cloud
3025
+ // so Claude knows what apps and buildings are currently connected.
3026
+ const pushSessionStatus = () => {
3027
+ if (!relay.isConnected())
3028
+ return;
3029
+ const sessions = ipc.getSessionInfo();
3030
+ relay.wsSend({
3031
+ type: 'session-status',
3032
+ connectedBuilding,
3033
+ sessions,
3034
+ clientCount: ipc.clientCount,
3035
+ });
3036
+ };
3037
+ /** Sync the installed apps list to the user's cloud workspace.
3038
+ * Calls app:register for each installed app and sends local-app-status.
3039
+ * The Home DO persists these in its installedApps table — cross-device. */
3040
+ function syncInstalledAppsToCloud() {
3041
+ if (!relay.isConnected())
3042
+ return;
3043
+ // Register each installed app with the Home DO
3044
+ for (const { manifest: m } of scanInstalledApps()) {
3045
+ if (!isPubliclyVisibleApp(m))
3046
+ continue;
3047
+ relay
3048
+ .sendRequest({
3049
+ type: 'app:register',
3050
+ name: m.name,
3051
+ version: m.version ?? '0.1.0',
3052
+ description: m.description ?? '',
3053
+ buildings: m.buildings ? Object.keys(m.buildings) : [],
3054
+ cloudBuildings: [],
3055
+ actions: {},
3056
+ }, 5_000)
3057
+ .catch(() => { });
3058
+ }
3059
+ // Also push the full local-app-status for the agent catalog
3060
+ if (daemonAppManager) {
3061
+ relay.sendLocalAppStatus(daemonAppManager.status());
3062
+ }
3063
+ // Capabilities may have changed (install/uninstall) — re-advertise this
3064
+ // node's beehive record so the swarm's dispatch matching stays accurate.
3065
+ registerBeehive();
3066
+ }
3067
+ // ── Distributed hive mesh — advertise this daemon as a beehive ──
3068
+ //
3069
+ // The node declares its ROLE (queen orchestrates / worker executes), its
3070
+ // KIND (this Mac / a headless server / a cloud container), and its
3071
+ // CAPABILITIES (the hives it can run — the dispatch match keys). The user's
3072
+ // Mac defaults to QUEEN (the natural orchestrator — where the human is); a
3073
+ // headless/container daemon defaults to WORKER. `beehaven swarm role` (config
3074
+ // `role`) or BEEHAVEN_ROLE overrides. See rpc/swarm-rpcs.ts for the queen
3075
+ // + dispatch side.
3076
+ function beehiveRole() {
3077
+ const env = (process.env.BEEHAVEN_ROLE || '').toLowerCase();
3078
+ if (env === 'queen' || env === 'worker')
3079
+ return env;
3080
+ const cfgRole = String(loadConfig()?.role || '').toLowerCase();
3081
+ if (cfgRole === 'queen' || cfgRole === 'worker')
3082
+ return cfgRole;
3083
+ return process.env.BEEHAVEN_HEADLESS === '1' ? 'worker' : 'queen';
3084
+ }
3085
+ function beehiveKind() {
3086
+ const env = (process.env.BEEHAVEN_KIND || '').toLowerCase();
3087
+ if (env === 'local-mac' || env === 'headless' || env === 'cloud-container')
3088
+ return env;
3089
+ return process.env.BEEHAVEN_HEADLESS === '1' ? 'headless' : 'local-mac';
3090
+ }
3091
+ function beehiveCapabilities() {
3092
+ try {
3093
+ const s = daemonAppManager?.status();
3094
+ const caps = new Set(Array.isArray(s?.buildingTypes) ? s.buildingTypes : []);
3095
+ // Surface app names too so `swarm list` reads friendly (motion-studio,
3096
+ // voice, recorder …) even before a building instance has spun up.
3097
+ for (const a of s?.apps ?? [])
3098
+ if (a?.name)
3099
+ caps.add(String(a.name));
3100
+ return [...caps];
3101
+ }
3102
+ catch {
3103
+ return [];
3104
+ }
3105
+ }
3106
+ function registerBeehive() {
3107
+ if (!relay.isConnected())
3108
+ return;
3109
+ relay.sendSwarmRegister({
3110
+ role: beehiveRole(),
3111
+ kind: beehiveKind(),
3112
+ capabilities: beehiveCapabilities(),
3113
+ });
3114
+ }
3115
+ /** Sync a single uninstall to the Home DO. */
3116
+ function syncUninstallToCloud(name) {
3117
+ if (!relay.isConnected())
3118
+ return;
3119
+ relay.sendRequest({ type: 'app:uninstall', name }, 5_000).catch(() => { });
3120
+ }
3121
+ // The IPC server broadcasts session:join/leave. We listen here to
3122
+ // relay that info to the cloud agent (separate from the normal
3123
+ // broadcast fan-out to other IPC clients).
3124
+ const origBroadcast = ipc.broadcast.bind(ipc);
3125
+ /**
3126
+ * ⚠⚠ THE WRAPPER MUST RETURN THE COUNT. `ipc.broadcast` now answers how many local clients it
3127
+ * reached, and this decorator swallowed it — so `reached` would have been `undefined` at every
3128
+ * call site, `=== 0` would never be true, and the browser fallback would have been dead code
3129
+ * that looked wired. Caught by the typechecker only because the base method gained a return
3130
+ * type; a JS-shaped patch would have shipped it silently.
3131
+ */
3132
+ ipc.broadcast = (msg) => {
3133
+ const sent = origBroadcast(msg);
3134
+ if (msg?.type === 'session:join' || msg?.type === 'session:leave') {
3135
+ pushSessionStatus();
3136
+ }
3137
+ return sent;
3138
+ };
3139
+ // PTY output → forward to local IPC clients (Electron, etc.)
3140
+ relay.onPtyOutput = (msg) => {
3141
+ ipc.broadcast(msg);
3142
+ };
3143
+ // DO broadcasts → forward to local clients
3144
+ /**
3145
+ * Pull the canonical avatar_settings row from the user's
3146
+ * ClearlyWorkspace workspace and apply it to the local avatar
3147
+ * building. Used on every relay (re)connect so a daemon that
3148
+ * was asleep while the user equipped a new companion on the
3149
+ * web catches up the moment it wakes — rather than waiting
3150
+ * for the next mutation to broadcast.
3151
+ *
3152
+ * Retries up to 3 times on transient failures (relay just
3153
+ * connected, ipc not ready, avatar building still loading).
3154
+ * Silent on terminal failure — the next reconnect will retry
3155
+ * and the existing broadcast handler still covers in-session
3156
+ * updates.
3157
+ */
3158
+ async function syncAvatarFromCloud(retry = 0) {
3159
+ try {
3160
+ if (!relay.isConnected())
3161
+ return;
3162
+ if (!daemonAppManager?.running) {
3163
+ if (retry < 3)
3164
+ setTimeout(() => syncAvatarFromCloud(retry + 1).catch(() => { }), 1000);
3165
+ return;
3166
+ }
3167
+ const instance = daemonAppManager.getBuilding('avatar', 'default');
3168
+ if (!instance) {
3169
+ if (retry < 3)
3170
+ setTimeout(() => syncAvatarFromCloud(retry + 1).catch(() => { }), 1000);
3171
+ return;
3172
+ }
3173
+ const settings = await relay.sendRequest({ type: 'avatar:settings-get' }, 8_000);
3174
+ if (!settings || typeof settings !== 'object')
3175
+ return;
3176
+ const patch = {};
3177
+ if (typeof settings.active_skin_id === 'string')
3178
+ patch.activeSkinId = settings.active_skin_id;
3179
+ if (typeof settings.active_champion_id === 'string')
3180
+ patch.activeChampionId = settings.active_champion_id;
3181
+ if (typeof settings.avatar_name === 'string')
3182
+ patch.avatarName = settings.avatar_name;
3183
+ if (typeof settings.body_type === 'string')
3184
+ patch.bodyType = settings.body_type;
3185
+ if (typeof settings.voice_provider === 'string')
3186
+ patch.voiceProvider = settings.voice_provider;
3187
+ if (settings.voice_id == null || typeof settings.voice_id === 'string')
3188
+ patch.voiceId = settings.voice_id;
3189
+ if (typeof settings.click_through_default === 'number')
3190
+ patch.clickThroughDefault = !!settings.click_through_default;
3191
+ if (settings.overlay_position == null || typeof settings.overlay_position === 'string')
3192
+ patch.overlayPosition = settings.overlay_position;
3193
+ if (typeof settings.overlay_size === 'number')
3194
+ patch.overlaySize = settings.overlay_size;
3195
+ await instance.instance.call('applyCloudSettings', patch);
3196
+ // Mirror the broadcast envelope into IPC so any embedded UI
3197
+ // (drawer, etc.) sees a `settings-changed` frame after the
3198
+ // initial sync — same shape it would see for a live delta.
3199
+ ipc.broadcast({ type: 'avatar:settings-changed', settings });
3200
+ }
3201
+ catch (err) {
3202
+ // Transient errors (relay disconnect mid-RPC, etc.) — retry
3203
+ // a couple times then give up; the next onConnected fires
3204
+ // the next attempt.
3205
+ if (retry < 2) {
3206
+ setTimeout(() => syncAvatarFromCloud(retry + 1).catch(() => { }), 2000);
3207
+ }
3208
+ else {
3209
+ console.warn(`[daemon] avatar settings cloud sync failed: ${err?.message ?? err}`);
3210
+ }
3211
+ }
3212
+ }
3213
+ relay.onBroadcast = (msg) => {
3214
+ /**
3215
+ * ⚠⚠ PUT AN AGENT'S COMPOSITION ON SCREEN — THE ONE PLACE THAT ACTUALLY CAN.
3216
+ *
3217
+ * The server's `open` RPC broadcasts `ui:navigate`. Only the WEB client handled it, and
3218
+ * `AgentNavigationListener` bails on `document.visibilityState === 'hidden'` BY DESIGN
3219
+ * ("barging a background app into a new tab is worse"). So with the desktop app in the
3220
+ * background, every tab RECEIVED the message and none acted: measured on staging, `open`
3221
+ * reported `delivered: 4` and nothing appeared on screen. `delivered` counts RECIPIENTS,
3222
+ * not actors — which is exactly what it had been telling us.
3223
+ *
3224
+ * ⚠ THE DAEMON IS THE ONLY PART OF THIS SYSTEM RUNNING ON THE PERSON'S MACHINE. A Cloudflare
3225
+ * worker cannot open a window; a hidden renderer refuses to. So the fallback lives here.
3226
+ *
3227
+ * ⚠ DESKTOP FIRST, BROWSER SECOND, and the ORDER is the requirement. `clearly://composition/<id>`
3228
+ * reaches the app's main process, which can RAISE the window and spawn a tab. Only if that
3229
+ * fails — no app installed, no scheme handler — does it fall back to the https url, which is
3230
+ * a plain browser tab. Opening the browser first would put the work somewhere the person's
3231
+ * desktop app cannot see it.
3232
+ *
3233
+ * ⚠ ONLY WHEN THE UI DID NOT ACT. `ui:navigate` carries no delivery receipt back to the
3234
+ * daemon, so this waits briefly and opens only if nothing claimed it — see `uiNavigateClaimed`
3235
+ * below. Without that, a visible web tab navigating itself would ALSO get a second window
3236
+ * thrown at it from here.
3237
+ */
3238
+ if (msg?.type === 'ui:navigate' && typeof msg.path === 'string') {
3239
+ /**
3240
+ * ⚠⚠ DESKTOP FIRST, BROWSER ONLY IF NOTHING LOCAL IS LISTENING — the order IS the
3241
+ * requirement. The desktop app handles the forwarded frame itself: it can RAISE its window
3242
+ * and spawn a tab, which is precisely what a hidden web renderer refuses to do. Opening a
3243
+ * browser first would put the person's work where their desktop app cannot see it.
3244
+ *
3245
+ * ⚠ `ipc.broadcast` RETURNS A COUNT NOW, and that count is the whole decision. Zero means no
3246
+ * local client at all — that is when, and only when, the daemon opens a browser itself.
3247
+ * Guessing instead would double-open for every desktop user.
3248
+ */
3249
+ const reached = ipc.broadcast({ ...msg, ui: true });
3250
+ // ⚠ An IIFE, because `onBroadcast` is a SYNCHRONOUS handler — the ladder below awaits the
3251
+ // OS opener. Fire-and-forget with a swallowing catch: putting a window on screen is a
3252
+ // courtesy and must never take the daemon's broadcast pump down with it.
3253
+ void (async () => {
3254
+ if (reached === 0) {
3255
+ /**
3256
+ * ⚠ THE BROADCAST ALREADY CARRIES AN ABSOLUTE `url` — use it rather than gluing an origin
3257
+ * onto `path`. Reconstructing means guessing the environment, and a staging workspace
3258
+ * would open a PRODUCTION page: the same class of mistake as the deep-link handler that
3259
+ * built `…/voice/home?note=<id>` from a stale string-strip.
3260
+ */
3261
+ const url = typeof msg.url === 'string' && /^https?:\/\//.test(msg.url)
3262
+ ? msg.url
3263
+ : `${String(msg.origin || 'https://www.clearly.sh').replace(/\/+$/, '')}${msg.path}`;
3264
+ /**
3265
+ * ⚠⚠ A THREE-STEP LADDER, AND THE MIDDLE RUNG IS THE ONE THAT WAS MISSING.
3266
+ * 1. a local client is listening → it already got the broadcast above (app RUNNING)
3267
+ * 2. the app is INSTALLED but NOT running → the deep link LAUNCHES it
3268
+ * 3. no app at all → a browser tab
3269
+ *
3270
+ * Without rung 2, "desktop first" only held while the app happened to be running, and an
3271
+ * installed app that was merely closed sent the person's work to a browser instead — the
3272
+ * opposite of what they asked for, and invisible because a browser opening looks like
3273
+ * success.
3274
+ *
3275
+ * ⚠ INSTALLED-NESS IS PROBED BY TRYING, NOT BY GUESSING A PATH. `open` exits non-zero when
3276
+ * no handler is registered for a scheme, so the OS answers the question it owns. Looking
3277
+ * for `/Applications/Clearly.app` would miss a user-local install, a renamed bundle and
3278
+ * every non-macOS case.
3279
+ */
3280
+ const openWith = (target) => new Promise((resolve) => {
3281
+ try {
3282
+ const opener = process.platform === 'darwin' ? 'open'
3283
+ : process.platform === 'win32' ? 'start' : 'xdg-open';
3284
+ const child = spawn(opener, [target], { detached: true, stdio: 'ignore' });
3285
+ child.on('error', () => resolve(false));
3286
+ child.on('exit', (code) => resolve(code === 0));
3287
+ child.unref();
3288
+ }
3289
+ catch {
3290
+ resolve(false);
3291
+ }
3292
+ });
3293
+ const kind = String(msg.kind || '');
3294
+ const id = String(msg.id || '');
3295
+ // ⚠ Only a kind the app has a deep-link ROUTE for. Sending `clearly://board/<id>` when
3296
+ // main.ts has no `board` case wakes the app to do nothing, and the browser fallback is
3297
+ // then never tried because the launch "succeeded".
3298
+ const deepLinkable = new Set(['composition', 'note', 'project']);
3299
+ const viaApp = deepLinkable.has(kind) && id
3300
+ ? await openWith(`clearly://${kind}/${encodeURIComponent(id)}`)
3301
+ : false;
3302
+ if (!viaApp)
3303
+ await openWith(url);
3304
+ }
3305
+ })().catch(() => { });
3306
+ return;
3307
+ }
3308
+ // Handle tool execution requests from cloud agent
3309
+ if (msg?.type === 'tool:execute' && msg.toolName && msg.requestId) {
3310
+ handleToolExecute(msg).catch((err) => {
3311
+ relay.wsSend({ type: 'tool:result', requestId: msg.requestId, error: err.message });
3312
+ });
3313
+ return;
3314
+ }
3315
+ // Handle app:call:forward — cloud workspace routing an RPC to a local building
3316
+ if (msg?.type === 'app:call:forward' && msg.building && msg.action) {
3317
+ (async () => {
3318
+ if (!daemonAppManager?.running) {
3319
+ relay.wsSend({
3320
+ type: 'app:call:result',
3321
+ requestId: msg.requestId,
3322
+ error: 'Local apps not running',
3323
+ });
3324
+ return;
3325
+ }
3326
+ const instance = daemonAppManager.getBuilding(msg.building, 'live');
3327
+ if (!instance) {
3328
+ relay.wsSend({
3329
+ type: 'app:call:result',
3330
+ requestId: msg.requestId,
3331
+ error: `Building "${msg.building}" not found locally`,
3332
+ });
3333
+ return;
3334
+ }
3335
+ try {
3336
+ const result = await instance.instance.call(msg.action, msg.params ?? {});
3337
+ relay.wsSend({ type: 'app:call:result', requestId: msg.requestId, ok: true, result });
3338
+ }
3339
+ catch (err) {
3340
+ relay.wsSend({
3341
+ type: 'app:call:result',
3342
+ requestId: msg.requestId,
3343
+ error: err?.message ?? 'Local app call failed',
3344
+ });
3345
+ }
3346
+ })().catch(() => { });
3347
+ return;
3348
+ }
3349
+ // ── agent:deps-install — dep framework installer ──
3350
+ // Worker fires this when the user clicks "Install" in the
3351
+ // InstallationsManager UI or when a hive's resources.required
3352
+ // dep needs to be present. Daemon downloads + installs to the
3353
+ // shared root (~/Library/Application Support/Clearly/{bin,models}/)
3354
+ // and reports progress back via the deps-install-progress
3355
+ // bidirectional message channel. See docs/HIVE-DEP-VERIFICATION.md
3356
+ // for the full wire map.
3357
+ if (msg?.type === 'agent:deps-install' && msg.depId && msg.spec) {
3358
+ (async () => {
3359
+ const { depId, spec, daemonSessionId } = msg;
3360
+ const reportProgress = (status, pct, message, error) => {
3361
+ try {
3362
+ relay.wsSend({
3363
+ type: 'deps-install-status',
3364
+ depId,
3365
+ daemonSessionId,
3366
+ status,
3367
+ progress_pct: pct,
3368
+ progress_message: message,
3369
+ error: error ?? '',
3370
+ });
3371
+ }
3372
+ catch {
3373
+ /* relay may be down */
3374
+ }
3375
+ };
3376
+ try {
3377
+ reportProgress('downloading', 0, 'Starting…');
3378
+ const { installDep } = await import('./dep-installer.js');
3379
+ const installPath = await installDep(spec, (pct, msg) => {
3380
+ reportProgress('downloading', pct, msg);
3381
+ });
3382
+ reportProgress('installed', 100, 'Installed', undefined);
3383
+ relay.wsSend({
3384
+ type: 'deps-install-status',
3385
+ depId,
3386
+ daemonSessionId,
3387
+ status: 'installed',
3388
+ progress_pct: 100,
3389
+ progress_message: 'Installed',
3390
+ install_path: installPath,
3391
+ });
3392
+ }
3393
+ catch (e) {
3394
+ console.error(`[daemon] deps-install ${depId} failed:`, e?.message ?? e);
3395
+ reportProgress('failed', 0, '', String(e?.message ?? e).slice(0, 500));
3396
+ }
3397
+ })().catch(() => { });
3398
+ return;
3399
+ }
3400
+ // ── agent:deps-uninstall — symmetric uninstall ──
3401
+ if (msg?.type === 'agent:deps-uninstall' && msg.depId && msg.spec) {
3402
+ (async () => {
3403
+ const { depId, spec, daemonSessionId } = msg;
3404
+ try {
3405
+ const { uninstallDep } = await import('./dep-installer.js');
3406
+ await uninstallDep(spec);
3407
+ relay.wsSend({
3408
+ type: 'deps-install-status',
3409
+ depId,
3410
+ daemonSessionId,
3411
+ status: 'not-installed',
3412
+ progress_pct: 0,
3413
+ progress_message: 'Uninstalled',
3414
+ });
3415
+ }
3416
+ catch (e) {
3417
+ console.error(`[daemon] deps-uninstall ${depId} failed:`, e?.message ?? e);
3418
+ relay.wsSend({
3419
+ type: 'deps-install-status',
3420
+ depId,
3421
+ daemonSessionId,
3422
+ status: 'failed',
3423
+ error: String(e?.message ?? e).slice(0, 500),
3424
+ });
3425
+ }
3426
+ })().catch(() => { });
3427
+ return;
3428
+ }
3429
+ // ── Hive avatar (mwah) cloud → local sync ──
3430
+ // ClearlyWorkspace is the canonical source for everything the user
3431
+ // *picked* (active skin, accessories, voice, body type, name);
3432
+ // the local AvatarWorkspace is the source for everything the
3433
+ // overlay is *doing right now* (animation phase, speech, timers,
3434
+ // try-on). When ClearlyWorkspace mutates settings it broadcasts
3435
+ // `avatar:settings-changed` with the full row; we mirror that
3436
+ // into the local building's cache so the overlay updates without
3437
+ // the CLI / web wardrobe having to dual-write.
3438
+ if (msg?.type === 'avatar:settings-changed' && msg.settings) {
3439
+ (async () => {
3440
+ if (!daemonAppManager?.running)
3441
+ return;
3442
+ const instance = daemonAppManager.getBuilding('avatar', 'default');
3443
+ if (!instance)
3444
+ return;
3445
+ // Translate snake_case cloud row → camelCase local input.
3446
+ const s = msg.settings;
3447
+ const patch = {};
3448
+ if (typeof s.active_skin_id === 'string')
3449
+ patch.activeSkinId = s.active_skin_id;
3450
+ // active_champion_id is the companion identity (godzilla,
3451
+ // librarian, kumo, …). The local Avatar building uses it
3452
+ // to pick mood baselines, voice persona, and the headshot
3453
+ // surfaced in the tray menu — without it the Mac app
3454
+ // displays Godzilla even after the user equipped Librarian
3455
+ // on the web.
3456
+ if (typeof s.active_champion_id === 'string')
3457
+ patch.activeChampionId = s.active_champion_id;
3458
+ if (typeof s.avatar_name === 'string')
3459
+ patch.avatarName = s.avatar_name;
3460
+ if (typeof s.body_type === 'string')
3461
+ patch.bodyType = s.body_type;
3462
+ if (typeof s.voice_provider === 'string')
3463
+ patch.voiceProvider = s.voice_provider;
3464
+ if (s.voice_id == null || typeof s.voice_id === 'string')
3465
+ patch.voiceId = s.voice_id;
3466
+ if (typeof s.click_through_default === 'number')
3467
+ patch.clickThroughDefault = !!s.click_through_default;
3468
+ // P1 — overlay position + size are renderer-only fields, but
3469
+ // forward them so the building can broadcast a single
3470
+ // 'avatar:settings-changed' IPC frame to any embedded UI
3471
+ // (drawer, etc.) instead of two separate broadcasts.
3472
+ // overlay_position is JSON in the cloud row; we pass it
3473
+ // through unparsed so the building can choose to ignore it.
3474
+ if (s.overlay_position == null || typeof s.overlay_position === 'string')
3475
+ patch.overlayPosition = s.overlay_position;
3476
+ if (typeof s.overlay_size === 'number')
3477
+ patch.overlaySize = s.overlay_size;
3478
+ try {
3479
+ await instance.instance.call('applyCloudSettings', patch);
3480
+ }
3481
+ catch (err) {
3482
+ // Apply errors are noise — the next delta will retry. Log
3483
+ // once at warn so a stuck overlay can be diagnosed.
3484
+ console.warn(`[daemon] avatar applyCloudSettings failed: ${err?.message ?? err}`);
3485
+ }
3486
+ })().catch(() => { });
3487
+ // Also forward to IPC so the drawer / Electron UI can react
3488
+ // (e.g. show "skin updated" toast).
3489
+ ipc.broadcast(msg);
3490
+ return;
3491
+ }
3492
+ // Inventory grant — daemon forwards to IPC (drawer toast etc.);
3493
+ // the actual settings change (auto-equip) flows through the
3494
+ // settings-changed broadcast above.
3495
+ if (msg?.type === 'avatar:inventory-granted') {
3496
+ ipc.broadcast(msg);
3497
+ return;
3498
+ }
3499
+ // Wallet / champion-grant deltas are pure notifications for the
3500
+ // web (no local-building state derives from them) — just forward
3501
+ // to any embedded IPC clients (drawer toast etc.) and stop.
3502
+ if (msg?.type === 'avatar:wallet-changed' || msg?.type === 'avatar:champion-granted') {
3503
+ ipc.broadcast(msg);
3504
+ return;
3505
+ }
3506
+ // Try-on overlay — cloud emits when the user clicks "Try on"
3507
+ // in the shop. Forward to the local Avatar building so the
3508
+ // overlay swaps to the preview skin for the duration, then
3509
+ // reverts. Without this branch the cloud RPC succeeds but the
3510
+ // Mac overlay never changes.
3511
+ if (msg?.type === 'avatar:tryon' && typeof msg.skinId === 'string') {
3512
+ (async () => {
3513
+ if (!daemonAppManager?.running)
3514
+ return;
3515
+ const instance = daemonAppManager.getBuilding('avatar', 'default');
3516
+ if (!instance)
3517
+ return;
3518
+ try {
3519
+ await instance.instance.call('tryOnSkin', {
3520
+ skinId: msg.skinId,
3521
+ durationMs: typeof msg.durationMs === 'number' ? msg.durationMs : 60_000,
3522
+ });
3523
+ }
3524
+ catch (err) {
3525
+ console.warn(`[daemon] avatar tryOnSkin failed: ${err?.message ?? err}`);
3526
+ }
3527
+ })().catch(() => { });
3528
+ ipc.broadcast(msg);
3529
+ return;
3530
+ }
3531
+ if (msg?.type === 'avatar:tryon-end') {
3532
+ (async () => {
3533
+ if (!daemonAppManager?.running)
3534
+ return;
3535
+ const instance = daemonAppManager.getBuilding('avatar', 'default');
3536
+ if (!instance)
3537
+ return;
3538
+ try {
3539
+ await instance.instance.call('endTryOn', {});
3540
+ }
3541
+ catch { }
3542
+ })().catch(() => { });
3543
+ ipc.broadcast(msg);
3544
+ return;
3545
+ }
3546
+ ipc.broadcast(msg);
3547
+ };
3548
+ // ── Local tool execution (cloud agent → daemon → local project) ──
3549
+ async function handleToolExecute(msg) {
3550
+ // ── Built-in beehaven: tools ─────────────────────────────────
3551
+ if (msg.toolName.startsWith('beehaven:')) {
3552
+ const action = msg.toolName.replace('beehaven:', 'daemon:');
3553
+ // Route through the same IPC RPC handlers defined above
3554
+ try {
3555
+ const result = await new Promise((resolve, reject) => {
3556
+ const fakeClientId = `tool_${msg.requestId}`;
3557
+ const fakeRid = msg.requestId;
3558
+ // Temporarily intercept the RPC result
3559
+ const origResult = ipc.sendRpcResult.bind(ipc);
3560
+ const origError = ipc.sendRpcError.bind(ipc);
3561
+ let handled = false;
3562
+ ipc.sendRpcResult = (cid, rid, result) => {
3563
+ if (cid === fakeClientId && rid === fakeRid) {
3564
+ handled = true;
3565
+ ipc.sendRpcResult = origResult;
3566
+ ipc.sendRpcError = origError;
3567
+ resolve(result);
3568
+ }
3569
+ else {
3570
+ origResult(cid, rid, result);
3571
+ }
3572
+ };
3573
+ ipc.sendRpcError = (cid, rid, error) => {
3574
+ if (cid === fakeClientId && rid === fakeRid) {
3575
+ handled = true;
3576
+ ipc.sendRpcResult = origResult;
3577
+ ipc.sendRpcError = origError;
3578
+ reject(new Error(error));
3579
+ }
3580
+ else {
3581
+ origError(cid, rid, error);
3582
+ }
3583
+ };
3584
+ // Fire the RPC handler
3585
+ ipc.emit('rpc', { clientId: fakeClientId, rid: fakeRid, action, input: msg.input || {} });
3586
+ // Safety timeout
3587
+ setTimeout(() => {
3588
+ if (!handled) {
3589
+ ipc.sendRpcResult = origResult;
3590
+ ipc.sendRpcError = origError;
3591
+ reject(new Error('Tool timeout'));
3592
+ }
3593
+ }, 60_000);
3594
+ });
3595
+ relay.wsSend({ type: 'tool:result', requestId: msg.requestId, result });
3596
+ }
3597
+ catch (err) {
3598
+ relay.wsSend({ type: 'tool:result', requestId: msg.requestId, error: err.message });
3599
+ }
3600
+ return;
3601
+ }
3602
+ const { loadManifest, getManifestTools, executeCommandTool, executeEndpointTool } = await import('./manifest.js');
3603
+ const linked = getLinkedFolders();
3604
+ // Find the tool across all linked projects
3605
+ for (const [projectDir, entry] of Object.entries(linked)) {
3606
+ const manifest = loadManifest(projectDir);
3607
+ if (!manifest?.tools)
3608
+ continue;
3609
+ // Tools are namespaced: "projectName:toolName"
3610
+ const prefix = `${manifest.name}:`;
3611
+ const toolName = msg.toolName.startsWith(prefix)
3612
+ ? msg.toolName.slice(prefix.length)
3613
+ : msg.toolName;
3614
+ const tool = manifest.tools.find((t) => t.name === toolName);
3615
+ if (!tool)
3616
+ continue;
3617
+ logger.info('daemon', `Executing local tool: ${msg.toolName} from ${projectDir}`);
3618
+ try {
3619
+ let result;
3620
+ if (tool.command) {
3621
+ const { result: stdout, exitCode } = await executeCommandTool({ ...tool, projectDir }, msg.input || {});
3622
+ result = { stdout, exitCode };
3623
+ }
3624
+ else if (tool.endpoint || manifest.endpoint) {
3625
+ const { result: body, status } = await executeEndpointTool(tool, msg.input || {}, manifest.endpoint);
3626
+ result = { body, status };
3627
+ }
3628
+ else {
3629
+ result = { error: `Tool ${toolName} has no command or endpoint` };
3630
+ }
3631
+ relay.wsSend({ type: 'tool:result', requestId: msg.requestId, result });
3632
+ }
3633
+ catch (err) {
3634
+ relay.wsSend({ type: 'tool:result', requestId: msg.requestId, error: err.message });
3635
+ }
3636
+ return;
3637
+ }
3638
+ relay.wsSend({
3639
+ type: 'tool:result',
3640
+ requestId: msg.requestId,
3641
+ error: `Tool not found: ${msg.toolName}`,
3642
+ });
3643
+ }
3644
+ // Register local tools with cloud DO on connect
3645
+ relay.ws?.addEventListener?.('open', () => registerLocalTools());
3646
+ async function registerLocalTools() {
3647
+ const { loadManifest, toolToSchema } = await import('./manifest.js');
3648
+ const linked = getLinkedFolders();
3649
+ const tools = [];
3650
+ for (const [projectDir] of Object.entries(linked)) {
3651
+ const manifest = loadManifest(projectDir);
3652
+ if (!manifest?.tools)
3653
+ continue;
3654
+ for (const tool of manifest.tools) {
3655
+ tools.push(toolToSchema(tool, manifest.name));
3656
+ }
3657
+ }
3658
+ // Built-in app management tools — always available regardless of linked projects
3659
+ tools.push({
3660
+ name: 'beehaven:app-list',
3661
+ description: 'List installed Beehaven apps with their buildings and versions',
3662
+ inputSchema: { type: 'object', properties: {} },
3663
+ }, {
3664
+ name: 'beehaven:app-install',
3665
+ description: 'Install a Beehaven app from a local path or URL (.hive archive)',
3666
+ inputSchema: {
3667
+ type: 'object',
3668
+ properties: { source: { type: 'string', description: 'Path or URL to .hive archive' } },
3669
+ required: ['source'],
3670
+ },
3671
+ }, {
3672
+ name: 'beehaven:app-uninstall',
3673
+ description: 'Uninstall a Beehaven app by name',
3674
+ inputSchema: {
3675
+ type: 'object',
3676
+ properties: { name: { type: 'string', description: 'App name (e.g. "avatar")' } },
3677
+ required: ['name'],
3678
+ },
3679
+ }, {
3680
+ name: 'beehaven:app-status',
3681
+ description: 'Get daemon session status — connected building, active sessions, running apps',
3682
+ inputSchema: { type: 'object', properties: {} },
3683
+ });
3684
+ if (tools.length > 0) {
3685
+ relay.wsSend({ type: 'register-local-tools', tools });
3686
+ logger.info('daemon', `Registered ${tools.length} local tool(s) with cloud DO`);
3687
+ }
3688
+ // Index documentation from linked projects
3689
+ indexLinkedDocs();
3690
+ }
3691
+ async function indexLinkedDocs() {
3692
+ try {
3693
+ const { DocIndexer } = await import('./doc-indexer.js');
3694
+ const { loadManifest } = await import('./manifest.js');
3695
+ const linked = getLinkedFolders();
3696
+ for (const [projectDir, entry] of Object.entries(linked)) {
3697
+ const manifest = loadManifest(projectDir);
3698
+ const docPaths = manifest?.docs ?? ['docs', 'README.md', 'CLAUDE.md'];
3699
+ const indexer = new DocIndexer({
3700
+ projectDir,
3701
+ projectName: entry.brandId ?? manifest?.name ?? 'project',
3702
+ docPaths,
3703
+ callAction: async (action, input) => {
3704
+ if (!relay.isConnected())
3705
+ return;
3706
+ try {
3707
+ await relay.sendRequest({ ...input, type: action });
3708
+ }
3709
+ catch {
3710
+ /* silently skip if disconnected */
3711
+ }
3712
+ },
3713
+ });
3714
+ const count = await indexer.index();
3715
+ if (count > 0) {
3716
+ logger.info('daemon', `Indexed ${count} doc(s) from ${projectDir}`);
3717
+ }
3718
+ }
3719
+ }
3720
+ catch (err) {
3721
+ logger.warn('daemon', `DocIndexer: ${err.message}`);
3722
+ }
3723
+ }
3724
+ // ── Subsystem registries the shutdown handler drains ────────────────
3725
+ //
3726
+ // DECLARED HERE, NOT AT THEIR USE SITE, and that placement is the whole point.
3727
+ //
3728
+ // These used to be `const`s ~400 lines further down, while the SIGTERM/SIGINT handlers
3729
+ // below closed over them. A signal arriving before execution reached those declarations hit
3730
+ // the temporal dead zone: `ReferenceError: Cannot access 'clearlySyncs' before
3731
+ // initialization`, thrown INSIDE the shutdown handler, so `origShutdown` never ran and the
3732
+ // IPC socket was never unlinked. The daemon then died leaving `/tmp/beehaven-ipc.sock`
3733
+ // behind, which put every later command into the loop where `beehaven <anything>` says
3734
+ // "Cannot connect to daemon" and `beehaven start --headless` says "already running".
3735
+ //
3736
+ // The window looks narrow, but it is exactly when the signal arrives in practice: opening
3737
+ // the Mac app SIGTERMs any foreign daemon the moment it launches (daemon-manager's
3738
+ // "replace a foreign daemon" path), and a daemon that has just been spawned is still in
3739
+ // relay-connect/token-refresh — i.e. still above the old declaration site. Reproduced on
3740
+ // every single Mac-app launch while verifying the review dock.
3741
+ const clearlySyncs = [];
3742
+ const folderWatchers = [];
3743
+ // Clean up IPC + subsystems on shutdown
3744
+ const origShutdown = shutdown;
3745
+ const shutdownWithCleanup = async (signal) => {
3746
+ try {
3747
+ ipc.stop();
3748
+ }
3749
+ catch { }
3750
+ try {
3751
+ transcriptTailer.stopAll();
3752
+ }
3753
+ catch { }
3754
+ for (const sync of clearlySyncs) {
3755
+ try {
3756
+ sync.stop();
3757
+ }
3758
+ catch { }
3759
+ }
3760
+ for (const fw of folderWatchers) {
3761
+ try {
3762
+ fw.stop();
3763
+ }
3764
+ catch { }
3765
+ }
3766
+ origShutdown(signal);
3767
+ };
3768
+ process.removeAllListeners('SIGINT');
3769
+ process.removeAllListeners('SIGTERM');
3770
+ process.on('SIGINT', () => shutdownWithCleanup('SIGINT'));
3771
+ process.on('SIGTERM', () => shutdownWithCleanup('SIGTERM'));
3772
+ // ── Local App (hybrid node) ────────────────────────────────────────
3773
+ // `isAuthenticated` is checked by loadInstalledApps when an app's
3774
+ // manifest has `requiresAuth: true` (currently just the avatar
3775
+ // hive). When the daemon is signed out the avatar hive is skipped
3776
+ // entirely — no buildings, no IPC routes, no overlay window.
3777
+ // After sign-out the tray restarts the daemon, which re-enters
3778
+ // this code path with isConfigured() === false and the avatar
3779
+ // disappears cleanly. Sign-in flips it back via the same restart.
3780
+ const appManager = new LocalAppManager({
3781
+ dataDir: join(CONFIG_DIR, 'apps'),
3782
+ port: config.localAppPort ?? 9876,
3783
+ host: '127.0.0.1',
3784
+ // AGENT_IDENTITY_SECRET is injected so buildings CAN verify agent
3785
+ // tokens independently if they want to; the daemon also verifies
3786
+ // them itself on the app-rpc path and passes a CallerContext. The
3787
+ // resolve() call also generates+persists the secret at boot so the
3788
+ // local agent-identity flow works out of the box.
3789
+ // `broadcast` is how a building PUSHES to its UI. LocalHive constructs
3790
+ // buildings with the Durable Object signature — `new BuildingClass(state,
3791
+ // env)` — so a building written to take a `{ broadcast }` options bag gets
3792
+ // a LocalState in that slot and silently falls back to a no-op. The avatar
3793
+ // hive did exactly that: every action worked, chat history recorded, and
3794
+ // not one frame ever reached the overlay window. Putting the emitter on
3795
+ // `env` puts it in the argument the host actually passes.
3796
+ env: {
3797
+ DAEMON: true,
3798
+ AGENT_IDENTITY_SECRET: resolveAgentIdentitySecret(),
3799
+ broadcast: (msg) => ipc.broadcast(msg),
3800
+ /**
3801
+ * How a hive reaches its WORKSPACE — the same bridge `daemon-mcp-clearly`
3802
+ * uses, handed to buildings directly.
3803
+ *
3804
+ * A hive that needs a metered operation (transcription, generation) must
3805
+ * NOT hold a provider key and call out itself: that bills the provider to
3806
+ * the user, so the workspace AI pool never sees it and the operation
3807
+ * earns nothing. Routing through the worker puts the key in a wrangler
3808
+ * secret and the spend on the credit ladder, where it belongs.
3809
+ *
3810
+ * Generous timeout because these are agent operations — a transcription
3811
+ * or a render, not a status ping — and the relay's 10s default would fail
3812
+ * exactly the interesting ones.
3813
+ */
3814
+ callWorkspace: (action, args = {}) => relay.sendRequest({ ...args, type: action }, 120_000),
3815
+ },
3816
+ isAuthenticated: () => relay.isConfigured(),
3817
+ });
3818
+ daemonAppManager = appManager;
3819
+ // ── Mount the beehaven surface as FIRST-PARTY TOOLS for daemon claude ──
3820
+ //
3821
+ // Both halves of the bridge only exist here: `relay` owns the socket to the
3822
+ // worker, `appManager` owns the local buildings. daemon-omni owns the
3823
+ // session. So daemon.ts does the wiring and neither module learns about the
3824
+ // other — the same split `setTierResolver` uses.
3825
+ //
3826
+ // What this changes for the agent: `canvas_add_text` stops being
3827
+ // "shell out to `beehaven call`, hope the JSON survives quoting" and becomes
3828
+ // a typed tool with a Zod-checked signature, called in-process, that the
3829
+ // permission gate can name. The 28 tools were written for exactly this and
3830
+ // had never once been mounted.
3831
+ /**
3832
+ * EVERY TOOL CALL THE DAEMON'S OWN CLAUDE MAKES, ATTRIBUTED — see the note on `setClearlyTools`.
3833
+ *
3834
+ * ⚠ ONE HELPER, TWO SERVERS. `clearly` and `principal` are separate MCP servers with separate
3835
+ * tool prefixes and separate tier rules, and they are the same CALLER. Two copies of this closure
3836
+ * is two chances for one of them to drift back to a raw dispatch — which is exactly what the
3837
+ * first version of this fix did, wrapping one and leaving the other.
3838
+ */
3839
+ const attributedRpc = (action, args) => {
3840
+ const who = daemonToolIdentity();
3841
+ if (!who) {
3842
+ return Promise.reject(new Error('No agent identity for this install, so the call would run as the account owner, '
3843
+ + 'ungated and unattributed. Run: beehaven agent login <name>'));
3844
+ }
3845
+ ensureAgentPresence(who.agentId);
3846
+ return relay.sendRequest({
3847
+ type: 'agent-invoke',
3848
+ agentId: who.agentId,
3849
+ action,
3850
+ input: args ?? {},
3851
+ ...(who.sessionId ? { sessionId: who.sessionId } : {}),
3852
+ }, 120_000);
3853
+ };
3854
+ try {
3855
+ const { setClearlyTools } = await import('./daemon-omni.js');
3856
+ const { driveAvatarAsMonet } = await import('./daemon-avatar.js');
3857
+ setClearlyTools({
3858
+ // Worker RPCs. The generous timeout is deliberate: these are agent
3859
+ // actions (generate, render, perceive), not status pings, and the
3860
+ // relay's 10s default would fail the interesting ones.
3861
+ /**
3862
+ * ⚠⚠ WRAPPED IN `agent-invoke`. WITHOUT THIS THE DAEMON'S CLAUDE WAS THE LEAST ACCOUNTABLE
3863
+ * CALLER IN THE SYSTEM (2026-08-29).
3864
+ *
3865
+ * This was a raw `relay.sendRequest({ ...args, type: action })`. It works, and it bypasses
3866
+ * everything agent identity exists for: no `permission_scope` / `tool_allowlist` gate, no
3867
+ * `agent_activity` row, no attribution. Measured before the change — `beehaven omni` asked
3868
+ * daemon Claude to list compositions, it called `mcp__clearly__canvas_list_compositions`, and
3869
+ * the activity table gained NOTHING. The identical action typed as `beehaven call` is
3870
+ * attributed, gated and logged, because the CLI has routed through `agent-invoke` since
3871
+ * 2026-08-28.
3872
+ *
3873
+ * So one Claude had two doors into one workspace with different privilege on each, and the
3874
+ * more convenient door was the ungoverned one. The desktop overlay's agent-activity
3875
+ * notifications read that same table, so daemon Claude's own work was invisible to them too —
3876
+ * which reads as the notifications being broken rather than as the caller being anonymous.
3877
+ *
3878
+ * ⚠ THE IDENTITY IS THE ONE ITS CLI SPAWNS ALREADY USE — the desktop binding. An agent whose
3879
+ * MCP calls and shell calls file under different names is worse than one that is not
3880
+ * attributed at all, because the log then asserts something false.
3881
+ *
3882
+ * ⚠⚠ NO IDENTITY IS A REFUSAL, NEVER A FALL-THROUGH TO THE RAW DISPATCH. Falling back is
3883
+ * exactly the pattern deleted from `beehaven call`, where an expired token silently
3884
+ * ESCALATED to the account owner. `daemonToolIdentity` holds the expiry check so this site
3885
+ * has one decision to make, and the message names the fix.
3886
+ */
3887
+ invokeRpc: attributedRpc,
3888
+ // The daemon's OWN avatar, driven locally as Monet — not a relay
3889
+ // round-trip to the cloud and back to this same machine.
3890
+ driveAvatar: (action, args) => driveAvatarAsMonet(daemonAppManager, action, args),
3891
+ log: (level, msg, meta) => {
3892
+ if (level === 'error')
3893
+ console.warn(` [omni-tools] ${msg}`, meta ?? '');
3894
+ },
3895
+ });
3896
+ console.log(' [omni] beehaven tools mounted for daemon claude');
3897
+ /**
3898
+ * ── Omni's OWN surface: the principal ──
3899
+ *
3900
+ * These are the tools that make daemon claude OMNI rather than a generic
3901
+ * Claude Code session — reply, ask, propose, the board, design docs, the
3902
+ * windows on someone's screen. They lived in the Mac app and were handed to
3903
+ * a SECOND `query()` that Electron spawned, which is what put two `claude`
3904
+ * processes on one machine and one BYOK login.
3905
+ *
3906
+ * ⚠ Mounted here, beside `setClearlyTools`, for the same reason: the context
3907
+ * they need (the relay, the session id, the emit channel) exists only in this
3908
+ * closure. `mountPrincipalTools` takes all three at once so the sessionId on
3909
+ * an emitted event and on a blocking question cannot disagree.
3910
+ */
3911
+ const { mountPrincipalTools, setOmniIdentity, hasWindowSurface, activeTurnSessionId } = await import('./daemon-omni.js');
3912
+ // Who Omni IS, registered once so every entry point — the Mac app, the
3913
+ // avatar panel, `beehaven agent` — gets the same agent rather than an
3914
+ // anonymous Claude Code session holding Omni's tools.
3915
+ const { OMNI_IDENTITY } = await import('./daemon-omni-identity.js');
3916
+ setOmniIdentity(OMNI_IDENTITY);
3917
+ mountPrincipalTools({
3918
+ /**
3919
+ * ⚠⚠ THE SAME WRAPPER, AND THIS SERVER NEEDED IT MORE. `mcp__principal__*` dispatches
3920
+ * `document-create`, `document-append`, `ticket-create`, `ticket-update`, `work-status` and
3921
+ * `agent-memory-remember` — every one a workspace WRITE, and the last of those is incoherent
3922
+ * without an agent, since a memory belongs to one by definition.
3923
+ *
3924
+ * ⚠ IT WAS MISSED ON THE FIRST PASS and a guard caught it, which is the argument for pinning
3925
+ * the SHAPE of the old dispatch rather than the one call site that had it.
3926
+ *
3927
+ * ⚠ `reply_to_principal` IS UNAFFECTED AND MUST STAY SO. It emits rather than dispatching, so
3928
+ * it never reaches this. That matters: it is the ONLY channel to the user, and a gate that
3929
+ * could deny it produces an agent that does its work and then cannot say anything.
3930
+ */
3931
+ invokeRpc: attributedRpc,
3932
+ // One session per daemon today. When Omni becomes multi-session this must
3933
+ // become the id of the session the tool call arrived on, not a constant.
3934
+ // ⚠ The session the CURRENT TURN belongs to, not a constant. A hardcoded
3935
+ // id broadcasts every reply under a session nobody is subscribed to — the
3936
+ // tool succeeds and the user sees silence.
3937
+ sessionId: activeTurnSessionId,
3938
+ // Floating windows are the ONE capability a terminal user does not get.
3939
+ // Declared by the surface (`omni:surface`), never inferred.
3940
+ canOpenWindows: hasWindowSurface,
3941
+ });
3942
+ console.log(' [omni] principal tools mounted (reply · ask · board · design)');
3943
+ }
3944
+ catch (err) {
3945
+ // Non-fatal by design: without this the session is plain Claude Code,
3946
+ // which still works. Failing the daemon over a tool surface would be a
3947
+ // worse outcome than a session with fewer tools.
3948
+ console.warn(` [omni] beehaven tool mount failed: ${err?.message ?? err}`);
3949
+ }
3950
+ appManager.on('started', ({ port, buildings }) => {
3951
+ console.log(` [app] Local app listening on ws://127.0.0.1:${port} — buildings: ${buildings.join(', ')}`);
3952
+ // Announce to cloud (include session info so Claude sees the full picture)
3953
+ const status = appManager.status();
3954
+ status.sessions = ipc.getSessionInfo();
3955
+ status.connectedBuilding = connectedBuilding;
3956
+ relay.sendLocalAppStatus(status);
3957
+ });
3958
+ appManager.on('building-registered', (name) => {
3959
+ console.log(` [app] Registered building: ${name}`);
3960
+ });
3961
+ appManager.on('error', (err) => {
3962
+ console.warn(` [app] ${err.message}`);
3963
+ });
3964
+ appManager.on('info', (msg) => {
3965
+ console.log(` [app] ${msg}`);
3966
+ });
3967
+ // Route cloud requests to local buildings via relay
3968
+ relay.onLocalAppFetch = async (type, id, url, method, headers, body) => {
3969
+ if (!appManager.running || !appManager.hasBuilding(type)) {
3970
+ return { status: 404, body: `Building "${type}" not found locally` };
3971
+ }
3972
+ const request = new Request(url, {
3973
+ method,
3974
+ headers,
3975
+ body: method !== 'GET' ? body : undefined,
3976
+ });
3977
+ const response = await appManager.routeFetch(type, id, request);
3978
+ const responseBody = await response.text();
3979
+ const responseHeaders = {};
3980
+ response.headers.forEach((v, k) => {
3981
+ responseHeaders[k] = v;
3982
+ });
3983
+ return { status: response.status, headers: responseHeaders, body: responseBody };
3984
+ };
3985
+ // #74 — cross-hive RPC from the cloud Workspace. The cloud emits
3986
+ // daemon-rpc-request, we look up the hive's default building,
3987
+ // invoke building.call(action, input), and the relay forwards the
3988
+ // resolved value back as daemon-rpc-response. Used for clipImport,
3989
+ // brandSync, projectCreate, clipSearch, clipFetch — i.e. every
3990
+ // workspace `_daemonRpc(...)` call site.
3991
+ relay.onDaemonRpcRequest = async (hive, action, input) => {
3992
+ const startedAt = Date.now();
3993
+ if (!appManager.running) {
3994
+ const err = `appManager not running — hive "${hive}" cannot be reached`;
3995
+ console.log(`[daemon-rpc] FAIL ${hive}:${action} — ${err}`);
3996
+ throw new Error(err);
3997
+ }
3998
+ if (!appManager.hasBuilding(hive)) {
3999
+ const err = `Building "${hive}" not loaded — is the hive installed?`;
4000
+ console.log(`[daemon-rpc] FAIL ${hive}:${action} — ${err}`);
4001
+ throw new Error(err);
4002
+ }
4003
+ // Look up the default instance ("default") and call its action.
4004
+ // Mirrors the IPC RPC path at daemon.ts:2015. Hives without an
4005
+ // exposed `.call()` are rejected — fetch-style routing would
4006
+ // require us to invent a URL + parse JSON bodies, defeating the
4007
+ // point of having a typed RPC bridge.
4008
+ const instance = appManager.getBuilding(hive, 'default');
4009
+ if (!instance) {
4010
+ const err = `Building "${hive}/default" not found`;
4011
+ console.log(`[daemon-rpc] FAIL ${hive}:${action} — ${err}`);
4012
+ throw new Error(err);
4013
+ }
4014
+ const { instance: workspace } = instance;
4015
+ if (typeof workspace.call !== 'function') {
4016
+ const err = `Hive "${hive}" does not expose .call() — only fetch-style routing supported`;
4017
+ console.log(`[daemon-rpc] FAIL ${hive}:${action} — ${err}`);
4018
+ throw new Error(err);
4019
+ }
4020
+ // Wire a broadcast function so hive events reach IPC subscribers
4021
+ // (and AgentActivityBadge via the relay) even on this code path.
4022
+ if (typeof workspace.broadcast !== 'function') {
4023
+ workspace.broadcast = (msg) => {
4024
+ ipc.broadcast({ ...msg, building: hive });
4025
+ };
4026
+ }
4027
+ console.log(`[daemon-rpc] → ${hive}:${action}`);
4028
+ try {
4029
+ const result = await workspace.call(action, input);
4030
+ const elapsed = Date.now() - startedAt;
4031
+ console.log(`[daemon-rpc] ← ${hive}:${action} OK ${elapsed}ms`);
4032
+ return result;
4033
+ }
4034
+ catch (err) {
4035
+ const elapsed = Date.now() - startedAt;
4036
+ console.log(`[daemon-rpc] ← ${hive}:${action} ERR ${elapsed}ms: ${String(err?.message ?? err).slice(0, 200)}`);
4037
+ throw err;
4038
+ }
4039
+ };
4040
+ /**
4041
+ * ⚠⚠ THE FIRST-PARTY HIVE BOOTSTRAP IS GONE (2026-08-28) — `scanInstalledApps` reads them from
4042
+ * the bundle IN PLACE instead. See `FIRST_PARTY_HIVES` there for the list and the reasoning.
4043
+ *
4044
+ * What used to be here was `ensureFirstPartyHives(['avatar','vector','image'], …)`, which
4045
+ * `cpSync`s each hive's WHOLE project directory — `src/`, `scripts/`, `docs/`, `tsconfig.json`,
4046
+ * `package.json`, and for the avatar a `ui/` the product then rendered from — into
4047
+ * `~/.beehaven/apps`. That is how first-party SOURCE came to live outside version control, in a
4048
+ * directory this same call would later overwrite with `force: true`. `~/.beehaven` holds runtime
4049
+ * state now: config, credentials, logs.
4050
+ *
4051
+ * The avatar building remains available through the same in-place bundle loader, so
4052
+ * `beehaven connect avatar` works on a fresh Mac install without a manual hive install.
4053
+ *
4054
+ * ⚠ `ensureFirstPartyHives` IS NOT DELETED. Third-party `.hive` installs still go through the
4055
+ * same install path, and the function is the reference for that shape. It simply has no caller
4056
+ * here any more.
4057
+ */
4058
+ // Load buildings from linked project + installed .hive packages.
4059
+ // Either source can contribute to the same local app server; we start the
4060
+ // app server after both have had a chance to register buildings.
4061
+ (async () => {
4062
+ let total = 0;
4063
+ if (config.linkedProject) {
4064
+ try {
4065
+ const loaded = await appManager.loadFromProject(config.linkedProject);
4066
+ total += loaded.length;
4067
+ }
4068
+ catch {
4069
+ /* logged via appManager error event */
4070
+ }
4071
+ }
4072
+ try {
4073
+ // First-party hives live in the read-only Mac bundle and are not copied
4074
+ // into ~/.beehaven/apps. Load them explicitly or the catalog can mention
4075
+ // avatar while the local app server has no avatar building.
4076
+ const bundled = await appManager.loadInstalledApps(bundledFirstPartyRoots());
4077
+ const installed = await appManager.loadInstalledApps();
4078
+ const allInstalled = [...bundled, ...installed];
4079
+ total += allInstalled.reduce((n, h) => n + h.buildings.length, 0);
4080
+ for (const h of allInstalled) {
4081
+ console.log(` [app] Installed: ${h.name}@${h.version}${h.frontendDir ? ' (frontend)' : ''}`);
4082
+ }
4083
+ // Hand the dashboard the frontend registry so it can serve
4084
+ // /apps/<name>/ static routes. Frontends-only apps (no
4085
+ // buildings) still get mounted via this path. The dashboard
4086
+ // serves from the app ROOT so a UI can fetch its own shipped
4087
+ // recipes and assets with stable /apps/<name>/... paths.
4088
+ if (dashboard) {
4089
+ dashboard.setAppFrontends(allInstalled.map((h) => ({
4090
+ name: h.name,
4091
+ appRoot: h.appDir,
4092
+ manifest: h.manifest,
4093
+ })));
4094
+ // Bridge the dashboard's /apps/ws WebSocket to the IPC bus
4095
+ // so browser frontends become virtual IPC clients — same
4096
+ // pipeline, app-rpc, broadcast fan-out as Claude's path.
4097
+ dashboard.setAppBridge({
4098
+ createVirtualClient: (send, subs) => ipc.createVirtualClient(send, subs),
4099
+ injectMessage: (id, msg) => ipc.injectMessage(id, msg),
4100
+ releaseVirtualClient: (id) => ipc.releaseVirtualClient(id),
4101
+ });
4102
+ // Marketplace provider — reads installed apps from the
4103
+ // app manager and fetches available apps from clearly.sh.
4104
+ dashboard.setMarketplaceCatalogProvider(async () => {
4105
+ const liveCatalog = appManager.getAgentCatalog();
4106
+ const installedNames = new Set(liveCatalog.map((h) => h.name));
4107
+ const catalog = await getAvailableApps();
4108
+ const available = catalog
4109
+ .filter((h) => isPubliclyVisibleApp(h))
4110
+ .filter((h) => !installedNames.has(h.name));
4111
+ return { installed: liveCatalog, available };
4112
+ });
4113
+ }
4114
+ }
4115
+ catch (err) {
4116
+ console.warn(` [app] failed to load installed apps: ${err?.message ?? err}`);
4117
+ }
4118
+ if (total > 0) {
4119
+ appManager.start().catch(() => { });
4120
+ }
4121
+ })();
4122
+ // ── Dashboard (local observability UI) ──────────────────────────────
4123
+ dashboard = new Dashboard({
4124
+ port: config.dashboardPort ?? 9800,
4125
+ host: '127.0.0.1',
4126
+ explorerTarget: `http://127.0.0.1:${config.devPort ?? 8787}`,
4127
+ explorerSecret: config.explorerSecret ?? config.adminSecret ?? '',
4128
+ projectDir: config.linkedProject ?? '',
4129
+ });
4130
+ dashboard
4131
+ .start()
4132
+ .then(() => {
4133
+ console.log(` [dashboard] Beehaven Dashboard at ${dashboard.url}`);
4134
+ })
4135
+ .catch((err) => {
4136
+ console.warn(` [dashboard] Failed to start: ${err.message}`);
4137
+ });
4138
+ // Forward daemon events to dashboard
4139
+ watcher.on('event', (event) => {
4140
+ dashboard.pushEvent({
4141
+ type: event.hook_event_name || event.type || 'unknown',
4142
+ timestamp: event.timestamp || Date.now(),
4143
+ data: {
4144
+ tool: event.tool_name,
4145
+ session: event.session_id,
4146
+ project: event.cwd?.split('/').pop(),
4147
+ },
4148
+ });
4149
+ });
4150
+ // Forward telemetry snapshots to dashboard as metrics
4151
+ const pushDashboardMetrics = () => {
4152
+ if (dashboard?.running) {
4153
+ dashboard.pushMetrics(metrics.snapshot());
4154
+ }
4155
+ };
4156
+ setInterval(pushDashboardMetrics, 5_000);
4157
+ // Write a small status file the Mac app's tray can read to compute
4158
+ // service-level health (beyond the "process is alive" PID check). We
4159
+ // mirror relay connectivity + pending RPC count + last error here.
4160
+ // The tray polls this file on every menu rebuild; we refresh on a
4161
+ // 2s tick plus on every relay state transition (below in connectWs/close).
4162
+ const STATUS_FILE = join(CONFIG_DIR, 'status.json');
4163
+ const { STATUS_SCHEMA_VERSION, validateStatusSnapshot } = await import('./shared/status/schema.js');
4164
+ const writeStatusFile = () => {
4165
+ try {
4166
+ const snapshot = {
4167
+ schemaVersion: STATUS_SCHEMA_VERSION,
4168
+ pid: process.pid,
4169
+ updatedAt: Date.now(),
4170
+ relayConnected: relay.isConnected(),
4171
+ relayConfigured: relay.isConfigured(),
4172
+ relayEndpoint: relay.config?.endpoint ?? null,
4173
+ pendingRequests: relay.pendingRequests?.size ?? 0,
4174
+ reconnectAttempts: relay.reconnectAttempts ?? 0,
4175
+ version: currentVersion,
4176
+ };
4177
+ // Self-validation: if any field drifts (wrong type from an
4178
+ // internal getter, missing version, etc.) we'd rather surface a
4179
+ // warning than write a snapshot the tray will reject. Cheap —
4180
+ // the validator is ~80 lines of if-checks, runs in microseconds.
4181
+ const v = validateStatusSnapshot(snapshot);
4182
+ if (!v.ok) {
4183
+ logger.warn('daemon', `status.json snapshot failed self-validation: ${v.errors.join(', ')}`);
4184
+ return;
4185
+ }
4186
+ writeFileSync(STATUS_FILE, JSON.stringify(v.value, null, 2), { mode: 0o600 });
4187
+ }
4188
+ catch {
4189
+ /* non-critical */
4190
+ }
4191
+ };
4192
+ setInterval(writeStatusFile, 2_000);
4193
+ writeStatusFile();
4194
+ // Start watcher + relay + health watchdog
4195
+ watcher.start();
4196
+ health.startWatchdog();
4197
+ // Pull avatar settings from the cloud on every relay (re)connect.
4198
+ // Without this, a daemon that started while offline (or that
4199
+ // missed a settings-changed broadcast during sleep) keeps the
4200
+ // hardcoded godzilla-default until the user mutates settings
4201
+ // again — which surfaces as "web shows librarian, Mac shows
4202
+ // godzilla". The handler is idempotent: applyCloudSettings is a
4203
+ // no-op when the patch matches current state.
4204
+ relay.onConnected = () => {
4205
+ // Rehydrate identities that were still logged in when this daemon was replaced. The local
4206
+ // projection is intentionally cleared in AgentSessions' constructor, but the stored agent
4207
+ // record is the durable intent; without this bridge a normal app/daemon restart looks exactly
4208
+ // like two agents logging out and the desktop removes both avatars.
4209
+ setTimeout(() => {
4210
+ try {
4211
+ void agentSessions.restorePersistedSessions(listAgents().map(({ record }) => record));
4212
+ }
4213
+ catch {
4214
+ /* best-effort; the relay reconnect loop remains healthy */
4215
+ }
4216
+ }, 350);
4217
+ // Defer one tick — the open handler still has heartbeat work
4218
+ // queued, and the avatar building may not be loaded yet on
4219
+ // first connect (loadInstalledApps fires in parallel). The
4220
+ // sync retries internally if the building isn't ready.
4221
+ setTimeout(() => {
4222
+ syncAvatarFromCloud().catch(() => { });
4223
+ }, 250);
4224
+ // Register as a beehive the moment the WS opens so the node appears in
4225
+ // the swarm immediately; syncInstalledAppsToCloud + the periodic tick
4226
+ // below re-register with the full capability list once hives finish
4227
+ // loading. Deferred slightly so the capability scan sees loaded hives.
4228
+ setTimeout(() => {
4229
+ try {
4230
+ registerBeehive();
4231
+ }
4232
+ catch {
4233
+ /* best-effort */
4234
+ }
4235
+ }, 500);
4236
+ // #111 — if startup rehydrated a Claude credential from keychain,
4237
+ // tell the worker now that the WS is open. Worker handler at
4238
+ // `case 'canvas-credential-rehydrated':` refreshes daemon_confirmed_at
4239
+ // on the existing claude_credentials row + broadcasts
4240
+ // claude:credential-changed so the web Send button unhides
4241
+ // immediately. Without this, the UI waits up to 8s for the next
4242
+ // claude-credential-status poll after a daemon restart.
4243
+ if (rehydratedCredential) {
4244
+ try {
4245
+ relay.wsSend({
4246
+ type: 'canvas-credential-rehydrated',
4247
+ credType: rehydratedCredential.credType,
4248
+ maskedKey: rehydratedCredential.maskedKey,
4249
+ });
4250
+ logger.debug('daemon', `Sent canvas-credential-rehydrated to worker (${rehydratedCredential.credType})`);
4251
+ }
4252
+ catch (err) {
4253
+ logger.warn('daemon', `canvas-credential-rehydrated send failed: ${err?.message ?? err}`);
4254
+ }
4255
+ }
4256
+ };
4257
+ await relay.start();
4258
+ // Handle expired token — retry login interactively unless headless (Electron)
4259
+ if (relay.isConfigured() && !relay.isConnected() && !process.env.BEEHAVEN_NO_REPL) {
4260
+ console.log(' Session expired. Please log in again.');
4261
+ const { login } = await import('./setup-relay.js');
4262
+ const loggedIn = await login();
4263
+ if (!loggedIn) {
4264
+ process.exit(1);
4265
+ }
4266
+ const fresh = Relay.loadConfig();
4267
+ if (fresh) {
4268
+ relay.configure(fresh.token, fresh.endpoint);
4269
+ await relay.start();
4270
+ }
4271
+ }
4272
+ else if (relay.isConfigured() && !relay.isConnected()) {
4273
+ // Only self-refresh if the token is ACTUALLY expired. A valid token that merely hasn't
4274
+ // registered a connection yet (the initial `relay.start()` is async) just needs the
4275
+ // reconnect loop — NOT a blocking `refreshIdToken()`, whose keychain `execSync` write
4276
+ // hangs in a headless/no-GUI context (the "stuck at attempting self-refresh" symptom).
4277
+ let tokenExpired = true;
4278
+ try {
4279
+ const cfg = Relay.loadConfig();
4280
+ if (cfg?.token) {
4281
+ const payload = JSON.parse(Buffer.from(cfg.token.split('.')[1], 'base64').toString());
4282
+ tokenExpired = !payload.exp || Date.now() / 1000 > payload.exp - 60;
4283
+ }
4284
+ }
4285
+ catch {
4286
+ tokenExpired = true;
4287
+ }
4288
+ if (tokenExpired) {
4289
+ logger.info('relay', 'Token expired — attempting self-refresh...');
4290
+ const refreshed = await relay.refreshIdToken();
4291
+ if (refreshed) {
4292
+ const fresh = Relay.loadConfig();
4293
+ if (fresh) {
4294
+ relay.configure(fresh.token, fresh.endpoint);
4295
+ await relay.start();
4296
+ }
4297
+ }
4298
+ else {
4299
+ logger.warn('relay', 'Self-refresh failed — sign in from the Clearly Dev Mac app tray to re-authenticate');
4300
+ }
4301
+ }
4302
+ else {
4303
+ // Valid token — kick a (re)connect and let the reconnect loop settle it.
4304
+ logger.info('relay', 'Token valid — connecting via the reconnect loop');
4305
+ relay.start().catch(() => { });
4306
+ }
4307
+ }
4308
+ // Start periodic token self-refresh (every 50 min)
4309
+ relay.startTokenRefresh();
4310
+ // ── .clearly/ bidirectional sync + folder sync for linked projects ──
4311
+ // (`clearlySyncs` / `folderWatchers` are declared up by the shutdown handler — see the
4312
+ // note there. Re-declaring them here is what put them in the handler's dead zone.)
4313
+ if (relay.isConnected()) {
4314
+ const linked = getLinkedFolders();
4315
+ for (const [folderPath, entry] of Object.entries(linked)) {
4316
+ const brandId = entry.brandId;
4317
+ // .clearly/ context sync (ClearlySync) — DISABLED 2026-07 (API-first).
4318
+ // Retired in favour of writing workspace content DIRECTLY to the DO via
4319
+ // MCP / the beehaven CLI (clearly_context_write, note-update, remember,
4320
+ // clearly_ticket_create, …). The DO is the single source of truth; the
4321
+ // old ".clearly/ folder ↔ DO" mirror added a SECOND source of truth plus
4322
+ // a reconciliation surface (md5 hashes / remote versions / 2s debounce /
4323
+ // .sync.json) for a job those tool calls already do — always fresh, no
4324
+ // conflicts. Nothing linked folders in practice, so this was inert anyway.
4325
+ // Re-enable ONLY if the local-file-materialisation model is deliberately
4326
+ // revived. Local-folder strategy going forward = the daemon local-tools
4327
+ // bridge (cloud agent → scoped local fs/exec via the daemon), NOT a mirror.
4328
+ /*
4329
+ try {
4330
+ const sync = new ClearlySync({
4331
+ projectPath: folderPath,
4332
+ projectId: brandId,
4333
+ callAction: (action, input) => relay.sendRequest({ ...input, type: action }),
4334
+ });
4335
+ sync.on('pulled', () => logger.info('sync', `Synced context for ${folderPath}`));
4336
+ sync.on('pushed', () => logger.info('sync', `Pushed changes from ${folderPath}`));
4337
+ sync.on('pull-error', (err) => logger.warn('sync', `Pull error: ${err}`));
4338
+ sync.on('push-error', (err) => logger.warn('sync', `Push error: ${err}`));
4339
+ sync.start().catch(() => {});
4340
+ clearlySyncs.push(sync);
4341
+ } catch (err: any) {
4342
+ logger.warn('sync', `Failed to start sync for ${folderPath}: ${err.message}`);
4343
+ }
4344
+ */
4345
+ // Folder watcher — syncs local file changes to cloud (R2 + DO)
4346
+ try {
4347
+ const fw = new FolderWatcher(folderPath, brandId, relay);
4348
+ fw.on('syncing', (n) => logger.info('sync', `Uploading ${n} file(s) from ${folderPath}`));
4349
+ fw.on('synced', (n) => logger.info('sync', `Synced ${n} file(s) from ${folderPath}`));
4350
+ fw.on('error', (err) => logger.warn('sync', `Watcher error for ${folderPath}: ${err.message}`));
4351
+ fw.start();
4352
+ folderWatchers.push(fw);
4353
+ logger.info('sync', `Watching ${folderPath} → project ${brandId}`);
4354
+ }
4355
+ catch (err) {
4356
+ logger.warn('sync', `Failed to start watcher for ${folderPath}: ${err.message}`);
4357
+ }
4358
+ }
4359
+ }
4360
+ // ── Cloud→Local file sync ──
4361
+ // When web clients upload/modify files, the DO broadcasts file-changed to daemons.
4362
+ // Download changed files to the appropriate linked folder.
4363
+ relay.onFileChanged = async (msg) => {
4364
+ const { action, file, brandId } = msg;
4365
+ if (!file?.path || !brandId)
4366
+ return;
4367
+ // Find the local folder linked to this brandId
4368
+ const linked = getLinkedFolders();
4369
+ let localRoot = null;
4370
+ for (const [folderPath, entry] of Object.entries(linked)) {
4371
+ if (entry.brandId === brandId) {
4372
+ localRoot = folderPath;
4373
+ break;
4374
+ }
4375
+ }
4376
+ if (!localRoot)
4377
+ return;
4378
+ const localPath = join(localRoot, file.path);
4379
+ if (action === 'delete') {
4380
+ try {
4381
+ unlinkSync(localPath);
4382
+ logger.info('sync', `Deleted ${localPath}`);
4383
+ }
4384
+ catch { }
4385
+ return;
4386
+ }
4387
+ // Download from R2
4388
+ try {
4389
+ const resp = await relay.sendRequest({
4390
+ type: 'get-download-url',
4391
+ key: file.storagePath || file.url,
4392
+ });
4393
+ if (!resp?.url)
4394
+ return;
4395
+ const dl = await fetch(resp.url, { signal: AbortSignal.timeout(30_000) });
4396
+ if (!dl.ok)
4397
+ return;
4398
+ const buffer = Buffer.from(await dl.arrayBuffer());
4399
+ // Ensure parent directory exists
4400
+ const dir = dirname(localPath);
4401
+ mkdirSync(dir, { recursive: true });
4402
+ writeFileSync(localPath, buffer);
4403
+ // Update hash cache in folder watchers to prevent re-upload loop
4404
+ for (const fw of folderWatchers) {
4405
+ if (localPath.startsWith(fw.rootPath)) {
4406
+ const rel = relative(fw.rootPath, localPath);
4407
+ const hash = createHash('md5').update(buffer).digest('hex');
4408
+ fw.fileHashes.set(rel, hash);
4409
+ }
4410
+ }
4411
+ logger.info('sync', `Downloaded ${file.path} → ${localPath} (${buffer.length} bytes)`);
4412
+ }
4413
+ catch (err) {
4414
+ logger.warn('sync', `Download failed (${file.path}): ${err.message}`);
4415
+ }
4416
+ };
4417
+ // Background historical session sync
4418
+ if (relay.isConnected()) {
4419
+ syncHistoricalSessions(relay)
4420
+ .then((result) => {
4421
+ if (result.uploaded > 0) {
4422
+ logger.info('sync', `Historical sync: ${result.uploaded} sessions uploaded`);
4423
+ }
4424
+ })
4425
+ .catch(() => { });
4426
+ }
4427
+ // Post-update health check (verifies relay connects after an auto-upgrade)
4428
+ runPostUpdateHealthCheck(relay);
4429
+ // Auto-update: check hourly, install when idle
4430
+ checkAndAutoUpdate();
4431
+ setInterval(() => checkAndAutoUpdate(), UPDATE_CHECK_INTERVAL);
4432
+ // Telemetry: opt-in via config.shareTelemetry (default off, togglable from cloud)
4433
+ // Sends system stats + agent metrics + git status every 2 minutes
4434
+ let lastActiveCwd = null;
4435
+ watcher.on('event', (e) => {
4436
+ if (e.cwd)
4437
+ lastActiveCwd = e.cwd;
4438
+ });
4439
+ const sendTelemetry = () => {
4440
+ if (!daemonConfig?.shareTelemetry || !relay.isConnected())
4441
+ return;
4442
+ try {
4443
+ const system = collectSystemTelemetry();
4444
+ const agentSnapshot = metrics.snapshot();
4445
+ const git = lastActiveCwd ? collectGitStatus(lastActiveCwd) : null;
4446
+ relay.sendTelemetry({ system, git, agent: agentSnapshot });
4447
+ }
4448
+ catch {
4449
+ /* non-critical */
4450
+ }
4451
+ };
4452
+ setTimeout(sendTelemetry, 10_000);
4453
+ setInterval(sendTelemetry, 120_000);
4454
+ // Reset metrics window every 5 minutes
4455
+ setInterval(() => metrics.resetWindow(), 5 * 60_000);
4456
+ // Re-advertise this beehive every 2 min: refreshes last_seen and, crucially,
4457
+ // self-heals the swarm's live routing map after a Home-DO hibernation dropped
4458
+ // it (the WS survives hibernation, so without this the node would read
4459
+ // "offline" until its next reconnect). Cheap — one WS frame.
4460
+ setInterval(() => {
4461
+ try {
4462
+ registerBeehive();
4463
+ }
4464
+ catch {
4465
+ /* best-effort */
4466
+ }
4467
+ }, 120_000);
4468
+ // Start REPL
4469
+ if (opts?.repl !== false && !process.env.BEEHAVEN_NO_REPL)
4470
+ repl.start();
4471
+ }