beehaven 0.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
package/dist/relay.js ADDED
@@ -0,0 +1,2723 @@
1
+ // ============================================================================
2
+ // Beehaven Office - Cloud Relay
3
+ // Persistent WebSocket connection to Cloudflare Durable Object relay.
4
+ // Falls back to legacy HTTP POST for old Cloud Function endpoints.
5
+ // ============================================================================
6
+ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, readdirSync, rmSync, chmodSync, } from 'fs';
7
+ import { homedir, hostname, platform } from 'os';
8
+ import { devModeAvailable, devModeRefusal } from './build-channel.js';
9
+ import { join, dirname, resolve, sep } from 'path';
10
+ import { execSync, execFileSync } from 'child_process';
11
+ import WebSocket from 'ws';
12
+ import { logger } from './logger.js';
13
+ import { TerminalBlockParser } from './terminal/block-parser.js';
14
+ import { writeIntegration, cleanupIntegration } from './terminal/shell-integration.js';
15
+ import { transformPath } from './daemon-path-transform.js';
16
+ import { relayUrlFromConfig } from './relay-presets.js';
17
+ // Daemon Omni (Claude Agent SDK) loader removed (destroy-the-treehouse).
18
+ /** Sensitive env vars to strip from PTY sessions */
19
+ const SENSITIVE_ENV_PATTERNS = [
20
+ 'AWS_SECRET_ACCESS_KEY',
21
+ 'AWS_SESSION_TOKEN',
22
+ 'GITHUB_TOKEN',
23
+ 'GH_TOKEN',
24
+ 'DATABASE_URL',
25
+ 'ANTHROPIC_API_KEY',
26
+ 'OPENAI_API_KEY',
27
+ 'SENDGRID_API_KEY',
28
+ 'FIREBASE_TOKEN',
29
+ 'GOOGLE_APPLICATION_CREDENTIALS',
30
+ 'STRIPE_SECRET_KEY',
31
+ 'TWILIO_AUTH_TOKEN',
32
+ 'NPM_TOKEN',
33
+ 'DOCKER_PASSWORD',
34
+ 'KUBECONFIG',
35
+ 'MAILGUN_API_KEY',
36
+ 'RESEND_API_KEY',
37
+ 'OPENROUTER_API_KEY',
38
+ 'ENCRYPTION_KEY',
39
+ 'R2_SIGNING_KEY',
40
+ 'WEBHOOK_SECRET',
41
+ ];
42
+ const SENSITIVE_ENV_SUFFIXES = ['_KEY', '_SECRET', '_TOKEN', '_PASSWORD', '_CREDENTIALS', '_AUTH'];
43
+ /** Allowed endpoint patterns for WebSocket relay */
44
+ const ENDPOINT_WHITELIST = [/\.clearly\.sh$/, /\.workers\.dev$/];
45
+ const CONFIG_DIR = join(homedir(), '.beehaven');
46
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
47
+ // Daemon relay endpoint. The Mac app sets `BEEHAVEN_RELAY_URL` when
48
+ // spawning daemon-entry.js so dev builds dial staging while production
49
+ // builds dial prod. Anyone running the CLI directly can also override
50
+ // this for local relay/staging iteration without rebuilding the Mac
51
+ // bundle. Falls back to prod when unset.
52
+ export const CLEARLY_RELAY_URL = typeof process !== 'undefined' &&
53
+ typeof process.env !== 'undefined' &&
54
+ typeof process.env.BEEHAVEN_RELAY_URL === 'string' &&
55
+ process.env.BEEHAVEN_RELAY_URL.length > 0
56
+ ? process.env.BEEHAVEN_RELAY_URL
57
+ : 'wss://relay.clearly.sh';
58
+ /** Legacy Cloud Function URL — used as fallback when config.endpoint is this */
59
+ const LEGACY_RELAY_URL = 'https://us-central1-clearly-9bd39.cloudfunctions.net/beehiveRelay';
60
+ /** Flush interval — how often projects/docs buffer is sent to the cloud */
61
+ const FLUSH_INTERVAL_MS = 5_000;
62
+ /** Debounce for state sync — immediate-ish but coalesces rapid updates */
63
+ const STATE_DEBOUNCE_MS = 200;
64
+ export class Relay {
65
+ config = null;
66
+ connected = false;
67
+ /** True when stop()/unconfigure() was called — suppresses auto-reconnect
68
+ * on the ws close handler. Cleared when start() is called again. */
69
+ stopped = false;
70
+ tier = 'free';
71
+ profile = null;
72
+ deviceId;
73
+ // ── WebSocket ─────────────────────────────────────────────────────────
74
+ ws = null;
75
+ wsConnecting = false;
76
+ reconnectAttempts = 0;
77
+ reconnectTimer = null;
78
+ /** Watchdog timer + the moment we first noticed we were disconnected. If the
79
+ * relay stays down past the threshold, the watchdog forces a HARD reconnect
80
+ * (terminate any zombie socket + reset backoff + redial). This is the
81
+ * self-heal a HEADLESS / orphaned daemon needs — it has no Mac-app tray
82
+ * parent to restart it, so a wedged WS (e.g. a half-open socket after system
83
+ * sleep) would otherwise leave it "stuck reconnecting" forever. */
84
+ supervisorTimer = null;
85
+ disconnectedSince = null;
86
+ /** Request-response correlation — pending promises keyed by rid */
87
+ pendingRequests = new Map();
88
+ // ── Intervals ─────────────────────────────────────────────────────────
89
+ heartbeatInterval = null;
90
+ flushInterval = null;
91
+ stateDebounceTimer = null;
92
+ wsPingInterval = null;
93
+ wsWakeDetector = null;
94
+ lastTickAt = Date.now();
95
+ awaitingPong = false;
96
+ /** Consecutive app-level pings sent without a pong back, while the event
97
+ * loop was NOT stalled. The home DO's edge auto-pong goes briefly silent
98
+ * while its isolate cold-starts, so a SINGLE miss is normal and must not
99
+ * sever the socket — terminating on the first miss turned one DO
100
+ * cold-start into a relay reconnect that forced ANOTHER cold-start (a
101
+ * self-feeding churn loop). Reset on every pong / stall; terminate only
102
+ * once it reaches MAX_MISSED_PONGS. */
103
+ missedPongs = 0;
104
+ /** How many consecutive un-stalled missed pongs (~15s each) to tolerate
105
+ * before declaring the relay socket dead. 2 ⇒ ~30s of genuine silence,
106
+ * long enough to ride out a home-DO cold-start, short enough to recover
107
+ * a truly dead peer quickly. (The web HiveClient uses 3; the relay's
108
+ * reconnect is heavier — it cold-starts the DO — so 2 keeps it tighter.) */
109
+ MAX_MISSED_PONGS = 2;
110
+ /** Timestamp of the last APP-LEVEL pong ({"type":"pong"}). The keep-alive now
111
+ * heartbeats with the app-level {"type":"ping"} the edge auto-answers
112
+ * (the home DO's setWebSocketAutoResponse pairs ping→pong). A protocol-level
113
+ * ws.ping() is NOT auto-ponged once the DO hibernates, which produced false
114
+ * "No pong" reconnects; this app-level pong is the hibernation-safe signal. */
115
+ appPongAt = 0;
116
+ /** When the 15s pong-watchdog last actually ran. If the gap since the
117
+ * previous run is far larger than the interval, the event loop was
118
+ * frozen (App Nap / heavy work / brief sleep) and a "missed" pong is an
119
+ * artifact of that freeze — not a dead peer. Used to suppress the
120
+ * false-positive terminate that was dropping healthy daemon sockets. */
121
+ lastPongCheckAt = Date.now();
122
+ /** Disposer for the ~/.claude/.credentials.json watcher. Set on
123
+ * first WS open; cleared + re-set on reconnects. The watcher
124
+ * fires canvas-credential-detected to the worker when the user
125
+ * completes the plan-SDK OAuth flow via `claude setup-token`. */
126
+ credentialsWatcherDispose = null;
127
+ // ── Flush buffer ──────────────────────────────────────────────────────
128
+ buffer = { events: [], docs: [] };
129
+ /** Per-project sync details for UI */
130
+ projectSyncDetails = new Map();
131
+ /** Timestamp of most recent successful sync */
132
+ lastSyncAt = 0;
133
+ /** Stats for logging */
134
+ stats = { sent: 0, failed: 0, batches: 0 };
135
+ /** Callback when profile updates from heartbeat (only fires on change) */
136
+ onProfileUpdate = null;
137
+ lastProfileJson = '';
138
+ /** Callback when a browse-files request arrives from the web UI */
139
+ onBrowseFiles = null;
140
+ /** Callback when a file has been teleported from web to local filesystem */
141
+ onFileTeleported = null;
142
+ /** Callback when cloud bee status update arrives */
143
+ onBeeStatus = null;
144
+ /** Callback when cloud bee emits a thought */
145
+ onBeeThought = null;
146
+ /** Callback to route fetch requests to local app buildings */
147
+ onLocalAppFetch = null;
148
+ /**
149
+ * Callback to route cross-hive RPC requests from the cloud workspace
150
+ * to a loaded local hive (#74). The cloud DO emits
151
+ * `daemon-rpc-request { requestId, hive, action, input }`; the
152
+ * daemon's local app manager looks up `hive` (default instance id),
153
+ * invokes `building.call(action, input)`, and the relay sends back
154
+ * `daemon-rpc-response { requestId, ok, result?, error? }`.
155
+ */
156
+ onDaemonRpcRequest = null;
157
+ /** Callback when a broadcast message arrives from the DO (forwarded to IPC clients) */
158
+ onBroadcast = null;
159
+ /** Fires every time the WebSocket transitions to OPEN — initial
160
+ * connect AND every reconnect after sleep / network blip. Use
161
+ * this to re-pull anything the daemon caches from the cloud
162
+ * (avatar settings, profile, …) so missed broadcasts during
163
+ * the offline window self-heal on the next reconnect. */
164
+ onConnected = null;
165
+ /** Callback when a file-changed broadcast arrives (cloud → local file sync) */
166
+ onFileChanged = null;
167
+ /** Callback when PTY produces output (for local IPC forwarding) */
168
+ onPtyOutput = null;
169
+ /** Number of web viewers currently watching (set by DO viewer-count message) */
170
+ viewerCount = 0;
171
+ /** Session events pending batch delivery (legacy HTTP mode only) */
172
+ sessionEventBatch = new Map();
173
+ /** Active PTY sessions: sessionId → pty process */
174
+ ptySessions = new Map();
175
+ /** Per-session OSC-133 block parsers: sessionId → TerminalBlockParser */
176
+ ptyParsers = new Map();
177
+ /** A managed PTY buffers input until its OSC-633 shell integration has printed its first prompt, so
178
+ * the FIRST command runs WITH markers → a terminal-block is emitted (not swallowed by the shell-init
179
+ * race). ptyReady = sessions past that point; ptyInputQueue = input typed before then; ptyReadyTimers
180
+ * = the no-integration fallback flush. */
181
+ ptyReady = new Set();
182
+ ptyInputQueue = new Map();
183
+ ptyReadyTimers = new Map();
184
+ constructor() {
185
+ this.config = Relay.loadConfig();
186
+ // Desktop hosts inject the anonymous install id so workspace RPCs can be joined back to the
187
+ // Mac funnel. Standalone CLI keeps the existing machine-scoped fallback. Both are bounded and
188
+ // never carry a username, path, serial number or account credential.
189
+ this.deviceId = process.env.BEEHAVEN_DEVICE_ID || `${hostname()}-${platform()}`;
190
+ }
191
+ /**
192
+ * Start watching ~/.claude/.credentials.json for OAuth completion.
193
+ * Idempotent — calling repeatedly disposes the prior watcher.
194
+ * Dynamically imports the credential-store module so the relay
195
+ * startup path stays light.
196
+ */
197
+ _startCredentialsWatcher() {
198
+ // Dispose any prior watcher (reconnect scenario).
199
+ if (this.credentialsWatcherDispose) {
200
+ try {
201
+ this.credentialsWatcherDispose();
202
+ }
203
+ catch {
204
+ /* idempotent */
205
+ }
206
+ this.credentialsWatcherDispose = null;
207
+ }
208
+ // Claude credentials-file watcher removed with the daemon Claude Code
209
+ // integration (destroy-the-treehouse).
210
+ }
211
+ // ── Config ────────────────────────────────────────────────────────────
212
+ /** Load relay config from ~/.beehaven/config.json */
213
+ static loadConfig() {
214
+ if (!existsSync(CONFIG_FILE))
215
+ return null;
216
+ try {
217
+ const raw = readFileSync(CONFIG_FILE, 'utf8');
218
+ const parsed = JSON.parse(raw);
219
+ let token = parsed.token;
220
+ if (parsed.tokenStorage === 'keychain') {
221
+ try {
222
+ // Use execSync to call macOS keychain directly (require() fails in ESM)
223
+ token =
224
+ execSync('security find-generic-password -s "beehaven" -a "daemon-token" -w', {
225
+ encoding: 'utf8',
226
+ timeout: 3000,
227
+ stdio: ['pipe', 'pipe', 'ignore'],
228
+ }).trim() || parsed.token;
229
+ }
230
+ catch {
231
+ token = parsed.token;
232
+ }
233
+ }
234
+ /**
235
+ * ⚠⚠ THE DIAL TARGET IS RESOLVED, NEVER READ RAW — `endpoint` OR `relayUrl`.
236
+ *
237
+ * This line was `if (!token || !parsed.endpoint) return null`, and it is the ONLY reader in
238
+ * the codebase that demanded `endpoint` specifically. Everything else goes through
239
+ * `relayUrlFromConfig`, whose own docstring calls `relayUrl` a supported alias — so a config
240
+ * carrying only `relayUrl` was well-formed to every other surface and invisible to this one.
241
+ *
242
+ * ⚠ THE FAILURE IS TOTAL AND SILENT. `null` here means `isConfigured()` is false, so the
243
+ * daemon never dials the relay at all: the tray reads "agent offline", the avatar's menu says
244
+ * "Could not reach the daemon", and `refreshIdToken` — which starts by calling this — returns
245
+ * false and logs "Token refresh failed — will retry in 5 minutes". THREE unrelated-looking
246
+ * symptoms, one missing key, and none of them names it.
247
+ *
248
+ * ⚠⚠ AND `beehaven login` WROTE EXACTLY THAT CONFIG. It spreads `...existing` and never emits
249
+ * a dial target, so it PRESERVES `endpoint` when one is already there and cannot create one.
250
+ * After `logout` (which deletes `endpoint` by name) or on a fresh install where the Mac app's
251
+ * `install()` had not yet run, signing in from the terminal produced a config with valid
252
+ * tokens, a correct `relayUrl`, and no way to connect. Measured: adding the one key to a live
253
+ * broken config took `relayConnected` from false to true with no other change.
254
+ *
255
+ * ⚠ FIXING IT HERE rather than only at the writer is what makes it SELF-HEALING: every config
256
+ * already on a user's disk starts working on the next daemon start, with no migration.
257
+ */
258
+ const endpoint = relayUrlFromConfig(parsed);
259
+ if (!token || !endpoint)
260
+ return null;
261
+ return { token, endpoint, refreshToken: parsed.refreshToken };
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ }
267
+ isConfigured() {
268
+ return this.config !== null;
269
+ }
270
+ /**
271
+ * True iff we actually have a live transport to the cloud relay.
272
+ *
273
+ * For WebSocket mode we prefer `ws.readyState === OPEN` over the cached
274
+ * `.connected` flag — the flag is event-driven and can drift stale if an
275
+ * open/close event is missed (e.g. after system sleep or a silent reconnect
276
+ * race), which made `beehaven rpc` report "Not connected to cloud" while
277
+ * the WebSocket was genuinely up. For HTTP-heartbeat mode we fall back to
278
+ * the flag since there's no socket to inspect.
279
+ */
280
+ isConnected() {
281
+ if (this.useWebSocket) {
282
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
283
+ }
284
+ return this.connected;
285
+ }
286
+ getTier() {
287
+ return this.tier;
288
+ }
289
+ getProfile() {
290
+ return this.profile;
291
+ }
292
+ /** Whether any web clients are watching the office (set by DO viewer-count) */
293
+ get hasViewers() {
294
+ return this.viewerCount > 0;
295
+ }
296
+ /** Configure relay with a new token */
297
+ configure(token, endpoint) {
298
+ this.config = { token, endpoint: endpoint || CLEARLY_RELAY_URL };
299
+ }
300
+ /** Clear relay configuration */
301
+ unconfigure() {
302
+ this.stop();
303
+ this.config = null;
304
+ this.profile = null;
305
+ this.connected = false;
306
+ this.tier = 'free';
307
+ }
308
+ /** Get sync status for UI display */
309
+ getSyncStatus() {
310
+ return {
311
+ connected: this.connected,
312
+ sent: this.stats.sent,
313
+ failed: this.stats.failed,
314
+ lastSyncAt: this.lastSyncAt,
315
+ };
316
+ }
317
+ // ── Endpoint Detection ────────────────────────────────────────────────
318
+ /** Whether this endpoint uses WebSocket (new Cloudflare relay) */
319
+ get useWebSocket() {
320
+ if (!this.config)
321
+ return false;
322
+ const ep = this.config.endpoint;
323
+ return ep.startsWith('wss://') || ep.includes('relay.clearly.sh') || ep.includes('workers.dev');
324
+ }
325
+ /** Get the WebSocket base URL (without auth params) */
326
+ wsBase() {
327
+ if (!this.config)
328
+ return '';
329
+ let base = this.config.endpoint;
330
+ if (base.startsWith('https://'))
331
+ base = base.replace('https://', 'wss://');
332
+ if (!base.startsWith('wss://'))
333
+ base = `wss://${base}`;
334
+ return base.replace(/\/$/, '');
335
+ }
336
+ /** Get the HTTPS base URL for ticket exchange */
337
+ ticketUrl() {
338
+ if (!this.config)
339
+ return '';
340
+ const base = this.wsBase();
341
+ return base.replace('wss://', 'https://').replace('ws://', 'http://') + '/auth/ticket';
342
+ }
343
+ /** Exchange relay token for a single-use ticket (30s TTL) */
344
+ async fetchTicket() {
345
+ if (!this.config)
346
+ return null;
347
+ try {
348
+ let resp = await fetch(this.ticketUrl(), {
349
+ method: 'POST',
350
+ headers: { Authorization: `Bearer ${this.config.token}` },
351
+ signal: AbortSignal.timeout(10_000),
352
+ });
353
+ // Token may have expired — try refreshing and retrying once
354
+ if (resp.status === 401 || resp.status === 403) {
355
+ console.log('[relay] Ticket fetch failed (auth) — refreshing token');
356
+ const refreshed = await this.refreshIdToken();
357
+ if (refreshed && this.config) {
358
+ resp = await fetch(this.ticketUrl(), {
359
+ method: 'POST',
360
+ headers: { Authorization: `Bearer ${this.config.token}` },
361
+ signal: AbortSignal.timeout(10_000),
362
+ });
363
+ }
364
+ }
365
+ if (!resp.ok)
366
+ return null;
367
+ const data = await resp.json();
368
+ return data.ticket;
369
+ }
370
+ catch {
371
+ return null;
372
+ }
373
+ }
374
+ /** Get the HTTP URL for one-shot requests (verifyToken, building, etc.) */
375
+ httpUrl(endpoint) {
376
+ const ep = endpoint || this.config?.endpoint || '';
377
+ if (ep.startsWith('wss://'))
378
+ return ep.replace('wss://', 'https://').replace(/\/$/, '') + '/relay';
379
+ if (ep.startsWith('https://') &&
380
+ (ep.includes('relay.clearly.sh') || ep.includes('workers.dev')))
381
+ return ep.replace(/\/$/, '') + '/relay';
382
+ return ep; // Legacy Cloud Function URL
383
+ }
384
+ // ── Lifecycle ─────────────────────────────────────────────────────────
385
+ /** Start the relay — connects via WebSocket or legacy HTTP */
386
+ async start() {
387
+ if (!this.config)
388
+ return;
389
+ // Re-enable auto-reconnect in case a previous stop() set it.
390
+ this.stopped = false;
391
+ // Validate endpoint against whitelist
392
+ try {
393
+ const endpointHost = new URL(this.config.endpoint.replace('wss://', 'https://').replace('ws://', 'http://')).hostname;
394
+ const allowed = ENDPOINT_WHITELIST.some((re) => re.test(endpointHost)) || endpointHost === 'localhost';
395
+ if (!allowed) {
396
+ console.error(`[relay] Endpoint rejected: ${endpointHost} not in whitelist`);
397
+ return;
398
+ }
399
+ }
400
+ catch {
401
+ console.error('[relay] Invalid endpoint URL');
402
+ return;
403
+ }
404
+ if (this.useWebSocket) {
405
+ await this.connectWs();
406
+ this.startSupervisor();
407
+ }
408
+ else {
409
+ // Legacy HTTP mode
410
+ const ok = await this.sendHeartbeatHttp();
411
+ this.connected = ok;
412
+ if (!ok) {
413
+ console.log('[relay] Failed to connect — check token and endpoint');
414
+ this.scheduleReconnect();
415
+ return;
416
+ }
417
+ console.log(`[relay] Connected to Clearly (tier: ${this.tier})`);
418
+ this.heartbeatInterval = setInterval(() => this.sendHeartbeatHttp(), 30_000);
419
+ this.flushInterval = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
420
+ }
421
+ }
422
+ /** Stop the relay — close WebSocket, flush remaining buffer */
423
+ stop() {
424
+ // Set BEFORE terminate(): the ws close handler checks this to
425
+ // suppress auto-reconnect on intentional shutdown.
426
+ this.stopped = true;
427
+ if (this.heartbeatInterval)
428
+ clearInterval(this.heartbeatInterval);
429
+ if (this.flushInterval)
430
+ clearInterval(this.flushInterval);
431
+ if (this.stateDebounceTimer)
432
+ clearTimeout(this.stateDebounceTimer);
433
+ if (this.wsPingInterval)
434
+ clearInterval(this.wsPingInterval);
435
+ if (this.wsWakeDetector)
436
+ clearInterval(this.wsWakeDetector);
437
+ if (this.supervisorTimer)
438
+ clearInterval(this.supervisorTimer);
439
+ this.supervisorTimer = null;
440
+ this.disconnectedSince = null;
441
+ this.heartbeatInterval = null;
442
+ this.flushInterval = null;
443
+ this.stateDebounceTimer = null;
444
+ this.wsPingInterval = null;
445
+ this.wsWakeDetector = null;
446
+ if (this.reconnectTimer)
447
+ clearTimeout(this.reconnectTimer);
448
+ this.reconnectTimer = null;
449
+ // Reject pending requests
450
+ for (const [, pending] of this.pendingRequests) {
451
+ clearTimeout(pending.timer);
452
+ pending.reject(new Error('Relay stopped'));
453
+ }
454
+ this.pendingRequests.clear();
455
+ // Kill all PTY sessions
456
+ for (const [sid, ptyProcess] of this.ptySessions) {
457
+ try {
458
+ ptyProcess.kill();
459
+ }
460
+ catch { }
461
+ cleanupIntegration(sid);
462
+ }
463
+ this.ptySessions.clear();
464
+ this.ptyParsers.clear();
465
+ // Close WebSocket — use terminate() to avoid hanging on graceful close handshake
466
+ if (this.ws) {
467
+ try {
468
+ this.ws.terminate();
469
+ }
470
+ catch { }
471
+ this.ws = null;
472
+ }
473
+ this.connected = false;
474
+ this.wsConnecting = false;
475
+ // Final flush (fire-and-forget)
476
+ this.flush();
477
+ }
478
+ // ── WebSocket ─────────────────────────────────────────────────────────
479
+ /** Establish WebSocket connection to the Cloudflare relay.
480
+ * Resolves when the connection opens (or immediately on failure). */
481
+ async connectWs() {
482
+ if (!this.config || this.wsConnecting)
483
+ return;
484
+ this.wsConnecting = true;
485
+ // Re-read the persisted token first. The Mac app (or a prior refresh)
486
+ // may have written a fresh idToken while we were looping; without this
487
+ // we'd keep dialing with the stale in-memory copy and never self-heal
488
+ // short of a full daemon restart.
489
+ try {
490
+ const fresh = Relay.loadConfig();
491
+ if (fresh?.token && this.config)
492
+ this.config.token = fresh.token;
493
+ }
494
+ catch { }
495
+ // Use ?token= directly — ticket exchange is unreliable across Cloudflare isolates.
496
+ // The ?token= path is the server's fallback for non-browser clients (CLI, daemon).
497
+ // If token is expired, try refreshing first.
498
+ let token = this.config.token;
499
+ // Quick expiry check — decode JWT exp without verification
500
+ let expiredNoRefresh = false;
501
+ try {
502
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
503
+ const nowSec = Date.now() / 1000;
504
+ if (payload.exp && nowSec > payload.exp - 60) {
505
+ console.log('[relay] Token near/at expiry — refreshing before connect');
506
+ const refreshed = await this.refreshIdToken();
507
+ if (refreshed && this.config)
508
+ token = this.config.token;
509
+ else if (payload.exp && nowSec > payload.exp)
510
+ expiredNoRefresh = true;
511
+ }
512
+ }
513
+ catch { }
514
+ // If the token is already expired AND refresh failed, dialing would just
515
+ // get rejected — spinning the reconnect loop forever with a dead
516
+ // credential (the classic "daemon stuck reconnecting" state). Back off
517
+ // and wait for a fresh token (e.g. the Mac app re-minting one) instead.
518
+ if (expiredNoRefresh) {
519
+ console.warn('[relay] Token expired and refresh failed — backing off without dialing. Reopen the Clearly Dev Mac app to re-authenticate.');
520
+ this.wsConnecting = false;
521
+ if (this.config && !this.stopped)
522
+ this.scheduleReconnect();
523
+ return;
524
+ }
525
+ // The daemon holds ONE socket — to the user's HIVE (`/my/hive/ws`, the per-user
526
+ // multiplexer). The hive opens a server-side portal to the caller's CANONICAL workspace
527
+ // (no `workspaceId` → the hive resolves it from `building_assignments`) and pipes it
528
+ // transparently, so every workspace RPC (canvas-invoke, actions, table sync) round-trips
529
+ // exactly as before — plus the daemon shares the same socket the web app uses. (Plain
530
+ // synchronous URL — no pre-connect HTTP hop — so nothing can stall the dial.)
531
+ const params = new URLSearchParams({
532
+ token,
533
+ client: process.env.BEEHAVEN_CLIENT || 'daemon',
534
+ clientType: process.env.BEEHAVEN_CLIENT_TYPE || 'daemon',
535
+ wsType: 'workspace',
536
+ });
537
+ if (this.deviceId)
538
+ params.set('deviceId', String(this.deviceId).slice(0, 64));
539
+ const url = `${this.wsBase()}/my/hive/ws?${params.toString()}`;
540
+ const ws = new WebSocket(url);
541
+ // Wait for open or first error/close before resolving
542
+ await new Promise((resolve) => {
543
+ const done = () => {
544
+ resolve();
545
+ };
546
+ ws.on('open', () => {
547
+ this.ws = ws;
548
+ this.wsConnecting = false;
549
+ this.connected = true;
550
+ this.reconnectAttempts = 0;
551
+ console.log(`[relay] WebSocket connected to ${this.config.endpoint}`);
552
+ // Notify subscribers that the WebSocket is open so they
553
+ // can pull fresh state. Wrapped because the callback
554
+ // might do RPCs that throw — we never want a subscriber
555
+ // error to crash the open handler.
556
+ try {
557
+ this.onConnected?.();
558
+ }
559
+ catch (err) {
560
+ console.warn(`[relay] onConnected callback threw: ${err?.message ?? err}`);
561
+ }
562
+ // Send heartbeat to get profile + trigger TTL extension
563
+ this.wsSend({ type: 'heartbeat', deviceId: this.deviceId });
564
+ // Announce environment so the bee agent knows about /local/
565
+ this.wsSend({
566
+ type: 'daemon-hello',
567
+ root: homedir(),
568
+ os: platform(),
569
+ shell: process.env.SHELL?.split('/').pop() || 'bash',
570
+ hostname: hostname(),
571
+ });
572
+ // Announce presence
573
+ this.wsSend({ type: 'presence', online: true, deviceId: this.deviceId, tier: this.tier });
574
+ // Start the ~/.claude/.credentials.json watcher — when
575
+ // the user completes the plan-SDK OAuth flow via
576
+ // `claude setup-token`, the CLI writes the file and our
577
+ // watcher fires canvas-credential-detected back to the
578
+ // worker, which flips the credential row + broadcasts to
579
+ // web clients. Idempotent across reconnects — calling
580
+ // start more than once just no-ops because the prior
581
+ // watcher object is replaced on each call.
582
+ this._startCredentialsWatcher();
583
+ // Start flush interval
584
+ if (this.flushInterval)
585
+ clearInterval(this.flushInterval);
586
+ this.flushInterval = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
587
+ // JSON heartbeat removed — was waking the home DO every 20s.
588
+ // Daemon liveness is already driven by WS connection state:
589
+ // `Workspace.onDisconnect` clears `daemonEnvs` + removes the
590
+ // session, and `deriveDaemonList()` reads only from currently
591
+ // connected sessions. No time-based prune exists, so the
592
+ // per-20s refresh was pure DO-wake cost with no signal value.
593
+ //
594
+ // The one-shot heartbeat on connect (line 421) still fires
595
+ // once so the server gets the initial profile snapshot.
596
+ // Heartbeat with the APP-LEVEL {type:'ping'} the edge auto-answers:
597
+ // the home DO's setWebSocketAutoResponse pairs {type:'ping'} →
598
+ // {type:'pong'} and replies AT THE EDGE without waking the DO. A
599
+ // protocol-level ws.ping() is NOT auto-ponged once the DO
600
+ // hibernates — that mismatch was the false "No pong" reconnect.
601
+ if (this.heartbeatInterval) {
602
+ clearInterval(this.heartbeatInterval);
603
+ this.heartbeatInterval = null;
604
+ }
605
+ // WebSocket-level ping every 15s — detects dead connections faster than TCP timeout
606
+ if (this.wsPingInterval)
607
+ clearInterval(this.wsPingInterval);
608
+ this.awaitingPong = false;
609
+ this.missedPongs = 0;
610
+ this.lastPongCheckAt = Date.now();
611
+ this.wsPingInterval = setInterval(() => {
612
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
613
+ return;
614
+ // Stall guard: if this 15s interval itself ran far late, the
615
+ // event loop was frozen (macOS App Nap on the backgrounded
616
+ // daemon, heavy sync work, or a brief sleep). A pong we
617
+ // "didn't receive" is then an artifact of that freeze, not a
618
+ // dead peer — terminating here was the false-positive that
619
+ // severed healthy daemon sockets (close 1006) every few
620
+ // minutes. Reset and re-probe instead of killing the socket.
621
+ const sinceLast = Date.now() - this.lastPongCheckAt;
622
+ this.lastPongCheckAt = Date.now();
623
+ if (sinceLast > 30_000) {
624
+ logger.warn('relay', `Ping interval ran ${(sinceLast / 1000).toFixed(0)}s late (event-loop stall) — re-probing, not terminating`);
625
+ this.awaitingPong = true;
626
+ this.missedPongs = 0; // the gap was OUR freeze, not a dead peer — don't count it
627
+ try {
628
+ this.ws.send(JSON.stringify({ type: 'ping' }));
629
+ }
630
+ catch { }
631
+ return;
632
+ }
633
+ if (this.awaitingPong) {
634
+ // Pinged last cycle, no pong this cycle, loop NOT stalled.
635
+ // Don't sever on the first miss: the home DO's edge
636
+ // auto-pong goes briefly silent while its isolate
637
+ // cold-starts, and terminating here forced a relay
638
+ // reconnect that triggered ANOTHER cold-start — a
639
+ // self-feeding churn loop that reconnected every client
640
+ // (web + mac-app) each cycle. Re-ping and only declare
641
+ // the peer dead after MAX_MISSED_PONGS consecutive
642
+ // un-stalled misses (~30s of genuine silence).
643
+ this.missedPongs++;
644
+ if (this.missedPongs >= this.MAX_MISSED_PONGS) {
645
+ logger.warn('relay', `No pong after ${this.missedPongs} pings (~${this.missedPongs * 15}s silent) — connection dead, reconnecting`);
646
+ try {
647
+ this.ws.terminate();
648
+ }
649
+ catch { }
650
+ return;
651
+ }
652
+ // Tolerated miss — re-probe and give it one more cycle.
653
+ try {
654
+ this.ws.send(JSON.stringify({ type: 'ping' }));
655
+ }
656
+ catch { }
657
+ return;
658
+ }
659
+ this.awaitingPong = true;
660
+ this.missedPongs = 0;
661
+ try {
662
+ this.ws.send(JSON.stringify({ type: 'ping' }));
663
+ }
664
+ catch { }
665
+ }, 15_000);
666
+ // Detect system wake — a >35s gap between 5s ticks means EITHER
667
+ // the system slept (socket dead) OR the process was merely
668
+ // throttled / the event loop stalled (macOS App Nap on the
669
+ // backgrounded daemon) — in which case the socket is still
670
+ // ALIVE. Blind-terminating here was severing healthy
671
+ // connections every few minutes (close 1006) and forcing
672
+ // reconnect churn. Verify first: ping, and only drop if no pong
673
+ // comes back within a short grace.
674
+ this.lastTickAt = Date.now();
675
+ if (this.wsWakeDetector)
676
+ clearInterval(this.wsWakeDetector);
677
+ this.wsWakeDetector = setInterval(() => {
678
+ const now = Date.now();
679
+ const drift = now - this.lastTickAt;
680
+ this.lastTickAt = now;
681
+ if (drift > 35_000 && this.ws && this.ws.readyState === WebSocket.OPEN) {
682
+ const sock = this.ws;
683
+ logger.warn('relay', `Timer drift ${(drift / 1000).toFixed(0)}s — verifying liveness before reconnect`);
684
+ const probeAt = Date.now();
685
+ try {
686
+ sock.send(JSON.stringify({ type: 'ping' }));
687
+ }
688
+ catch { }
689
+ setTimeout(() => {
690
+ // Alive iff the edge auto-ponged our app-level ping AFTER
691
+ // this probe — the hibernation-safe signal (a protocol-
692
+ // level pong never arrives once the home DO hibernates).
693
+ if (this.appPongAt >= probeAt) {
694
+ logger.info('relay', 'Alive after drift (throttle, not sleep) — keeping connection');
695
+ return;
696
+ }
697
+ // Only terminate the socket we probed, and only if
698
+ // it's still the current one (not already replaced).
699
+ if (sock === this.ws && sock.readyState === WebSocket.OPEN) {
700
+ logger.warn('relay', 'No pong after drift probe — connection dead, reconnecting');
701
+ try {
702
+ sock.terminate();
703
+ }
704
+ catch { }
705
+ }
706
+ }, 8_000);
707
+ }
708
+ }, 5_000);
709
+ done();
710
+ });
711
+ ws.on('error', () => {
712
+ done();
713
+ });
714
+ });
715
+ ws.on('pong', () => {
716
+ this.awaitingPong = false;
717
+ this.missedPongs = 0;
718
+ });
719
+ ws.on('message', (data) => {
720
+ try {
721
+ const msg = JSON.parse(data.toString());
722
+ // App-level heartbeat pong — the edge's setWebSocketAutoResponse
723
+ // answers our {type:'ping'} with {type:'pong'} WITHOUT waking the
724
+ // home DO, so this is the hibernation-safe liveness signal. (The
725
+ // protocol-level ws.on('pong') never fires once the DO hibernates;
726
+ // the edge auto-pongs only this app-level pair, not RFC 6455 frames.)
727
+ if (msg && msg.type === 'pong') {
728
+ this.awaitingPong = false;
729
+ this.missedPongs = 0;
730
+ this.appPongAt = Date.now();
731
+ return;
732
+ }
733
+ this.handleMessage(msg).catch(() => {
734
+ /* message handling error */
735
+ });
736
+ }
737
+ catch {
738
+ /* malformed message */
739
+ }
740
+ });
741
+ ws.on('close', (code, reason) => {
742
+ // Record the close code the daemon observed. 1006 (no close frame)
743
+ // = abrupt sever — either our own terminate() or an edge drop;
744
+ // 1000/1001 = graceful. Pairs with the server's
745
+ // sdk-diag.webSocketClose so a drop is visible from both ends
746
+ // (this lands in daemon.log; console.log here would go to /dev/null).
747
+ const reasonStr = (() => {
748
+ try {
749
+ return reason?.toString?.() ?? '';
750
+ }
751
+ catch {
752
+ return '';
753
+ }
754
+ })();
755
+ logger.warn('relay', `WS closed code=${code ?? '?'} reason="${reasonStr.slice(0, 80)}" wasConnected=${this.connected}`);
756
+ this.ws = null;
757
+ this.wsConnecting = false;
758
+ this.connected = false;
759
+ if (this.flushInterval)
760
+ clearInterval(this.flushInterval);
761
+ if (this.heartbeatInterval)
762
+ clearInterval(this.heartbeatInterval);
763
+ if (this.wsPingInterval)
764
+ clearInterval(this.wsPingInterval);
765
+ if (this.wsWakeDetector)
766
+ clearInterval(this.wsWakeDetector);
767
+ this.flushInterval = null;
768
+ this.heartbeatInterval = null;
769
+ this.wsPingInterval = null;
770
+ this.wsWakeDetector = null;
771
+ // Reject pending requests
772
+ for (const [, pending] of this.pendingRequests) {
773
+ clearTimeout(pending.timer);
774
+ pending.reject(new Error('WebSocket closed'));
775
+ }
776
+ this.pendingRequests.clear();
777
+ // Always schedule reconnect while we're still configured and not
778
+ // stopped. Previous guard `if (wasConnected)` broke the chain: if
779
+ // a reconnect attempt failed to open, the new ws's close handler
780
+ // saw connected=false and skipped scheduling — leaving us stuck
781
+ // forever with no WS and no pending retry. The `stopped` flag is
782
+ // set synchronously in stop()/unconfigure(), so intentional
783
+ // shutdowns still skip the reconnect.
784
+ if (this.config && !this.stopped) {
785
+ this.scheduleReconnect();
786
+ }
787
+ });
788
+ ws.on('error', (err) => {
789
+ if (this.wsConnecting) {
790
+ // Connection failed — close handler will trigger reconnect
791
+ this.wsConnecting = false;
792
+ }
793
+ // Avoid noisy logs for expected connection errors
794
+ if (err.message && !err.message.includes('ECONNREFUSED')) {
795
+ console.error(`[relay] WebSocket error: ${err.message}`);
796
+ }
797
+ });
798
+ }
799
+ /** Handle an incoming WebSocket message from the DO */
800
+ async handleMessage(msg) {
801
+ const type = msg.type;
802
+ // Handle request-response correlation (rid for legacy, requestId for workspace actions)
803
+ const correlationId = msg.requestId ?? msg.rid;
804
+ if (correlationId && this.pendingRequests.has(correlationId)) {
805
+ const pending = this.pendingRequests.get(correlationId);
806
+ clearTimeout(pending.timer);
807
+ this.pendingRequests.delete(correlationId);
808
+ pending.resolve(msg);
809
+ return;
810
+ }
811
+ switch (type) {
812
+ case 'welcome':
813
+ console.log(`[relay] Connected to Clearly (tier: ${msg.tier})`);
814
+ if (msg.tier)
815
+ this.tier = msg.tier;
816
+ break;
817
+ case 'heartbeat-ack': {
818
+ if (msg.tier)
819
+ this.tier = msg.tier;
820
+ const profileData = msg.profile;
821
+ if (profileData) {
822
+ this.profile = {
823
+ displayName: profileData.displayName || 'Clearly User',
824
+ username: profileData.username,
825
+ photoURL: profileData.photoURL,
826
+ email: profileData.email,
827
+ subscriptionPlan: profileData.subscriptionPlan || 'free',
828
+ subscriptionStatus: profileData.subscriptionStatus,
829
+ shareTerminal: profileData.shareTerminal,
830
+ shareTelemetry: profileData.shareTelemetry,
831
+ };
832
+ const json = JSON.stringify(this.profile);
833
+ if (json !== this.lastProfileJson) {
834
+ this.lastProfileJson = json;
835
+ this.onProfileUpdate?.(this.profile);
836
+ }
837
+ }
838
+ break;
839
+ }
840
+ case 'browse-files': {
841
+ const path = msg.path;
842
+ const rid = msg.rid;
843
+ if (path && rid && this.onBrowseFiles) {
844
+ this.onBrowseFiles(path, rid);
845
+ }
846
+ break;
847
+ }
848
+ case 'file-teleport-delivery': {
849
+ const fileId = msg.fileId;
850
+ const fileName = msg.fileName;
851
+ const size = msg.size;
852
+ const contentType = msg.contentType;
853
+ const downloadUrl = msg.downloadUrl;
854
+ const targetDir = msg.targetDir;
855
+ const uploadUrl = msg.uploadUrl;
856
+ const publicUrl = msg.publicUrl;
857
+ const localPath = msg.localPath;
858
+ // Reverse teleport: upload local file TO R2
859
+ if (uploadUrl && localPath) {
860
+ this.handleUploadLocalFile(localPath, uploadUrl, publicUrl || '', contentType, undefined)
861
+ .then(() => {
862
+ // Send ack with publicUrl so canvas can update the block
863
+ this.wsSend({
864
+ type: 'file-teleport-ack',
865
+ fileId,
866
+ status: 'completed',
867
+ localPath,
868
+ publicUrl,
869
+ });
870
+ })
871
+ .catch((err) => {
872
+ this.wsSend({
873
+ type: 'file-teleport-ack',
874
+ fileId,
875
+ status: 'failed',
876
+ error: err.message,
877
+ });
878
+ });
879
+ break;
880
+ }
881
+ this.handleFileTeleport(fileId, fileName, size, contentType, downloadUrl, targetDir);
882
+ break;
883
+ }
884
+ case 'upload-local-file-delivery': {
885
+ const filePath = msg.filePath;
886
+ const uploadUrl = msg.uploadUrl;
887
+ const publicUrl = msg.publicUrl;
888
+ const contentType = msg.contentType;
889
+ const rid = msg.rid;
890
+ this.handleUploadLocalFile(filePath, uploadUrl, publicUrl, contentType, rid);
891
+ break;
892
+ }
893
+ case 'agent-tool-exec': {
894
+ const toolId = msg.toolId;
895
+ const sessionId = msg.sessionId;
896
+ const toolName = msg.toolName;
897
+ const args = msg.args;
898
+ // `root` (optional) = the cloud agent's bound working folder. When
899
+ // present, executeAgentTool JAILS every path/cwd arg to it. (#local-folder)
900
+ const root = msg.root;
901
+ this.executeAgentTool(toolId, sessionId, toolName, args, root);
902
+ break;
903
+ }
904
+ // ── PTY Terminal Sessions ───────────────────────────────────────
905
+ case 'pty-start': {
906
+ const sid = msg.sessionId;
907
+ // Idempotent: the web client re-sends pty-start on EVERY submit so a command
908
+ // auto-re-binds after a daemon restart. If this session is already live, reuse it —
909
+ // never re-spawn or count it against the session limit.
910
+ if (this.ptySessions.has(sid))
911
+ break;
912
+ const ALLOWED_SHELLS = [
913
+ '/bin/zsh',
914
+ '/bin/bash',
915
+ '/bin/sh',
916
+ '/usr/bin/zsh',
917
+ '/usr/bin/bash',
918
+ ];
919
+ const MAX_PTY_SESSIONS = 5;
920
+ if (this.ptySessions.size >= MAX_PTY_SESSIONS) {
921
+ console.warn(`[relay] PTY session limit reached (${MAX_PTY_SESSIONS})`);
922
+ this.wsSend({ type: 'pty-exit', sessionId: msg.sessionId, exitCode: 1 });
923
+ break;
924
+ }
925
+ const cols = msg.cols || 80;
926
+ const rows = msg.rows || 24;
927
+ const cwd = msg.cwd || homedir();
928
+ const requestedShell = msg.shell || process.env.SHELL || '/bin/zsh';
929
+ const shell = ALLOWED_SHELLS.includes(requestedShell) ? requestedShell : '/bin/zsh';
930
+ this.ptyStart(sid, shell, cols, rows, cwd);
931
+ break;
932
+ }
933
+ case 'pty-input': {
934
+ const sid = msg.sessionId;
935
+ const data = msg.data;
936
+ // Queue until the shell integration is live (see ptyStart) so the first command isn't swallowed
937
+ // by the init race; write directly once ready (or if this session isn't buffered).
938
+ if (this.ptyReady.has(sid) || !this.ptyInputQueue.has(sid))
939
+ this.ptySessions.get(sid)?.write(data);
940
+ else
941
+ this.ptyInputQueue.get(sid).push(data);
942
+ break;
943
+ }
944
+ case 'pty-resize': {
945
+ const sid = msg.sessionId;
946
+ const cols = msg.cols;
947
+ const rows = msg.rows;
948
+ this.ptySessions.get(sid)?.resize(cols, rows);
949
+ break;
950
+ }
951
+ case 'pty-kill': {
952
+ const sid = msg.sessionId;
953
+ const pty = this.ptySessions.get(sid);
954
+ if (pty) {
955
+ pty.kill();
956
+ this.ptySessions.delete(sid);
957
+ }
958
+ this.ptyParsers.delete(sid);
959
+ this._cleanupPtyReady(sid);
960
+ cleanupIntegration(sid);
961
+ break;
962
+ }
963
+ case 'viewer-count': {
964
+ this.viewerCount = msg.count || 0;
965
+ break;
966
+ }
967
+ case 'pong':
968
+ break;
969
+ case 'agent-status': {
970
+ this.onBeeStatus?.(msg);
971
+ break;
972
+ }
973
+ case 'bee-thought': {
974
+ this.onBeeThought?.(msg.thought);
975
+ break;
976
+ }
977
+ case 'local-app-fetch': {
978
+ const { buildingType, buildingId, url, method, headers, body, rid } = msg;
979
+ if (this.onLocalAppFetch) {
980
+ this.onLocalAppFetch(buildingType, buildingId, url || 'http://local/', method || 'GET', headers || {}, body)
981
+ .then((result) => {
982
+ this.wsSend({ type: 'local-app-fetch-result', rid, ...result });
983
+ })
984
+ .catch((err) => {
985
+ this.wsSend({ type: 'local-app-fetch-result', rid, status: 500, body: err.message });
986
+ });
987
+ }
988
+ else {
989
+ this.wsSend({
990
+ type: 'local-app-fetch-result',
991
+ rid,
992
+ status: 503,
993
+ body: 'Local app not available',
994
+ });
995
+ }
996
+ break;
997
+ }
998
+ // #74 — cross-hive RPC from the cloud Workspace.
999
+ // IN: { type: 'daemon-rpc-request', requestId, hive, action, input }
1000
+ // OUT: { type: 'daemon-rpc-response', requestId, ok, result?, error? }
1001
+ // The workspace's `_daemonRpc(hive, action, input)` opens a
1002
+ // pending promise keyed on requestId and resolves it when
1003
+ // it sees a matching response back through the same relay.
1004
+ // Fires before any cloud-driven cross-hive call (clipImport,
1005
+ // brandSync, projectCreate, clipSearch, etc).
1006
+ case 'daemon-rpc-request': {
1007
+ const requestId = msg?.requestId;
1008
+ const hive = msg?.hive;
1009
+ const action = msg?.action;
1010
+ const input = msg?.input ?? {};
1011
+ if (!requestId || !hive || !action) {
1012
+ this.wsSend({
1013
+ type: 'daemon-rpc-response',
1014
+ requestId: requestId ?? 'unknown',
1015
+ ok: false,
1016
+ error: 'malformed daemon-rpc-request: requestId, hive, action all required',
1017
+ });
1018
+ break;
1019
+ }
1020
+ if (!this.onDaemonRpcRequest) {
1021
+ this.wsSend({
1022
+ type: 'daemon-rpc-response',
1023
+ requestId,
1024
+ ok: false,
1025
+ error: 'daemon-rpc handler not wired (onDaemonRpcRequest is null) — daemon may need a restart',
1026
+ });
1027
+ break;
1028
+ }
1029
+ const startedAt = Date.now();
1030
+ this.onDaemonRpcRequest(hive, action, input)
1031
+ .then((result) => {
1032
+ const elapsed = Date.now() - startedAt;
1033
+ this.wsSend({
1034
+ type: 'daemon-rpc-response',
1035
+ requestId,
1036
+ ok: true,
1037
+ result,
1038
+ elapsedMs: elapsed,
1039
+ });
1040
+ })
1041
+ .catch((err) => {
1042
+ const elapsed = Date.now() - startedAt;
1043
+ this.wsSend({
1044
+ type: 'daemon-rpc-response',
1045
+ requestId,
1046
+ ok: false,
1047
+ error: String(err?.message ?? err).slice(0, 500),
1048
+ elapsedMs: elapsed,
1049
+ });
1050
+ });
1051
+ break;
1052
+ }
1053
+ case 'error':
1054
+ console.error(`[relay] Server error: ${msg.message}`);
1055
+ if (msg.code === 'auth_failed') {
1056
+ console.error('[relay] Token rejected — sign in from the Clearly Dev Mac app tray');
1057
+ this.connected = false;
1058
+ }
1059
+ break;
1060
+ case 'file-changed': {
1061
+ this.onFileChanged?.(msg);
1062
+ break;
1063
+ }
1064
+ default:
1065
+ // Forward unhandled messages as broadcasts (IPC clients may want them)
1066
+ this.onBroadcast?.(msg);
1067
+ break;
1068
+ }
1069
+ }
1070
+ /** Send a message over WebSocket. Callers waiting for a reply must check the result. */
1071
+ wsSend(msg) {
1072
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
1073
+ return false;
1074
+ try {
1075
+ this.ws.send(JSON.stringify(msg));
1076
+ this.stats.sent++;
1077
+ return true;
1078
+ }
1079
+ catch {
1080
+ this.stats.failed++;
1081
+ return false;
1082
+ }
1083
+ }
1084
+ /** Send a message and wait for a response with matching requestId */
1085
+ sendRequest(msg, timeout = 10_000) {
1086
+ return new Promise((resolve, reject) => {
1087
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1088
+ return reject(new Error('WebSocket not connected'));
1089
+ }
1090
+ const requestId = Math.random().toString(36).slice(2) + Date.now().toString(36);
1091
+ const timer = setTimeout(() => {
1092
+ this.pendingRequests.delete(requestId);
1093
+ const action = msg.type === 'agent-invoke' ? msg.action : msg.type;
1094
+ reject(new Error(`Request timeout after ${timeout}ms (action=${action}, requestId=${requestId}); outcome unknown`));
1095
+ }, timeout);
1096
+ this.pendingRequests.set(requestId, { resolve, reject, timer });
1097
+ // Send both rid (legacy) and requestId (workspace actions) for compatibility
1098
+ if (!this.wsSend({ ...msg, rid: requestId, requestId })) {
1099
+ clearTimeout(timer);
1100
+ this.pendingRequests.delete(requestId);
1101
+ reject(new Error(`WebSocket send failed (requestId=${requestId}); send was not confirmed`));
1102
+ }
1103
+ });
1104
+ }
1105
+ // ── Reconnection ──────────────────────────────────────────────────────
1106
+ /**
1107
+ * Reconnect with exponential backoff + jitter.
1108
+ *
1109
+ * Backoff: 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped). Jitter adds
1110
+ * 0-25% of the base delay so a fleet of clients reconnecting after a
1111
+ * server outage doesn't synchronize into a thundering herd. The cap
1112
+ * keeps idle daemons polling at most once a minute.
1113
+ *
1114
+ * Single-flight: a reconnect already scheduled is a no-op. The timer
1115
+ * is cleared on successful connect (reconnectAttempts → 0) and on
1116
+ * stop().
1117
+ */
1118
+ scheduleReconnect() {
1119
+ if (this.reconnectTimer)
1120
+ return;
1121
+ const baseDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 60_000);
1122
+ // Add 0-25% random jitter — `delay * (1 + 0..0.25)` keeps the
1123
+ // floor at the exponential value so we never reconnect FASTER
1124
+ // than the backoff intends, only slower.
1125
+ const jitter = Math.random() * 0.25 * baseDelay;
1126
+ const delay = Math.round(baseDelay + jitter);
1127
+ this.reconnectAttempts++;
1128
+ console.log(`[relay] Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.reconnectAttempts})`);
1129
+ this.reconnectTimer = setTimeout(() => {
1130
+ this.reconnectTimer = null;
1131
+ if (this.useWebSocket) {
1132
+ this.connectWs();
1133
+ }
1134
+ else {
1135
+ this.reconnectLegacy();
1136
+ }
1137
+ }, delay);
1138
+ }
1139
+ /** Start the relay health watchdog (idempotent). Every 30s: if we've been
1140
+ * disconnected past `maxDownMs`, force a HARD reconnect. The self-heal a
1141
+ * headless/orphaned daemon needs (no Mac-app tray to restart it) — a wedged
1142
+ * WS (half-open zombie socket after sleep) otherwise sticks "reconnecting"
1143
+ * forever, which `beehaven restart` had to fix by hand. */
1144
+ startSupervisor(maxDownMs = 120_000) {
1145
+ if (this.supervisorTimer)
1146
+ return;
1147
+ this.supervisorTimer = setInterval(() => {
1148
+ if (this.stopped)
1149
+ return;
1150
+ if (this.connected) {
1151
+ this.disconnectedSince = null;
1152
+ return;
1153
+ }
1154
+ if (this.disconnectedSince == null) {
1155
+ this.disconnectedSince = Date.now();
1156
+ return;
1157
+ }
1158
+ if (Date.now() - this.disconnectedSince > maxDownMs) {
1159
+ console.warn(`[relay] Watchdog: relay down ${Math.round((Date.now() - this.disconnectedSince) / 1000)}s — forcing hard reconnect`);
1160
+ this.disconnectedSince = Date.now();
1161
+ this.forceReconnect();
1162
+ }
1163
+ }, 30_000);
1164
+ }
1165
+ /** Hard reconnect: drop any (possibly zombie) socket, clear pending reconnect
1166
+ * state, reset the backoff so the next dial is immediate, then redial. */
1167
+ forceReconnect() {
1168
+ try {
1169
+ this.ws?.terminate();
1170
+ }
1171
+ catch { }
1172
+ this.ws = null;
1173
+ this.connected = false;
1174
+ this.wsConnecting = false;
1175
+ if (this.reconnectTimer) {
1176
+ clearTimeout(this.reconnectTimer);
1177
+ this.reconnectTimer = null;
1178
+ }
1179
+ this.reconnectAttempts = 0;
1180
+ if (this.useWebSocket)
1181
+ this.connectWs();
1182
+ else
1183
+ this.reconnectLegacy();
1184
+ }
1185
+ async reconnectLegacy() {
1186
+ const ok = await this.sendHeartbeatHttp();
1187
+ if (ok) {
1188
+ this.connected = true;
1189
+ this.reconnectAttempts = 0;
1190
+ console.log(`[relay] Reconnected (tier: ${this.tier})`);
1191
+ if (this.heartbeatInterval)
1192
+ clearInterval(this.heartbeatInterval);
1193
+ this.heartbeatInterval = setInterval(() => this.sendHeartbeatHttp(), 30_000);
1194
+ if (this.flushInterval)
1195
+ clearInterval(this.flushInterval);
1196
+ this.flushInterval = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
1197
+ }
1198
+ else {
1199
+ this.scheduleReconnect();
1200
+ }
1201
+ }
1202
+ // ── Public API ───────────────────────────────────────────────────────
1203
+ /** Send a raw Claude Code hook event to the DO for processing */
1204
+ sendRawEvent(event) {
1205
+ if (!this.config || !this.connected)
1206
+ return;
1207
+ if (this.useWebSocket) {
1208
+ this.wsSend({ type: 'raw-event', event });
1209
+ }
1210
+ else {
1211
+ this.buffer.events.push(event);
1212
+ }
1213
+ }
1214
+ /** Send system telemetry, git status, and agent metrics */
1215
+ sendTelemetry(data) {
1216
+ if (!this.config || !this.connected)
1217
+ return;
1218
+ if (this.useWebSocket) {
1219
+ this.wsSend({ type: 'telemetry', ...data });
1220
+ }
1221
+ }
1222
+ /**
1223
+ * Announce local app capabilities to cloud. The `apps` field carries
1224
+ * the Omni-agent catalog — compact metadata for every installed .app
1225
+ * on the machine, so the cloud agent knows which apps and which
1226
+ * recipes are available when reasoning about what to do for the user.
1227
+ */
1228
+ sendLocalAppStatus(status) {
1229
+ this.wsSend({ type: 'local-app-status', ...status });
1230
+ }
1231
+ /**
1232
+ * Advertise this daemon as a BEEHIVE in the distributed hive mesh.
1233
+ *
1234
+ * The Home DO's `case 'swarm-register'` upserts a durable node row (keyed by
1235
+ * this stable deviceId) + maps deviceId → this live session, which is how the
1236
+ * swarm knows the node is online and how the queen targets a dispatch at it.
1237
+ * Re-sent on connect, whenever capabilities change, and on a periodic cadence
1238
+ * (see daemon.ts `registerBeehive`) so it also serves as the beehive's
1239
+ * keepalive and self-heals after a DO hibernation dropped the live map.
1240
+ *
1241
+ * role 'queen' (orchestrator) | 'worker' (executor)
1242
+ * kind 'local-mac' | 'headless' | 'cloud-container'
1243
+ * capabilities the hive/building names this node can run (dispatch match keys)
1244
+ */
1245
+ sendSwarmRegister(opts) {
1246
+ if (!this.connected)
1247
+ return;
1248
+ this.wsSend({
1249
+ type: 'swarm-register',
1250
+ deviceId: this.deviceId,
1251
+ role: opts.role,
1252
+ kind: opts.kind,
1253
+ capabilities: opts.capabilities,
1254
+ hostname: hostname(),
1255
+ os: platform(),
1256
+ root: homedir(),
1257
+ });
1258
+ }
1259
+ /** Start periodic token self-refresh (every 50 min, for headless/long-running daemons) */
1260
+ tokenRefreshTimer = null;
1261
+ startTokenRefresh() {
1262
+ if (this.tokenRefreshTimer)
1263
+ return;
1264
+ const REFRESH_INTERVAL = 50 * 60 * 1000;
1265
+ const doRefresh = async () => {
1266
+ const ok = await this.refreshIdToken();
1267
+ if (!ok) {
1268
+ console.log('[relay] Token refresh failed — will retry in 5 minutes');
1269
+ setTimeout(doRefresh, 5 * 60 * 1000);
1270
+ }
1271
+ };
1272
+ // Initial refresh after a short delay (gives time for first connection)
1273
+ setTimeout(doRefresh, 30_000);
1274
+ this.tokenRefreshTimer = setInterval(doRefresh, REFRESH_INTERVAL);
1275
+ }
1276
+ /** Refresh the auth token via the relay's /auth/refresh endpoint.
1277
+ * Sends the stored refresh token to the relay Worker, which handles
1278
+ * the Firebase token exchange server-side. */
1279
+ async refreshIdToken() {
1280
+ const cfg = Relay.loadConfig();
1281
+ if (!cfg?.refreshToken) {
1282
+ return false;
1283
+ }
1284
+ try {
1285
+ const base = this.wsBase().replace('wss://', 'https://').replace('ws://', 'http://');
1286
+ const resp = await fetch(`${base}/auth/refresh`, {
1287
+ method: 'POST',
1288
+ headers: { 'Content-Type': 'application/json' },
1289
+ body: JSON.stringify({ refreshToken: cfg.refreshToken }),
1290
+ signal: AbortSignal.timeout(10_000),
1291
+ });
1292
+ if (!resp.ok) {
1293
+ return false;
1294
+ }
1295
+ const data = (await resp.json());
1296
+ if (!data.token)
1297
+ return false;
1298
+ // Update config + keychain with the fresh tokens. Both must
1299
+ // be in sync — Relay.loadConfig() reads from keychain when
1300
+ // tokenStorage='keychain', so a stale keychain after a
1301
+ // successful in-memory refresh would cause every subsequent
1302
+ // daemon restart to dial the WebSocket with an expired
1303
+ // idToken and burn another refresh round-trip.
1304
+ try {
1305
+ const raw = readFileSync(CONFIG_FILE, 'utf8');
1306
+ const config = JSON.parse(raw);
1307
+ config.token = data.token;
1308
+ if (data.refreshToken)
1309
+ config.refreshToken = data.refreshToken;
1310
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
1311
+ try {
1312
+ chmodSync(CONFIG_FILE, 0o600);
1313
+ }
1314
+ catch { }
1315
+ // Mirror to keychain via `security` so the next daemon
1316
+ // launch's loadConfig reads a fresh idToken. execSync
1317
+ // avoids importing keychain.ts (which is ESM-only and
1318
+ // would create circular import drama).
1319
+ if (config.tokenStorage === 'keychain') {
1320
+ try {
1321
+ execSync(`security delete-generic-password -s beehaven -a daemon-token`, {
1322
+ stdio: 'ignore',
1323
+ });
1324
+ }
1325
+ catch { } // entry didn't exist; fine
1326
+ execSync(`security add-generic-password -s beehaven -a daemon-token -w '${data.token.replace(/'/g, "'\\''")}'`, { stdio: ['ignore', 'ignore', 'pipe'] });
1327
+ }
1328
+ }
1329
+ catch { }
1330
+ // Update in-memory config
1331
+ if (this.config) {
1332
+ this.config.token = data.token;
1333
+ }
1334
+ console.log('[relay] Token refreshed successfully');
1335
+ return true;
1336
+ }
1337
+ catch {
1338
+ return false;
1339
+ }
1340
+ }
1341
+ /** Queue docs for next flush */
1342
+ uploadDocs(project, projectRoot) {
1343
+ if (!this.config || !this.connected)
1344
+ return;
1345
+ const DOC_FILES = ['README.md', 'CLAUDE.md', 'package.json'];
1346
+ const docs = [];
1347
+ for (const name of DOC_FILES) {
1348
+ const filePath = join(projectRoot, name);
1349
+ if (!existsSync(filePath))
1350
+ continue;
1351
+ try {
1352
+ const content = readFileSync(filePath, 'utf8');
1353
+ if (content.length > 500_000)
1354
+ continue;
1355
+ docs.push({ name, content });
1356
+ }
1357
+ catch {
1358
+ continue;
1359
+ }
1360
+ }
1361
+ if (docs.length > 0) {
1362
+ this.buffer.docs.push({ project, docs });
1363
+ }
1364
+ }
1365
+ // ── Commands (web → local) ────────────────────────────────────────────
1366
+ // DISABLED: Programmatic Claude orchestration not permitted — Anthropic credential use policy
1367
+ // https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use
1368
+ // /** Commands arrive over WebSocket — no polling needed */
1369
+ // ── Hook Events (local → web) ────────────────────────────────────────
1370
+ /** Send a hook event to the web client — immediate over WebSocket */
1371
+ sendHookEvent(webSessionId, event) {
1372
+ if (!this.config || !this.connected)
1373
+ return;
1374
+ if (this.useWebSocket) {
1375
+ this.wsSend({
1376
+ type: 'hook-events',
1377
+ sessionId: webSessionId,
1378
+ events: [
1379
+ {
1380
+ hook_event_name: event.hook_event_name,
1381
+ tool_name: event.tool_name,
1382
+ tool_input: event.tool_input,
1383
+ error: event.error,
1384
+ prompt: event.prompt,
1385
+ agent_type: event.agent_type,
1386
+ message: event.message,
1387
+ task_subject: event.task_subject,
1388
+ timestamp: event.timestamp,
1389
+ },
1390
+ ],
1391
+ });
1392
+ }
1393
+ }
1394
+ // ── File Browser (web → daemon → web) ────────────────────────────────
1395
+ /** Send browse-files result back to the web client via DO */
1396
+ sendBrowseFilesResult(path, entries, rid, error) {
1397
+ if (!this.connected)
1398
+ return;
1399
+ this.wsSend({
1400
+ type: 'browse-files-result',
1401
+ path,
1402
+ entries,
1403
+ error,
1404
+ rid,
1405
+ });
1406
+ }
1407
+ // ── Session Events (local → web) ─────────────────────────────────────
1408
+ /** Send a session event — immediate over WebSocket, batched for legacy HTTP */
1409
+ sendSessionEvent(sessionId, event) {
1410
+ if (!this.config || !this.connected) {
1411
+ return;
1412
+ }
1413
+ if (this.useWebSocket) {
1414
+ this.wsSend({ type: 'session-events', sessionId, events: [event] });
1415
+ }
1416
+ else {
1417
+ const batch = this.sessionEventBatch.get(sessionId) || [];
1418
+ batch.push(event);
1419
+ this.sessionEventBatch.set(sessionId, batch);
1420
+ }
1421
+ }
1422
+ // ── One-off Requests ──────────────────────────────────────────────────
1423
+ /** Verify a token by sending a heartbeat (always uses HTTP) */
1424
+ async verifyToken(token, endpoint) {
1425
+ const url = this.httpUrl(endpoint);
1426
+ try {
1427
+ const resp = await fetch(url, {
1428
+ method: 'POST',
1429
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
1430
+ body: JSON.stringify({ type: 'heartbeat', deviceId: this.deviceId }),
1431
+ signal: AbortSignal.timeout(10000),
1432
+ });
1433
+ if (!resp.ok)
1434
+ return null;
1435
+ const json = await resp.json();
1436
+ if (!json.ok)
1437
+ return null;
1438
+ return json.profile || { verified: true };
1439
+ }
1440
+ catch {
1441
+ return null;
1442
+ }
1443
+ }
1444
+ /** Delete the relay session server-side (used on uninstall) */
1445
+ async deleteSession(token, endpoint) {
1446
+ const t = token || this.config?.token;
1447
+ if (!t)
1448
+ return;
1449
+ const url = this.httpUrl(endpoint);
1450
+ try {
1451
+ await fetch(url, {
1452
+ method: 'POST',
1453
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
1454
+ body: JSON.stringify({ type: 'delete-session', deviceId: this.deviceId }),
1455
+ signal: AbortSignal.timeout(5000),
1456
+ });
1457
+ }
1458
+ catch {
1459
+ /* best effort */
1460
+ }
1461
+ }
1462
+ /** Upload a session transcript to Cloud Storage */
1463
+ async uploadTranscript(transcriptPath, sessionId, project) {
1464
+ if (!this.config || !this.connected)
1465
+ return;
1466
+ if (!existsSync(transcriptPath))
1467
+ return;
1468
+ try {
1469
+ const fileSize = statSync(transcriptPath).size;
1470
+ if (fileSize > 10 * 1024 * 1024)
1471
+ return; // Skip > 10MB
1472
+ let uploadUrl = null;
1473
+ if (this.useWebSocket) {
1474
+ const resp = await this.sendRequest({
1475
+ type: 'get-upload-url',
1476
+ path: `transcripts/${sessionId}.jsonl`,
1477
+ contentType: 'application/x-ndjson',
1478
+ });
1479
+ uploadUrl = resp?.uploadUrl || null;
1480
+ }
1481
+ else {
1482
+ const resp = await this.postHttp({
1483
+ type: 'get-upload-url',
1484
+ path: `transcripts/${sessionId}.jsonl`,
1485
+ contentType: 'application/x-ndjson',
1486
+ });
1487
+ uploadUrl = resp?.uploadUrl || null;
1488
+ }
1489
+ if (!uploadUrl)
1490
+ return;
1491
+ const content = readFileSync(transcriptPath);
1492
+ const uploadResp = await fetch(uploadUrl, {
1493
+ method: 'PUT',
1494
+ headers: { 'Content-Type': 'application/x-ndjson' },
1495
+ body: content,
1496
+ signal: AbortSignal.timeout(30_000),
1497
+ });
1498
+ if (uploadResp.ok) {
1499
+ this.lastSyncAt = Date.now();
1500
+ if (project) {
1501
+ const existing = this.projectSyncDetails.get(project);
1502
+ if (existing) {
1503
+ existing.transcriptUploaded = true;
1504
+ existing.lastSyncAt = Date.now();
1505
+ }
1506
+ }
1507
+ console.log(`[relay] Transcript uploaded: ${sessionId} (${(fileSize / 1024).toFixed(0)}KB)`);
1508
+ }
1509
+ }
1510
+ catch (err) {
1511
+ console.error(`[relay] Transcript upload error: ${err.message}`);
1512
+ }
1513
+ }
1514
+ /** Upload a saved session JSON file to R2 storage */
1515
+ async uploadSession(sessionPath, sessionId) {
1516
+ if (!this.config || !this.connected)
1517
+ return false;
1518
+ if (!existsSync(sessionPath))
1519
+ return false;
1520
+ try {
1521
+ const fileSize = statSync(sessionPath).size;
1522
+ if (fileSize > 5 * 1024 * 1024)
1523
+ return false;
1524
+ let uploadUrl = null;
1525
+ if (this.useWebSocket) {
1526
+ const resp = await this.sendRequest({
1527
+ type: 'get-upload-url',
1528
+ path: `sessions/${sessionId}.json`,
1529
+ contentType: 'application/json',
1530
+ });
1531
+ uploadUrl = resp?.uploadUrl || null;
1532
+ }
1533
+ else {
1534
+ const resp = await this.postHttp({
1535
+ type: 'get-upload-url',
1536
+ path: `sessions/${sessionId}.json`,
1537
+ contentType: 'application/json',
1538
+ });
1539
+ uploadUrl = resp?.uploadUrl || null;
1540
+ }
1541
+ if (!uploadUrl)
1542
+ return false;
1543
+ const content = readFileSync(sessionPath);
1544
+ const uploadResp = await fetch(uploadUrl, {
1545
+ method: 'PUT',
1546
+ headers: { 'Content-Type': 'application/json' },
1547
+ body: content,
1548
+ signal: AbortSignal.timeout(30_000),
1549
+ });
1550
+ if (uploadResp.ok) {
1551
+ this.lastSyncAt = Date.now();
1552
+ return true;
1553
+ }
1554
+ return false;
1555
+ }
1556
+ catch {
1557
+ return false;
1558
+ }
1559
+ }
1560
+ /** Fetch building state (always uses HTTP) */
1561
+ async getBuilding() {
1562
+ if (!this.config)
1563
+ return null;
1564
+ try {
1565
+ const resp = await this.postHttp({ type: 'building' });
1566
+ return resp?.building || null;
1567
+ }
1568
+ catch {
1569
+ return null;
1570
+ }
1571
+ }
1572
+ /** Claim a desk in a building (always uses HTTP) */
1573
+ async selectDesk(floor, desk) {
1574
+ if (!this.config)
1575
+ return false;
1576
+ try {
1577
+ const resp = await this.postHttp({ type: 'claim-desk', floor, desk });
1578
+ return resp?.ok === true;
1579
+ }
1580
+ catch {
1581
+ return false;
1582
+ }
1583
+ }
1584
+ // ── Folder Sync ───────────────────────────────────────────────────────
1585
+ async syncFileUpload(brandId, relativePath, content, hash) {
1586
+ if (!this.config || !this.connected)
1587
+ return;
1588
+ try {
1589
+ const contentType = this.getMimeType(relativePath);
1590
+ let uploadUrl = null;
1591
+ if (this.useWebSocket) {
1592
+ const resp = await this.sendRequest({
1593
+ type: 'get-upload-url',
1594
+ path: `projects/${brandId}/assets/${relativePath}`,
1595
+ contentType,
1596
+ });
1597
+ uploadUrl = resp?.uploadUrl || null;
1598
+ }
1599
+ else {
1600
+ const result = await this.postHttp({
1601
+ type: 'get-upload-url',
1602
+ path: `projects/${brandId}/assets/${relativePath}`,
1603
+ contentType,
1604
+ });
1605
+ uploadUrl = result?.uploadUrl || null;
1606
+ }
1607
+ if (!uploadUrl)
1608
+ return;
1609
+ await fetch(uploadUrl, {
1610
+ method: 'PUT',
1611
+ headers: { 'Content-Type': contentType },
1612
+ body: new Uint8Array(content),
1613
+ signal: AbortSignal.timeout(30_000),
1614
+ });
1615
+ const fileMsg = {
1616
+ type: 'file-sync',
1617
+ brandId,
1618
+ action: 'upsert',
1619
+ file: {
1620
+ path: relativePath,
1621
+ hash,
1622
+ size: content.length,
1623
+ storagePath: `projects/${brandId}/assets/${relativePath}`,
1624
+ },
1625
+ };
1626
+ if (this.useWebSocket) {
1627
+ this.wsSend(fileMsg);
1628
+ }
1629
+ else {
1630
+ await this.postHttp(fileMsg);
1631
+ }
1632
+ this.lastSyncAt = Date.now();
1633
+ }
1634
+ catch (err) {
1635
+ console.error(`[relay] File upload failed (${relativePath}): ${err.message}`);
1636
+ }
1637
+ }
1638
+ // ── Security helpers for cloud-driven local file transfer ────────────
1639
+ // The cloud→daemon WS dispatches on msg.type with NO per-message auth, so
1640
+ // these handlers must assume a frame may be hostile (injected agent /
1641
+ // compromised relay). Restrict transfer endpoints + protected paths.
1642
+ static isAllowedTransferUrl(rawUrl) {
1643
+ try {
1644
+ const u = new URL(rawUrl);
1645
+ if (u.protocol !== 'https:')
1646
+ return false;
1647
+ const h = u.hostname.toLowerCase();
1648
+ return [
1649
+ /(^|\.)r2\.cloudflarestorage\.com$/,
1650
+ /(^|\.)r2\.dev$/,
1651
+ /(^|\.)clearly\.sh$/,
1652
+ /(^|\.)clearly-sh\.pages\.dev$/,
1653
+ ].some((re) => re.test(h));
1654
+ }
1655
+ catch {
1656
+ return false;
1657
+ }
1658
+ }
1659
+ static isSensitivePath(p) {
1660
+ const s = String(p || '');
1661
+ return [
1662
+ /\/\.ssh\//,
1663
+ /\/\.aws\//,
1664
+ /\/\.config\/gcloud\//,
1665
+ /\/\.gnupg\//,
1666
+ /\/\.beehaven\//,
1667
+ /\/Library\/LaunchAgents\//,
1668
+ /\/Library\/LaunchDaemons\//,
1669
+ /id_rsa|id_ed25519/,
1670
+ /\.pem$|\.key$|\.p12$/,
1671
+ /(^|\/)\.env(\.|$)/,
1672
+ /(^|\/)\.(zshrc|zshenv|zprofile|bashrc|bash_profile|profile)$/,
1673
+ ].some((re) => re.test(s));
1674
+ }
1675
+ /** Read a local file and upload it to R2 via presigned URL */
1676
+ async handleUploadLocalFile(filePath, uploadUrl, publicUrl, contentType, rid) {
1677
+ try {
1678
+ const { readFileSync, statSync } = await import('fs');
1679
+ const resolved = filePath.startsWith('~') ? join(homedir(), filePath.slice(1)) : filePath;
1680
+ // SECURITY: only upload to Clearly R2, and never read credential/key
1681
+ // material — otherwise an injected frame exfiltrates local secrets.
1682
+ if (!Relay.isAllowedTransferUrl(uploadUrl))
1683
+ throw new Error('upload destination not allowed');
1684
+ if (Relay.isSensitivePath(resolved))
1685
+ throw new Error('refusing to read protected path');
1686
+ const stat = statSync(resolved);
1687
+ const data = readFileSync(resolved);
1688
+ console.log(`[relay] Uploading local file to R2: ${resolved} (${(stat.size / 1024).toFixed(0)}KB)`);
1689
+ const resp = await fetch(uploadUrl, {
1690
+ method: 'PUT',
1691
+ headers: { 'Content-Type': contentType },
1692
+ body: data,
1693
+ });
1694
+ if (!resp.ok) {
1695
+ throw new Error(`R2 upload failed: ${resp.status} ${resp.statusText}`);
1696
+ }
1697
+ console.log(`[relay] Local file uploaded successfully: ${publicUrl}`);
1698
+ this.wsSend({
1699
+ type: 'upload-local-file-result',
1700
+ rid,
1701
+ filePath,
1702
+ publicUrl,
1703
+ status: 'completed',
1704
+ size: stat.size,
1705
+ });
1706
+ }
1707
+ catch (err) {
1708
+ console.error(`[relay] Failed to upload local file: ${err.message}`);
1709
+ this.wsSend({
1710
+ type: 'upload-local-file-result',
1711
+ rid,
1712
+ filePath,
1713
+ publicUrl,
1714
+ status: 'failed',
1715
+ error: err.message,
1716
+ });
1717
+ }
1718
+ }
1719
+ /** Download a teleported file from R2 and save to local filesystem */
1720
+ async handleFileTeleport(fileId, fileName, size, contentType, downloadUrl, targetDir) {
1721
+ try {
1722
+ // SECURITY: confine writes to the teleport root and only download
1723
+ // from Clearly R2. Previously an arbitrary absolute targetDir let a
1724
+ // cloud frame write ~/.zshrc, ~/Library/LaunchAgents, etc. → RCE.
1725
+ if (!Relay.isAllowedTransferUrl(downloadUrl))
1726
+ throw new Error('download source not allowed');
1727
+ const teleportRoot = join(homedir(), 'Downloads', 'clearly-teleport');
1728
+ // Honor a caller subdir only if it's a simple alnum name (cannot
1729
+ // escape the root); an arbitrary absolute targetDir is ignored.
1730
+ const sub = String(targetDir || '').replace(/[^a-z0-9_-]/gi, '');
1731
+ const baseDir = sub ? join(teleportRoot, sub) : teleportRoot;
1732
+ const safeName = fileName.replace(/\.\./g, '').replace(/[/\\]/g, '_');
1733
+ const savePath = join(baseDir, safeName);
1734
+ // Ensure directory exists
1735
+ mkdirSync(dirname(savePath), { recursive: true });
1736
+ // Check if file already exists with matching size — skip re-download
1737
+ if (existsSync(savePath)) {
1738
+ const existing = statSync(savePath);
1739
+ if (size > 0 && Math.abs(existing.size - size) / size < 0.01) {
1740
+ console.log(`[relay] File already exists, skipping download: ${savePath} (${(existing.size / 1024).toFixed(0)}KB)`);
1741
+ this.wsSend({
1742
+ type: 'file-teleport-ack',
1743
+ fileId,
1744
+ status: 'completed',
1745
+ localPath: savePath,
1746
+ });
1747
+ this.onFileTeleported?.({
1748
+ fileId,
1749
+ fileName,
1750
+ localPath: savePath,
1751
+ size: existing.size,
1752
+ contentType,
1753
+ });
1754
+ return;
1755
+ }
1756
+ console.log(`[relay] File exists but size differs (local: ${existing.size}, remote: ${size}), re-downloading`);
1757
+ }
1758
+ // Stream download from R2 presigned URL
1759
+ const resp = await fetch(downloadUrl, {
1760
+ signal: AbortSignal.timeout(300_000), // 5 min timeout for large files
1761
+ });
1762
+ if (!resp.ok) {
1763
+ throw new Error(`Download failed: ${resp.status} ${resp.statusText}`);
1764
+ }
1765
+ // Read as buffer and write to disk
1766
+ const buffer = Buffer.from(await resp.arrayBuffer());
1767
+ writeFileSync(savePath, buffer);
1768
+ console.log(`[relay] File teleported: ${savePath} (${(buffer.length / 1024).toFixed(0)}KB)`);
1769
+ // Send ack back to web
1770
+ this.wsSend({
1771
+ type: 'file-teleport-ack',
1772
+ fileId,
1773
+ status: 'completed',
1774
+ localPath: savePath,
1775
+ });
1776
+ // Notify callback
1777
+ this.onFileTeleported?.({
1778
+ fileId,
1779
+ fileName,
1780
+ localPath: savePath,
1781
+ size: buffer.length,
1782
+ contentType,
1783
+ });
1784
+ }
1785
+ catch (err) {
1786
+ console.error(`[relay] File teleport failed (${fileName}): ${err.message}`);
1787
+ this.wsSend({
1788
+ type: 'file-teleport-ack',
1789
+ fileId,
1790
+ status: 'failed',
1791
+ error: err.message,
1792
+ });
1793
+ }
1794
+ }
1795
+ // ── Agent Tool Execution (bee agent → local machine) ────────────────
1796
+ /** Execute a tool call from the bee agent on the local filesystem */
1797
+ async executeAgentTool(toolId, sessionId, toolName, args, root) {
1798
+ const startMs = Date.now();
1799
+ // Audit log
1800
+ const sanitizedPath = args?.path ? String(args.path).slice(0, 200) : '';
1801
+ console.log(`[agent-tool] ${toolName} ${sanitizedPath}`);
1802
+ // ── JAIL to the bound working folder (#local-folder Phase 2.5) ───────────
1803
+ // When the cloud agent is scoped to a folder, the DO sends `root`; confine
1804
+ // every path/cwd arg to it (lexical, strict) so an out-of-folder read /
1805
+ // write / edit / exec is REFUSED — including prompt-injected ones (a repo
1806
+ // file telling the model to read ~/Documents). Upstream reads are ungated,
1807
+ // so this is the real scope boundary; isSensitivePath + the HIVE_DEV_MODE
1808
+ // bash gate still apply on top. No `root` (other callers) → skip, so
1809
+ // existing behavior is unchanged. NOTE: lexical only — a symlink inside the
1810
+ // folder pointing out is not resolved (realpath hardening = follow-up).
1811
+ if (root && typeof root === 'string' && args && typeof args === 'object') {
1812
+ const confine = (field) => {
1813
+ const v = args[field];
1814
+ if (typeof v !== 'string' || !v)
1815
+ return null;
1816
+ const r = transformPath(v, { compositionCwd: root, outOfScopePolicy: 'strict' });
1817
+ if (r.absolutePath === null)
1818
+ return r.reason || `${field} is outside the working folder`;
1819
+ args[field] = r.absolutePath;
1820
+ return null;
1821
+ };
1822
+ // Default a missing directory to the folder root (else ls/grep/bash fall
1823
+ // back to $HOME — outside the jail).
1824
+ if ((toolName === 'ls' || toolName === 'grep') && !args.path)
1825
+ args.path = root;
1826
+ if (toolName === 'bash' && !args.cwd)
1827
+ args.cwd = root;
1828
+ let scopeErr = null;
1829
+ if (['read', 'write', 'edit', 'ls', 'grep'].includes(toolName))
1830
+ scopeErr = confine('path');
1831
+ else if (toolName === 'glob')
1832
+ scopeErr = confine('pattern') || confine('path');
1833
+ else if (toolName === 'bash')
1834
+ scopeErr = confine('cwd');
1835
+ if (scopeErr) {
1836
+ this.wsSend({
1837
+ type: 'agent-tool-exec-result',
1838
+ toolId,
1839
+ sessionId,
1840
+ result: '',
1841
+ error: `Refused: ${scopeErr}. This agent is scoped to ${root} — stay inside it, or ask the user to re-point the working folder.`,
1842
+ durationMs: Date.now() - startMs,
1843
+ });
1844
+ return;
1845
+ }
1846
+ }
1847
+ // SECURITY: bash / workspace-exec are arbitrary local command execution.
1848
+ // The cloud→daemon channel is unauthenticated per message, so gate the
1849
+ // RCE primitives behind explicit Developer Mode (HIVE_DEV_MODE=1).
1850
+ /**
1851
+ * ⚠ THE SAME PREDICATE THE DAEMON USES. This tested `HIVE_DEV_MODE !== '1'` while the daemon
1852
+ * accepted `'true'` as well, so `HIVE_DEV_MODE=true` produced a daemon in developer mode whose
1853
+ * relay path still refused `bash` — a split nothing reports, and which reads as a broken gate.
1854
+ * It also means a RELEASED build cannot unlock local command execution at all.
1855
+ */
1856
+ if ((toolName === 'bash' || toolName === 'workspace-exec') && !devModeAvailable()) {
1857
+ this.wsSend({
1858
+ type: 'agent-tool-exec-result',
1859
+ toolId,
1860
+ sessionId,
1861
+ result: '',
1862
+ error: `tool '${toolName}' requires Developer Mode. ${devModeRefusal()}`,
1863
+ durationMs: Date.now() - startMs,
1864
+ });
1865
+ return;
1866
+ }
1867
+ try {
1868
+ let result;
1869
+ switch (toolName) {
1870
+ case 'read':
1871
+ result = this.agentRead(args);
1872
+ break;
1873
+ case 'write':
1874
+ result = this.agentWrite(args);
1875
+ break;
1876
+ case 'edit':
1877
+ result = this.agentEdit(args);
1878
+ break;
1879
+ case 'glob':
1880
+ result = this.agentGlob(args);
1881
+ break;
1882
+ case 'ls':
1883
+ result = this.agentLs(args);
1884
+ break;
1885
+ case 'grep':
1886
+ result = this.agentGrep(args);
1887
+ break;
1888
+ case 'bash':
1889
+ result = this.agentBash(args);
1890
+ break;
1891
+ case 'workspace-init':
1892
+ result = this.workspaceInit(args);
1893
+ break;
1894
+ case 'workspace-sync':
1895
+ result = await this.workspaceSync(args);
1896
+ break;
1897
+ case 'workspace-exec':
1898
+ result = this.workspaceExec(args);
1899
+ break;
1900
+ case 'workspace-upload':
1901
+ result = await this.workspaceUpload(args);
1902
+ break;
1903
+ default:
1904
+ result = `Unknown tool: ${toolName}`;
1905
+ }
1906
+ this.wsSend({
1907
+ type: 'agent-tool-exec-result',
1908
+ toolId,
1909
+ sessionId,
1910
+ result,
1911
+ durationMs: Date.now() - startMs,
1912
+ });
1913
+ }
1914
+ catch (err) {
1915
+ this.wsSend({
1916
+ type: 'agent-tool-exec-result',
1917
+ toolId,
1918
+ sessionId,
1919
+ result: '',
1920
+ error: err.message || String(err),
1921
+ durationMs: Date.now() - startMs,
1922
+ });
1923
+ }
1924
+ }
1925
+ agentRead(args) {
1926
+ const filePath = args.path;
1927
+ if (!filePath)
1928
+ return 'Error: path is required';
1929
+ if (Relay.isSensitivePath(filePath))
1930
+ return 'Error: refusing to read protected path';
1931
+ if (!existsSync(filePath))
1932
+ return `File not found: ${filePath}`;
1933
+ const stat = statSync(filePath);
1934
+ if (stat.isDirectory())
1935
+ return `Error: ${filePath} is a directory, not a file`;
1936
+ if (stat.size > 5 * 1024 * 1024)
1937
+ return `Error: File too large (${Math.round(stat.size / 1024)}KB). Max 5MB.`;
1938
+ const text = readFileSync(filePath, 'utf8');
1939
+ const lines = text.split('\n');
1940
+ const offset = Math.max(0, (args.offset || 1) - 1);
1941
+ const limit = args.limit || lines.length;
1942
+ const slice = lines.slice(offset, offset + limit);
1943
+ const numbered = slice
1944
+ .map((line, i) => {
1945
+ const lineNum = String(offset + i + 1).padStart(6, ' ');
1946
+ return `${lineNum}\t${line}`;
1947
+ })
1948
+ .join('\n');
1949
+ return numbered.length > 50_000 ? numbered.slice(0, 50_000) + '\n... (truncated)' : numbered;
1950
+ }
1951
+ agentWrite(args) {
1952
+ const filePath = args.path;
1953
+ const content = args.content;
1954
+ if (!filePath || content === undefined)
1955
+ return 'Error: path and content are required';
1956
+ if (Relay.isSensitivePath(filePath))
1957
+ return 'Error: refusing to write protected path';
1958
+ const dir = dirname(filePath);
1959
+ if (!existsSync(dir))
1960
+ mkdirSync(dir, { recursive: true });
1961
+ writeFileSync(filePath, content, 'utf8');
1962
+ return `File written: ${filePath} (${content.length} bytes)`;
1963
+ }
1964
+ agentEdit(args) {
1965
+ const filePath = args.path;
1966
+ const oldStr = args.old_string;
1967
+ const newStr = args.new_string;
1968
+ if (!filePath || !oldStr || newStr === undefined)
1969
+ return 'Error: path, old_string, and new_string are required';
1970
+ if (Relay.isSensitivePath(filePath))
1971
+ return 'Error: refusing to edit protected path';
1972
+ if (!existsSync(filePath))
1973
+ return `File not found: ${filePath}`;
1974
+ const text = readFileSync(filePath, 'utf8');
1975
+ const occurrences = text.split(oldStr).length - 1;
1976
+ if (occurrences === 0)
1977
+ return `Error: old_string not found in ${filePath}`;
1978
+ if (occurrences > 1)
1979
+ return `Error: old_string appears ${occurrences} times — must be unique`;
1980
+ const newText = text.replace(oldStr, newStr);
1981
+ writeFileSync(filePath, newText, 'utf8');
1982
+ return `File edited: ${filePath} (1 replacement)`;
1983
+ }
1984
+ agentGlob(args) {
1985
+ let pattern = args.pattern || args.path;
1986
+ if (!pattern)
1987
+ return 'Error: pattern is required';
1988
+ // Normalize double slashes (e.g. /home/user//* → /home/user/*)
1989
+ pattern = pattern.replace(/\/\//g, '/');
1990
+ const globStart = pattern.search(/[*?{[]/);
1991
+ const baseDir = globStart >= 0 ? pattern.slice(0, pattern.lastIndexOf('/', globStart) + 1) || '.' : pattern;
1992
+ if (!existsSync(baseDir))
1993
+ throw new Error(`Directory not found: ${baseDir}`);
1994
+ const matches = [];
1995
+ const globRegex = this.patternToRegex(pattern);
1996
+ this.walkDir(baseDir, matches, globRegex, 500, 8);
1997
+ return matches.length > 0
1998
+ ? `Found ${matches.length} files:\n${matches.join('\n')}`
1999
+ : `No files matching "${pattern}"`;
2000
+ }
2001
+ agentLs(args) {
2002
+ const dirPath = args.path || homedir();
2003
+ if (!existsSync(dirPath))
2004
+ throw new Error(`Directory not found: ${dirPath}`);
2005
+ const stat = statSync(dirPath);
2006
+ if (!stat.isDirectory())
2007
+ throw new Error(`Not a directory: ${dirPath}`);
2008
+ try {
2009
+ const entries = readdirSync(dirPath, { withFileTypes: true });
2010
+ const lines = [];
2011
+ for (const entry of entries) {
2012
+ if (entry.name.startsWith('.'))
2013
+ continue; // skip hidden files
2014
+ const fullPath = join(dirPath, entry.name);
2015
+ if (entry.isDirectory()) {
2016
+ lines.push(`${entry.name}/`);
2017
+ }
2018
+ else {
2019
+ try {
2020
+ const fstat = statSync(fullPath);
2021
+ const sizeStr = fstat.size > 1024 ? `${Math.round(fstat.size / 1024)}KB` : `${fstat.size}B`;
2022
+ lines.push(`${entry.name} (${sizeStr})`);
2023
+ }
2024
+ catch {
2025
+ lines.push(entry.name);
2026
+ }
2027
+ }
2028
+ }
2029
+ if (lines.length === 0)
2030
+ return `Empty directory: ${dirPath}`;
2031
+ return lines.join('\n');
2032
+ }
2033
+ catch (err) {
2034
+ throw new Error(`Failed to read directory ${dirPath}: ${err.message}`);
2035
+ }
2036
+ }
2037
+ agentGrep(args) {
2038
+ const pattern = args.pattern;
2039
+ const searchPath = args.path || homedir();
2040
+ if (!pattern)
2041
+ return 'Error: pattern is required';
2042
+ try {
2043
+ const output = execFileSync('grep', ['-rn', '--include=*', pattern, searchPath], {
2044
+ cwd: searchPath,
2045
+ timeout: 30_000,
2046
+ maxBuffer: 1024 * 1024,
2047
+ encoding: 'utf8',
2048
+ stdio: ['pipe', 'pipe', 'pipe'],
2049
+ });
2050
+ return output.length > 50_000 ? output.slice(0, 50_000) + '\n... (truncated)' : output;
2051
+ }
2052
+ catch (err) {
2053
+ // grep returns exit code 1 when no matches found
2054
+ if (err.status === 1)
2055
+ return `No matches for "${pattern}" in ${searchPath}`;
2056
+ const stderr = err.stderr ? String(err.stderr).slice(0, 2000) : '';
2057
+ return stderr || err.message || 'grep failed';
2058
+ }
2059
+ }
2060
+ agentBash(args) {
2061
+ const command = args.command;
2062
+ if (!command)
2063
+ return 'Error: command is required';
2064
+ let cwd = args.cwd || homedir();
2065
+ // Resolve virtual /local/ prefix from cloud agent
2066
+ if (cwd.startsWith('/local/')) {
2067
+ cwd = cwd.slice('/local'.length); // /local/Users/foo → /Users/foo
2068
+ }
2069
+ // If path doesn't exist, try resolving relative to $HOME
2070
+ // (handles /Documents → ~/Documents, /projects → ~/projects, etc.)
2071
+ if (!existsSync(cwd) && cwd.startsWith('/')) {
2072
+ const asHome = join(homedir(), cwd);
2073
+ if (existsSync(asHome)) {
2074
+ cwd = asHome;
2075
+ }
2076
+ }
2077
+ if (!existsSync(cwd)) {
2078
+ return `Error: working directory does not exist: ${cwd} (also tried ${join(homedir(), cwd)})`;
2079
+ }
2080
+ const timeout = Math.min(args.timeout || 30_000, 120_000);
2081
+ try {
2082
+ const output = execSync(command, {
2083
+ cwd,
2084
+ timeout,
2085
+ maxBuffer: 1024 * 1024,
2086
+ encoding: 'utf8',
2087
+ stdio: ['pipe', 'pipe', 'pipe'],
2088
+ env: { ...process.env, TERM: 'dumb' },
2089
+ });
2090
+ return output.length > 50_000 ? output.slice(0, 50_000) + '\n... (truncated)' : output;
2091
+ }
2092
+ catch (err) {
2093
+ const stderr = err.stderr ? String(err.stderr).slice(0, 2000) : '';
2094
+ const stdout = err.stdout ? String(err.stdout).slice(0, 2000) : '';
2095
+ const output = stdout || stderr || err.message || 'Command failed';
2096
+ return `Exit code ${err.status || 1}\n${output}`;
2097
+ }
2098
+ }
2099
+ // ── PTY Terminal ────────────────────────────────────────────────────
2100
+ /** Mark a managed PTY ready (its shell integration is live) and flush any input typed before then. */
2101
+ _markPtyReady(sessionId) {
2102
+ if (this.ptyReady.has(sessionId))
2103
+ return;
2104
+ this.ptyReady.add(sessionId);
2105
+ const timer = this.ptyReadyTimers.get(sessionId);
2106
+ if (timer) {
2107
+ clearTimeout(timer);
2108
+ this.ptyReadyTimers.delete(sessionId);
2109
+ }
2110
+ const queued = this.ptyInputQueue.get(sessionId);
2111
+ this.ptyInputQueue.delete(sessionId);
2112
+ const pty = this.ptySessions.get(sessionId);
2113
+ if (pty && queued)
2114
+ for (const d of queued) {
2115
+ try {
2116
+ pty.write(d);
2117
+ }
2118
+ catch {
2119
+ /* ignore */
2120
+ }
2121
+ }
2122
+ }
2123
+ /** Drop a PTY's readiness / queue / timer state (on kill or exit). */
2124
+ _cleanupPtyReady(sessionId) {
2125
+ const timer = this.ptyReadyTimers.get(sessionId);
2126
+ if (timer)
2127
+ clearTimeout(timer);
2128
+ this.ptyReadyTimers.delete(sessionId);
2129
+ this.ptyInputQueue.delete(sessionId);
2130
+ this.ptyReady.delete(sessionId);
2131
+ }
2132
+ /** Spawn a PTY session with node-pty (optional dependency) */
2133
+ async ptyStart(sessionId, shell, cols, rows, cwd) {
2134
+ try {
2135
+ // @ts-ignore — optional native dependency
2136
+ const pty = await import('node-pty');
2137
+ // Resolve virtual /local/ prefix from cloud agent
2138
+ let resolvedCwd = cwd;
2139
+ if (resolvedCwd?.startsWith('/local/')) {
2140
+ resolvedCwd = resolvedCwd.slice('/local'.length);
2141
+ }
2142
+ // Try resolving relative to $HOME if absolute path doesn't exist
2143
+ if (resolvedCwd && !existsSync(resolvedCwd) && resolvedCwd.startsWith('/')) {
2144
+ const asHome = join(homedir(), resolvedCwd);
2145
+ if (existsSync(asHome))
2146
+ resolvedCwd = asHome;
2147
+ }
2148
+ if (!resolvedCwd || !existsSync(resolvedCwd)) {
2149
+ resolvedCwd = homedir();
2150
+ }
2151
+ // Filter sensitive environment variables before passing to PTY
2152
+ const filteredEnv = { ...process.env };
2153
+ for (const key of Object.keys(filteredEnv)) {
2154
+ if (SENSITIVE_ENV_PATTERNS.includes(key) ||
2155
+ SENSITIVE_ENV_SUFFIXES.some((s) => key.endsWith(s))) {
2156
+ delete filteredEnv[key];
2157
+ }
2158
+ }
2159
+ // Inject OSC-633 shell integration (sources the user's real rc first,
2160
+ // then adds prompt markers) so we can slice the stream into blocks.
2161
+ const integ = writeIntegration(sessionId, shell);
2162
+ // A canvas prompt-bar shell session is `prompt-shell-<compositionId>`. Expose that
2163
+ // composition id to the PTY so an interactive `claude` launched inside it inherits
2164
+ // CLEARLY_CANVAS_COMPOSITION — its UserPromptSubmit/PostToolUse/Stop hooks then post
2165
+ // prompt→tool→response blocks onto THIS composition (and stay silent elsewhere).
2166
+ const canvasComp = sessionId.startsWith('prompt-shell-')
2167
+ ? sessionId.slice('prompt-shell-'.length)
2168
+ : '';
2169
+ const ptyProcess = pty.spawn(shell, integ.args, {
2170
+ name: 'xterm-256color',
2171
+ cols,
2172
+ rows,
2173
+ cwd: resolvedCwd,
2174
+ env: {
2175
+ ...filteredEnv,
2176
+ ...integ.env,
2177
+ ...(canvasComp && canvasComp !== 'scratch'
2178
+ ? { CLEARLY_CANVAS_COMPOSITION: canvasComp }
2179
+ : {}),
2180
+ },
2181
+ });
2182
+ this.ptySessions.set(sessionId, ptyProcess);
2183
+ // Block parser — fed the SAME bytes as pty-output (it never consumes them,
2184
+ // so the live xterm is unaffected); emits a terminal-block per completed
2185
+ // command, which the DO mints as a canvas frame.
2186
+ const parser = new TerminalBlockParser((block) => {
2187
+ this.wsSend({ type: 'terminal-block', sessionId, ...block });
2188
+ });
2189
+ this.ptyParsers.set(sessionId, parser);
2190
+ // Buffer input until the OSC-633 integration prints its first prompt (marker A/B) — the first
2191
+ // command otherwise races the shell's rc-sourcing and runs with no markers → no terminal-block.
2192
+ // Fallback: flush after a grace period if no marker ever arrives (integration absent).
2193
+ this.ptyInputQueue.set(sessionId, []);
2194
+ this.ptyReady.delete(sessionId);
2195
+ this.ptyReadyTimers.set(sessionId, setTimeout(() => this._markPtyReady(sessionId), 2500));
2196
+ ptyProcess.onData((data) => {
2197
+ this.wsSend({ type: 'pty-output', sessionId, data });
2198
+ this.onPtyOutput?.({ type: 'pty-output', sessionId, data });
2199
+ try {
2200
+ parser.write(data);
2201
+ }
2202
+ catch {
2203
+ /* parsing must never break the live stream */
2204
+ }
2205
+ if (!this.ptyReady.has(sessionId) && /\x1b\]633;[AB]/.test(data))
2206
+ this._markPtyReady(sessionId);
2207
+ });
2208
+ ptyProcess.onExit(({ exitCode }) => {
2209
+ this.ptySessions.delete(sessionId);
2210
+ this.ptyParsers.delete(sessionId);
2211
+ this._cleanupPtyReady(sessionId);
2212
+ cleanupIntegration(sessionId);
2213
+ this.wsSend({ type: 'pty-exit', sessionId, exitCode });
2214
+ this.onPtyOutput?.({ type: 'pty-exit', sessionId, exitCode });
2215
+ });
2216
+ console.log(`[relay] PTY started: ${sessionId} (${shell}, ${cols}x${rows}, ${cwd})`);
2217
+ }
2218
+ catch (err) {
2219
+ console.error(`[relay] PTY start failed: ${err.message}`);
2220
+ this.wsSend({ type: 'pty-exit', sessionId, exitCode: 1 });
2221
+ }
2222
+ }
2223
+ // ── Workspace Sync (Tier 2) ─────────────────────────────────────────
2224
+ /** Workspace state — maps workspaceId to temp dir path */
2225
+ workspaces = new Map();
2226
+ /** Auto-clean stale workspaces every 5 minutes */
2227
+ cleanupWorkspacesIfNeeded() {
2228
+ const now = Date.now();
2229
+ for (const [id, ws] of this.workspaces) {
2230
+ if (now - ws.lastUsed > 10 * 60_000) {
2231
+ // Clean up temp dir
2232
+ try {
2233
+ rmSync(ws.path, { recursive: true, force: true });
2234
+ }
2235
+ catch {
2236
+ /* ignore */
2237
+ }
2238
+ this.workspaces.delete(id);
2239
+ }
2240
+ }
2241
+ }
2242
+ workspaceInit(args) {
2243
+ const workspaceId = String(args.workspaceId ?? '').replace(/[^a-zA-Z0-9_-]/g, '');
2244
+ if (!workspaceId)
2245
+ return JSON.stringify({
2246
+ error: 'workspaceId is required (alphanumeric, hyphens, underscores only)',
2247
+ });
2248
+ this.cleanupWorkspacesIfNeeded();
2249
+ const existing = this.workspaces.get(workspaceId);
2250
+ if (existing && existsSync(existing.path)) {
2251
+ existing.lastUsed = Date.now();
2252
+ // Scan existing files for manifest
2253
+ const files = this.scanWorkspaceFiles(existing.path);
2254
+ return JSON.stringify({ path: existing.path, existingFiles: files });
2255
+ }
2256
+ // Create new workspace
2257
+ const tmpDir = join(homedir(), '.beehaven', 'workspaces', workspaceId);
2258
+ mkdirSync(tmpDir, { recursive: true });
2259
+ this.workspaces.set(workspaceId, { path: tmpDir, lastUsed: Date.now() });
2260
+ return JSON.stringify({ path: tmpDir, existingFiles: [] });
2261
+ }
2262
+ async workspaceSync(args) {
2263
+ const workspaceId = args.workspaceId;
2264
+ const files = args.files;
2265
+ if (!workspaceId || !files)
2266
+ return JSON.stringify({ synced: 0, errors: ['Missing args'] });
2267
+ const ws = this.workspaces.get(workspaceId);
2268
+ if (!ws)
2269
+ return JSON.stringify({ synced: 0, errors: ['Workspace not found'] });
2270
+ ws.lastUsed = Date.now();
2271
+ let synced = 0;
2272
+ const errors = [];
2273
+ // Download files in parallel (max 5 concurrent)
2274
+ const CONCURRENCY = 5;
2275
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
2276
+ const batch = files.slice(i, i + CONCURRENCY);
2277
+ await Promise.all(batch.map(async (f) => {
2278
+ try {
2279
+ // H1: Reject path traversal
2280
+ if (!f.relativePath ||
2281
+ f.relativePath.includes('..') ||
2282
+ f.relativePath.startsWith('/')) {
2283
+ errors.push(`${f.relativePath}: rejected (path traversal)`);
2284
+ return;
2285
+ }
2286
+ // H4: Reject non-HTTPS download URLs and internal IPs
2287
+ try {
2288
+ const dlUrl = new URL(f.downloadUrl);
2289
+ if (dlUrl.protocol !== 'https:') {
2290
+ errors.push(`${f.relativePath}: rejected (non-HTTPS URL)`);
2291
+ return;
2292
+ }
2293
+ if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.)/.test(dlUrl.hostname) ||
2294
+ dlUrl.hostname === 'localhost') {
2295
+ errors.push(`${f.relativePath}: rejected (internal URL)`);
2296
+ return;
2297
+ }
2298
+ }
2299
+ catch {
2300
+ errors.push(`${f.relativePath}: rejected (invalid URL)`);
2301
+ return;
2302
+ }
2303
+ const resp = await fetch(f.downloadUrl);
2304
+ if (!resp.ok) {
2305
+ errors.push(`${f.relativePath}: HTTP ${resp.status}`);
2306
+ return;
2307
+ }
2308
+ const buffer = Buffer.from(await resp.arrayBuffer());
2309
+ const filePath = join(ws.path, f.relativePath);
2310
+ /**
2311
+ * ⚠⚠ THIS CONTAINMENT CHECK WAS `require('path').resolve(...)` AND THREW EVERY TIME.
2312
+ * The package is ESM, so `require` is not defined; the throw was caught by this loop's
2313
+ * own `catch` and reported as a per-file error, meaning `workspaceSync` has never
2314
+ * written a single file. It failed CLOSED — the write is below this line — so the cost
2315
+ * was a dead feature rather than an escape. Same trap as the IPC socket chmod.
2316
+ *
2317
+ * ⚠ AND THE COMPARISON WAS A BARE PREFIX MATCH. `startsWith(ws.path)` accepts a sibling
2318
+ * whose name merely begins the same way — a workspace at `/w/app` would admit writes
2319
+ * resolved into `/w/app-evil`. Comparing against the path plus a separator is what
2320
+ * makes it a DIRECTORY test rather than a string test.
2321
+ */
2322
+ const root = resolve(ws.path);
2323
+ const resolved = resolve(filePath);
2324
+ if (resolved !== root && !resolved.startsWith(root + sep)) {
2325
+ errors.push(`${f.relativePath}: rejected (path escape)`);
2326
+ return;
2327
+ }
2328
+ mkdirSync(dirname(filePath), { recursive: true });
2329
+ writeFileSync(filePath, buffer);
2330
+ synced++;
2331
+ }
2332
+ catch (err) {
2333
+ errors.push(`${f.relativePath}: ${err.message}`);
2334
+ }
2335
+ }));
2336
+ }
2337
+ return JSON.stringify({ synced, errors });
2338
+ }
2339
+ workspaceExec(args) {
2340
+ const workspaceId = args.workspaceId;
2341
+ const command = args.command;
2342
+ const cwdRelative = args.cwd || '.';
2343
+ const timeout = Math.min(args.timeout || 60_000, 120_000);
2344
+ const extraEnv = args.env || {};
2345
+ const ws = this.workspaces.get(workspaceId);
2346
+ if (!ws)
2347
+ return JSON.stringify({
2348
+ stdout: '',
2349
+ stderr: 'Workspace not found',
2350
+ exitCode: 1,
2351
+ changedFiles: [],
2352
+ });
2353
+ ws.lastUsed = Date.now();
2354
+ const cwd = join(ws.path, cwdRelative);
2355
+ if (!existsSync(cwd))
2356
+ mkdirSync(cwd, { recursive: true });
2357
+ // Snapshot file state before execution
2358
+ const filesBefore = this.scanWorkspaceFiles(ws.path);
2359
+ const beforeMap = new Map(filesBefore.map((f) => [f.relativePath, f.size]));
2360
+ let stdout = '';
2361
+ let stderr = '';
2362
+ let exitCode = 0;
2363
+ try {
2364
+ stdout = execSync(command, {
2365
+ cwd,
2366
+ timeout,
2367
+ maxBuffer: 2 * 1024 * 1024, // 2MB for workspace commands
2368
+ encoding: 'utf8',
2369
+ stdio: ['pipe', 'pipe', 'pipe'],
2370
+ env: { ...process.env, ...extraEnv, TERM: 'dumb' },
2371
+ });
2372
+ if (stdout.length > 50_000)
2373
+ stdout = stdout.slice(0, 50_000) + '\n... (truncated)';
2374
+ }
2375
+ catch (err) {
2376
+ exitCode = err.status || 1;
2377
+ stderr =
2378
+ (err.stderr ? String(err.stderr).slice(0, 5000) : '') ||
2379
+ (err.stdout ? String(err.stdout).slice(0, 5000) : '') ||
2380
+ err.message ||
2381
+ 'Command failed';
2382
+ stdout = err.stdout ? String(err.stdout).slice(0, 50_000) : '';
2383
+ }
2384
+ // Diff filesystem to find changed files
2385
+ const filesAfter = this.scanWorkspaceFiles(ws.path);
2386
+ const changedFiles = [];
2387
+ for (const f of filesAfter) {
2388
+ const beforeSize = beforeMap.get(f.relativePath);
2389
+ if (beforeSize === undefined || beforeSize !== f.size) {
2390
+ changedFiles.push(f);
2391
+ }
2392
+ }
2393
+ return JSON.stringify({ stdout, stderr, exitCode, changedFiles });
2394
+ }
2395
+ async workspaceUpload(args) {
2396
+ const workspaceId = args.workspaceId;
2397
+ const files = args.files;
2398
+ if (!workspaceId || !files)
2399
+ return JSON.stringify({ uploaded: 0, errors: ['Missing args'] });
2400
+ const ws = this.workspaces.get(workspaceId);
2401
+ if (!ws)
2402
+ return JSON.stringify({ uploaded: 0, errors: ['Workspace not found'] });
2403
+ ws.lastUsed = Date.now();
2404
+ let uploaded = 0;
2405
+ const errors = [];
2406
+ const CONCURRENCY = 5;
2407
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
2408
+ const batch = files.slice(i, i + CONCURRENCY);
2409
+ await Promise.all(batch.map(async (f) => {
2410
+ try {
2411
+ const filePath = join(ws.path, f.relativePath);
2412
+ if (!existsSync(filePath)) {
2413
+ errors.push(`${f.relativePath}: not found`);
2414
+ return;
2415
+ }
2416
+ const content = readFileSync(filePath);
2417
+ const resp = await fetch(f.uploadUrl, {
2418
+ method: 'PUT',
2419
+ body: content,
2420
+ headers: { 'Content-Type': 'application/octet-stream' },
2421
+ });
2422
+ if (!resp.ok) {
2423
+ errors.push(`${f.relativePath}: upload HTTP ${resp.status}`);
2424
+ return;
2425
+ }
2426
+ uploaded++;
2427
+ }
2428
+ catch (err) {
2429
+ errors.push(`${f.relativePath}: ${err.message}`);
2430
+ }
2431
+ }));
2432
+ }
2433
+ return JSON.stringify({ uploaded, errors });
2434
+ }
2435
+ /** Scan all files in workspace, returning relative paths and sizes */
2436
+ scanWorkspaceFiles(rootDir, maxFiles = 500) {
2437
+ const files = [];
2438
+ const walk = (dir, depth) => {
2439
+ if (depth > 10 || files.length >= maxFiles)
2440
+ return;
2441
+ try {
2442
+ const entries = readdirSync(dir, { withFileTypes: true });
2443
+ for (const entry of entries) {
2444
+ if (files.length >= maxFiles)
2445
+ return;
2446
+ if (entry.name === 'node_modules' || entry.name === '.git')
2447
+ continue;
2448
+ const fullPath = join(dir, entry.name);
2449
+ if (entry.isDirectory()) {
2450
+ walk(fullPath, depth + 1);
2451
+ }
2452
+ else {
2453
+ const stat = statSync(fullPath);
2454
+ const relativePath = fullPath.slice(rootDir.length + 1);
2455
+ files.push({ relativePath, size: stat.size });
2456
+ }
2457
+ }
2458
+ }
2459
+ catch {
2460
+ /* permission denied */
2461
+ }
2462
+ };
2463
+ walk(rootDir, 0);
2464
+ return files;
2465
+ }
2466
+ /** Walk a directory recursively, collecting paths that match the regex */
2467
+ walkDir(dir, results, regex, maxResults, maxDepth, depth = 0) {
2468
+ if (depth > maxDepth || results.length >= maxResults)
2469
+ return;
2470
+ try {
2471
+ const entries = readdirSync(dir, { withFileTypes: true });
2472
+ for (const entry of entries) {
2473
+ if (results.length >= maxResults)
2474
+ return;
2475
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
2476
+ continue;
2477
+ const fullPath = join(dir, entry.name);
2478
+ if (entry.isDirectory()) {
2479
+ this.walkDir(fullPath, results, regex, maxResults, maxDepth, depth + 1);
2480
+ }
2481
+ else if (regex.test(fullPath)) {
2482
+ results.push(fullPath);
2483
+ }
2484
+ }
2485
+ }
2486
+ catch {
2487
+ /* permission denied or similar */
2488
+ }
2489
+ }
2490
+ /** Convert a glob pattern to a regex */
2491
+ patternToRegex(pattern) {
2492
+ const escaped = pattern
2493
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
2494
+ .replace(/\*\*/g, '{{GLOBSTAR}}')
2495
+ .replace(/\*/g, '[^/]*')
2496
+ .replace(/\?/g, '[^/]')
2497
+ .replace(/\{\{GLOBSTAR\}\}/g, '.*');
2498
+ return new RegExp(`^${escaped}$`);
2499
+ }
2500
+ async syncFileDelete(brandId, relativePath) {
2501
+ if (!this.config || !this.connected)
2502
+ return;
2503
+ try {
2504
+ const msg = { type: 'file-sync', brandId, action: 'delete', file: { path: relativePath } };
2505
+ if (this.useWebSocket) {
2506
+ this.wsSend(msg);
2507
+ }
2508
+ else {
2509
+ await this.postHttp(msg);
2510
+ }
2511
+ }
2512
+ catch (err) {
2513
+ console.error(`[relay] File delete failed (${relativePath}): ${err.message}`);
2514
+ }
2515
+ }
2516
+ async updateBrandSync(brandId, data) {
2517
+ if (!this.config)
2518
+ return;
2519
+ try {
2520
+ const msg = { type: 'brand-sync', brandId, ...data };
2521
+ if (this.useWebSocket) {
2522
+ this.wsSend(msg);
2523
+ }
2524
+ else {
2525
+ await this.postHttp(msg);
2526
+ }
2527
+ }
2528
+ catch (err) {
2529
+ console.error(`[relay] Brand sync update failed: ${err.message}`);
2530
+ }
2531
+ }
2532
+ /**
2533
+ * ⚠⚠ `project-list`, NOT `list-projects` — THE LATTER IS NOT A REGISTERED ACTION.
2534
+ *
2535
+ * Measured 2026-09-04: `beehaven link` answered *"No projects found in your Clearly account.
2536
+ * Create a project at https://clearly.sh first."* against a workspace holding **50 live
2537
+ * projects**. The daemon was answering *"Unknown action: list-projects. Did you mean: … "* the
2538
+ * whole time, and the bare `catch { return null }` below turned that into an empty list, which
2539
+ * `link` reports as "you have none".
2540
+ *
2541
+ * ⚠ THIRD INSTANCE OF ONE PATTERN IN ONE SESSION — `remember`, `recall` and now this: an action
2542
+ * renamed, the caller left pointing at the old name, and the failure rendered as a confident
2543
+ * empty answer rather than an error. A verb that cannot tell "nothing matched" from "the call
2544
+ * failed" will always report the first.
2545
+ *
2546
+ * ⚠ THE CATCH NO LONGER SWALLOWS. It returns `null` for a genuine failure and `[]` only when the
2547
+ * workspace really has no projects — two different answers, so the caller can say which happened.
2548
+ */
2549
+ async listProjects() {
2550
+ if (!this.config)
2551
+ return null;
2552
+ try {
2553
+ const resp = this.useWebSocket
2554
+ ? await this.sendRequest({ type: 'project-list', limit: 500 })
2555
+ : await this.postHttp({ type: 'project-list', limit: 500 });
2556
+ // ⚠ An `{ error }` comes back as a VALUE on this transport — it never throws.
2557
+ if (!resp || resp.error)
2558
+ return null;
2559
+ return Array.isArray(resp.projects) ? resp.projects : [];
2560
+ }
2561
+ catch {
2562
+ return null;
2563
+ }
2564
+ }
2565
+ getMimeType(filePath) {
2566
+ const ext = filePath.split('.').pop()?.toLowerCase() || '';
2567
+ const types = {
2568
+ png: 'image/png',
2569
+ jpg: 'image/jpeg',
2570
+ jpeg: 'image/jpeg',
2571
+ gif: 'image/gif',
2572
+ webp: 'image/webp',
2573
+ svg: 'image/svg+xml',
2574
+ avif: 'image/avif',
2575
+ ico: 'image/x-icon',
2576
+ bmp: 'image/bmp',
2577
+ woff: 'font/woff',
2578
+ woff2: 'font/woff2',
2579
+ ttf: 'font/ttf',
2580
+ otf: 'font/otf',
2581
+ json: 'application/json',
2582
+ css: 'text/css',
2583
+ md: 'text/markdown',
2584
+ pdf: 'application/pdf',
2585
+ yaml: 'text/yaml',
2586
+ yml: 'text/yaml',
2587
+ txt: 'text/plain',
2588
+ scss: 'text/x-scss',
2589
+ less: 'text/x-less',
2590
+ };
2591
+ return types[ext] || 'application/octet-stream';
2592
+ }
2593
+ // ── Internals ─────────────────────────────────────────────────────────
2594
+ /** Flush buffer — sends accumulated events + docs */
2595
+ async flush() {
2596
+ if (!this.config)
2597
+ return;
2598
+ const { events, docs } = this.buffer;
2599
+ const hasData = events.length > 0 || docs.length > 0;
2600
+ if (!hasData && this.sessionEventBatch.size === 0)
2601
+ return;
2602
+ // Snapshot and clear buffer
2603
+ const payload = {};
2604
+ if (events.length > 0)
2605
+ payload.events = events.slice(0, 20);
2606
+ if (docs.length > 0)
2607
+ payload.docs = docs;
2608
+ this.buffer = { events: [], docs: [] };
2609
+ let ok = false;
2610
+ if (hasData) {
2611
+ if (this.useWebSocket) {
2612
+ this.wsSend({ type: 'batch-sync', ...payload });
2613
+ ok = this.connected;
2614
+ }
2615
+ else {
2616
+ const resp = await this.postHttp({ type: 'batch', ...payload });
2617
+ ok = !!resp;
2618
+ }
2619
+ }
2620
+ if (ok) {
2621
+ this.stats.batches++;
2622
+ this.lastSyncAt = Date.now();
2623
+ }
2624
+ // Legacy mode: flush session event batch
2625
+ if (!this.useWebSocket) {
2626
+ await this.flushSessionEventBatch();
2627
+ }
2628
+ }
2629
+ /** Flush batched session events via HTTP (legacy mode only) */
2630
+ async flushSessionEventBatch() {
2631
+ if (!this.config || this.sessionEventBatch.size === 0)
2632
+ return;
2633
+ const sessionIds = [...this.sessionEventBatch.keys()];
2634
+ for (const sessionId of sessionIds) {
2635
+ const events = this.sessionEventBatch.get(sessionId);
2636
+ if (!events || events.length === 0) {
2637
+ this.sessionEventBatch.delete(sessionId);
2638
+ continue;
2639
+ }
2640
+ const batch = events.splice(0, 50);
2641
+ const resp = await this.postHttp({ type: 'session-events', sessionId, events: batch });
2642
+ if (!resp) {
2643
+ const remaining = this.sessionEventBatch.get(sessionId) || [];
2644
+ this.sessionEventBatch.set(sessionId, [...batch, ...remaining]);
2645
+ }
2646
+ const current = this.sessionEventBatch.get(sessionId);
2647
+ if (current && current.length === 0)
2648
+ this.sessionEventBatch.delete(sessionId);
2649
+ }
2650
+ }
2651
+ /** Send heartbeat via HTTP (legacy mode) */
2652
+ async sendHeartbeatHttp() {
2653
+ try {
2654
+ const resp = await this.postHttp({ type: 'heartbeat', deviceId: this.deviceId });
2655
+ if (!resp)
2656
+ return false;
2657
+ if (resp.tier)
2658
+ this.tier = resp.tier;
2659
+ const profileData = resp.profile;
2660
+ if (profileData) {
2661
+ this.profile = {
2662
+ displayName: profileData.displayName || 'Clearly User',
2663
+ username: profileData.username,
2664
+ photoURL: profileData.photoURL,
2665
+ email: profileData.email,
2666
+ subscriptionPlan: profileData.subscriptionPlan || 'free',
2667
+ subscriptionStatus: profileData.subscriptionStatus,
2668
+ shareTerminal: profileData.shareTerminal,
2669
+ shareTelemetry: profileData.shareTelemetry,
2670
+ };
2671
+ const json = JSON.stringify(this.profile);
2672
+ if (json !== this.lastProfileJson) {
2673
+ this.lastProfileJson = json;
2674
+ this.onProfileUpdate?.(this.profile);
2675
+ }
2676
+ }
2677
+ return true;
2678
+ }
2679
+ catch {
2680
+ return false;
2681
+ }
2682
+ }
2683
+ /** Single POST to the relay endpoint (legacy HTTP mode) */
2684
+ async postHttp(data) {
2685
+ if (!this.config)
2686
+ return null;
2687
+ try {
2688
+ const resp = await fetch(this.httpUrl(), {
2689
+ method: 'POST',
2690
+ headers: {
2691
+ 'Content-Type': 'application/json',
2692
+ Authorization: `Bearer ${this.config.token}`,
2693
+ },
2694
+ body: JSON.stringify(data),
2695
+ signal: AbortSignal.timeout(10_000),
2696
+ });
2697
+ if (!resp.ok) {
2698
+ this.stats.failed++;
2699
+ if (resp.status === 401) {
2700
+ console.error('[relay] Token rejected — sign in from the Clearly Dev Mac app tray');
2701
+ this.connected = false;
2702
+ }
2703
+ else if (resp.status === 429) {
2704
+ console.warn('[relay] Rate limited — backing off');
2705
+ }
2706
+ return null;
2707
+ }
2708
+ this.stats.sent++;
2709
+ return await resp.json();
2710
+ }
2711
+ catch (err) {
2712
+ this.stats.failed++;
2713
+ if (err.name !== 'AbortError') {
2714
+ console.error(`[relay] POST error: ${err.message}`);
2715
+ }
2716
+ if (this.stats.failed > 5 && this.connected) {
2717
+ this.connected = false;
2718
+ this.scheduleReconnect();
2719
+ }
2720
+ return null;
2721
+ }
2722
+ }
2723
+ }