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.
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- 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
|
+
}
|