beehaven 0.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
@@ -0,0 +1,1048 @@
1
+ /**
2
+ * beehaven shell — Execute commands on a live Durable Object.
3
+ *
4
+ * The DO's pseudo-shell supports: ls, cat, grep, find, tree, echo, head, tail,
5
+ * wc, sort, jq, sql, mkdir, rm, mv, cp, pipes (|), redirects (> >>).
6
+ *
7
+ * Usage:
8
+ * beehaven shell <building-type> <building-id> "<command>"
9
+ * beehaven shell salon lunas "ls /folders/products/"
10
+ * beehaven shell salon lunas "cat /folders/products/haircut.json"
11
+ * beehaven shell salon lunas "sql 'SELECT * FROM products WHERE price > 50'"
12
+ * beehaven shell salon lunas "grep urgent /folders/notes/"
13
+ *
14
+ * Shorthand (when building is configured in beehaven.json):
15
+ * beehaven ls /folders/products/
16
+ * beehaven cat /folders/products/haircut.json
17
+ * beehaven sql "SELECT * FROM products"
18
+ * beehaven grep draft /folders/notes/
19
+ *
20
+ * Logs:
21
+ * beehaven logs <building-type> <building-id> # syslog
22
+ * beehaven logs <building-type> <building-id> --ws # WS message log
23
+ * beehaven logs <building-type> <building-id> --ops # operation audit
24
+ * beehaven logs <building-type> <building-id> --triggers # trigger metrics
25
+ */
26
+ import WebSocket from 'ws';
27
+ import { readFileSync } from 'fs';
28
+ import { join } from 'path';
29
+ import { homedir } from 'os';
30
+ import { daemonGetConnection } from '../daemon-client.js';
31
+ import { getTarget, HOME_ADDRESSES } from '../cli-state.js';
32
+ /**
33
+ * ⚠ A PURE LEAF, IMPORTED EAGERLY ON PURPOSE. `shell-output.ts` has no imports of its own and no
34
+ * side effects, and this module is itself lazily loaded by `cli.ts`, so there is nothing to defer.
35
+ * The eager-import budget is a rule about `cli.ts`, not about command modules.
36
+ */
37
+ import { formatShellResult, formatFailure, breadcrumbAddress, grepAttributionFlags, } from './shell-output.js';
38
+ const DEFAULT_PORT = 8787;
39
+ /**
40
+ * Execute a single shell command on a DO.
41
+ * Connect → auth → shell:exec → print stdout → disconnect.
42
+ */
43
+ export async function shell(args, opts = {}) {
44
+ if (args.length < 3) {
45
+ console.error('Usage: beehaven shell <building-type> <building-id> "<command>"');
46
+ console.error(' e.g. beehaven shell salon lunas "ls /folders/products/"');
47
+ process.exit(1);
48
+ }
49
+ const [buildingType, buildingId, ...commandParts] = args;
50
+ const command = commandParts.join(' ');
51
+ if (!command) {
52
+ console.error('Missing command. Usage: beehaven shell salon lunas "ls /folders/"');
53
+ process.exit(1);
54
+ }
55
+ const result = await execRemote(buildingType, buildingId, command, opts);
56
+ if (result.stdout)
57
+ process.stdout.write(result.stdout);
58
+ if (result.stderr)
59
+ process.stderr.write(result.stderr);
60
+ if (result.exitCode !== 0)
61
+ process.exit(result.exitCode);
62
+ }
63
+ /**
64
+ * Fetch logs from a DO. Defaults to syslog (the `_log()` ring buffer that
65
+ * webhook handlers and worker-side RPCs write to via `this._log(level,
66
+ * event, detail)`). Other surfaces:
67
+ *
68
+ * --ops operation audit (admin-only, _opsAudit ring buffer)
69
+ * --ws WebSocket message log
70
+ * --triggers trigger metrics
71
+ *
72
+ * Target resolution: same as VFS shorthand — uses `beehaven cd` target if
73
+ * no <type> <id> args are given. So once you `beehaven cd home`, plain
74
+ * `beehaven logs` shows your home workspace's syslog (honey credits,
75
+ * Stripe events, errors).
76
+ */
77
+ export async function logs(args, opts = {}) {
78
+ // Resolve target: positional <type> <id> wins, then fall through to
79
+ // the same resolver `beehaven ls` / `cat` / etc. use.
80
+ let buildingType;
81
+ let buildingId;
82
+ if (args.length >= 2) {
83
+ [buildingType, buildingId] = args;
84
+ }
85
+ else {
86
+ const resolved = await resolveDefaultBuilding();
87
+ buildingType = resolved.buildingType;
88
+ buildingId = resolved.buildingId;
89
+ }
90
+ // Mode selection. Default = syslog for human-readable event log.
91
+ // --ops / --ws / --triggers fall back to the original debug protocol.
92
+ if (opts.ops || opts.ws || opts.triggers) {
93
+ let debugType = 'debug:audit';
94
+ if (opts.ws)
95
+ debugType = 'debug:messages';
96
+ if (opts.triggers)
97
+ debugType = 'debug:triggers';
98
+ const result = await execDebug(buildingType, buildingId, debugType, opts);
99
+ console.log(JSON.stringify(result, null, 2));
100
+ return;
101
+ }
102
+ // Syslog path — works for the home workspace and any DO that uses
103
+ // `_log()`. We read /var/log/syslog directly via the shell.exec
104
+ // path so this works whether or not the user is admin (cat respects
105
+ // the same VFS read perms as ls/grep).
106
+ const limit = parseInt(opts.limit ?? '50', 10) || 50;
107
+ // tail -n <limit> /var/log/syslog → human-readable trailing logs.
108
+ const cmd = `tail -n ${limit} /var/log/syslog`;
109
+ const result = await execRemote(buildingType, buildingId, cmd, opts);
110
+ if (result.exitCode === 0) {
111
+ if (result.stdout)
112
+ process.stdout.write(result.stdout);
113
+ return;
114
+ }
115
+ // Fallback: try the admin _admin:logs action via daemonRpc (works
116
+ // for the home workspace where we can't always shell-cat /var/log).
117
+ try {
118
+ const { daemonRpc, isDaemonRunning } = await import('../daemon-client.js');
119
+ if (await isDaemonRunning()) {
120
+ const r = buildingType === 'home'
121
+ ? await daemonRpc('_admin:logs', { limit, level: opts.level })
122
+ : await daemonRpc(`admin:${buildingType}-rpc`, {
123
+ storeId: buildingId,
124
+ action: '_admin:logs',
125
+ input: { limit, level: opts.level },
126
+ });
127
+ const inner = r?.result ?? r;
128
+ const events = inner?.logs ?? [];
129
+ for (const e of events) {
130
+ const ts = new Date(e.timestamp).toISOString();
131
+ const lvl = (e.level ?? 'info').toUpperCase().padEnd(5);
132
+ const detail = e.detail ?? '';
133
+ console.log(`${ts} ${lvl} ${e.event}${detail ? ' ' + detail : ''}`);
134
+ }
135
+ return;
136
+ }
137
+ }
138
+ catch {
139
+ /* fall through to error */
140
+ }
141
+ if (result.stderr)
142
+ process.stderr.write(result.stderr);
143
+ process.exit(result.exitCode || 1);
144
+ }
145
+ /**
146
+ * Shorthand: beehaven ls /folders/ → shell exec "ls /folders/"
147
+ *
148
+ * Building resolution order (first hit wins):
149
+ * 1. Active `beehaven connect` session — via daemon IPC. This is the
150
+ * canonical path and matches what `beehaven actions` / `call` /
151
+ * `workspace` use. Per CLAUDE.md: "Once connected, every other
152
+ * command auto-routes to that target."
153
+ * 2. beehaven.json in the current directory (project-local default).
154
+ * 3. Legacy credentials.json (admin `--url/--secret` flow).
155
+ *
156
+ * Previously this only checked (2) and (3) — so `connect` state was
157
+ * silently dropped for every VFS command, and users got "Cannot
158
+ * determine building" even though they'd just connected successfully.
159
+ */
160
+ /**
161
+ * ── WHICH FILESYSTEM ANSWERS ──────────────────────────────────────────────────────────────────
162
+ *
163
+ * There are two, and until now these verbs only ever reached the wrong one for the common case.
164
+ *
165
+ * • The **VFS** (`shell:exec`) — mounts, block files, `/shop/tickets/`, `/folders/notes/`. Its
166
+ * note listing is uuids: `0044f8ac-….json 005a5ecc-….json … 929 more`.
167
+ * • The **notes shell** (`bash`) — the same notes as a filesystem where paths are TITLES:
168
+ * `ls ~` → `Batch demo/ Clearly SEO/ Q1/`, and `grep -ril pricing ~` works.
169
+ *
170
+ * `CLAUDE.md` used to explain that there was no notes terminal because "the prerequisite is a
171
+ * title-based namespace, not the shell". That namespace was built; nothing repointed these verbs
172
+ * at it, so `beehaven ls` kept printing uuids — a shell over 929 opaque names, which is exactly
173
+ * the thing that argument said was not worth shipping.
174
+ *
175
+ * ⚠ ROUTED BY PATH, NOT SWITCHED WHOLESALE. The VFS serves paths the notes shell knows nothing
176
+ * about, so a blanket repoint would break `/shop/...`, block files and `/folders/...`. A bare verb
177
+ * or a `~`-rooted / relative path is a question about NOTES; an absolute path outside the notes
178
+ * roots is a question about the VFS.
179
+ *
180
+ * ⚠ AND IT DEGRADES. `bash` only exists on a Clearly workspace; a generic hive has no such action.
181
+ * An "unknown action" answer falls back to the VFS rather than failing, so this is an upgrade for
182
+ * the DOs that have it and a no-op everywhere else.
183
+ */
184
+ const NOTES_ROOTS = ['~', '/home', '/code', '/local'];
185
+ /**
186
+ * ⚠ THE SHELL EATS `~` BEFORE THE CLI EVER SEES IT. `beehaven ls ~/Q1` arrives as
187
+ * `ls /Users/you/Q1`, so the one spelling every tool description teaches lands as an absolute
188
+ * local path, routes to the VFS, and comes back "no such file" for a folder that plainly exists.
189
+ * Quoting fixes it and nobody thinks to.
190
+ *
191
+ * Rewriting it back is safe because a workspace path is never a real local path in these verbs —
192
+ * the machine has its own namespace (`/local`). Same class of repair `normalizePath` already makes
193
+ * server-side for `~/~/` and `\/`: it looks like leniency and is a bug fix, because the intent is
194
+ * not in doubt.
195
+ */
196
+ export function unexpandHome(arg) {
197
+ const home = homedir();
198
+ if (!home || arg === '-' || arg.startsWith('-'))
199
+ return arg;
200
+ if (arg === home)
201
+ return '~';
202
+ return arg.startsWith(`${home}/`) ? `~${arg.slice(home.length)}` : arg;
203
+ }
204
+ /**
205
+ * ⚠ THE PATH IS NOT ALWAYS THE FIRST ARGUMENT, and assuming it was mis-routed every `grep`.
206
+ * `grep PATTERN [PATH]` puts the pattern first, so `grep pricing /folders/notes/` read "pricing"
207
+ * as the path, saw a bare relative name, and sent a VFS question to the notes shell. Caught by the
208
+ * test below — which is the only place the difference is visible, because BOTH filesystems answer.
209
+ * One just answers about the wrong corpus.
210
+ */
211
+ const PATTERN_FIRST = new Set(['grep', 'jq']);
212
+ export function wantsNotesShell(command, args) {
213
+ const positional = args.filter((a) => !a.startsWith('-'));
214
+ const path = PATTERN_FIRST.has(command) ? positional[1] : positional[0];
215
+ // No path at all is a question about NOTES: bare `ls`, or `grep foo` with nothing scoping it.
216
+ if (!path)
217
+ return true;
218
+ if (path.startsWith('~'))
219
+ return true;
220
+ if (!path.startsWith('/'))
221
+ return true; // relative — the notes shell normalizes it
222
+ return NOTES_ROOTS.some((r) => path === r || path.startsWith(`${r}/`));
223
+ }
224
+ /**
225
+ * ⚠⚠ ONE ARGUMENT MUST SURVIVE AS ONE ARGUMENT. The caller's own shell has already stripped the
226
+ * quotes, so `beehaven cat "~/CLR-47 The Beaching.md"` reaches us as ONE argv entry containing
227
+ * spaces — and joining the array back into a command string hands the REMOTE shell three paths.
228
+ *
229
+ * Measured live on hive2 2026-08-19: `beehaven cat "/home/CLR-47 The Beaching.md"` answered
230
+ * `cat: The: No such file or directory`. It has always been broken for multi-word titles; it became
231
+ * broken for EVERY note the day filenames gained a key prefix, because `CLR-47 The Beaching.md`
232
+ * has a space in it by construction.
233
+ *
234
+ * ⚠ SINGLE QUOTES, WITH THE POSIX ESCAPE FOR AN EMBEDDED ONE (`'\''`). Double quotes would leave
235
+ * `$`, backticks and `\` live inside the string — a filename containing `$` is unusual but a
236
+ * filename that silently expands to something else is a data bug, not a display one.
237
+ *
238
+ * ⚠ ARGUMENTS THAT NEED NOTHING ARE LEFT BARE, so the routing breadcrumb and the notes-shell
239
+ * command still read like the command a person would type. Quoting everything would turn
240
+ * `ls ~` into `ls '~'` — and `~` must reach the remote tokenizer UNQUOTED or it stops expanding.
241
+ */
242
+ /**
243
+ * Verbs whose argument is the REST OF THE LINE rather than a list of arguments.
244
+ *
245
+ * ⚠ A NAMED SET, NEVER A HEURISTIC ("does it look like SQL?"). Getting it wrong in one direction
246
+ * breaks a statement; in the other it un-quotes a filename with a space in it, which is the bug
247
+ * `shellQuoteArg` was written to fix.
248
+ */
249
+ export const REST_OF_LINE_COMMANDS = new Set(['sql']);
250
+ export function shellQuoteArg(arg) {
251
+ if (arg === '')
252
+ return "''";
253
+ // Bare when it is plainly safe: no whitespace, no quote, no shell metacharacter.
254
+ if (/^[A-Za-z0-9_@%+=:,.\/~^-]+$/.test(arg))
255
+ return arg;
256
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
257
+ }
258
+ /**
259
+ * The command line sent to the remote shell.
260
+ *
261
+ * ⚠ EXPORTED AND PURE SO THE BEHAVIOUR CAN BE TESTED, not just the lookup table. A first version
262
+ * left this inline and asserted only on `REST_OF_LINE_COMMANDS`; deleting the branch that READS
263
+ * that set then changed nothing any test could see. The set and the branch are one claim.
264
+ */
265
+ /**
266
+ * Wrap a whole statement as ONE argument for the remote shell.
267
+ *
268
+ * ⚠⚠ DOUBLE QUOTES, ESCAPING ONLY `"` AND `\` — NOT `shellQuoteArg`. That helper wraps in SINGLE
269
+ * quotes and POSIX-escapes any inner `'` as `'\''`, and SQL string literals are written with `'`:
270
+ * `SELECT 'a' AS y` would reach SQLite carrying literal backslashes. That is exactly why `sql` was
271
+ * exempted from quoting in the first place, and why the exemption then made `>` unusable.
272
+ */
273
+ export function sqlQuoteStatement(stmt) {
274
+ return `"${stmt.replace(/(["\\])/g, '\\$1')}"`;
275
+ }
276
+ export function buildShellCommand(command, args) {
277
+ if (!REST_OF_LINE_COMMANDS.has(command)) {
278
+ return `${command} ${args.map(shellQuoteArg).join(' ')}`.trim();
279
+ }
280
+ /**
281
+ * ⚠⚠ THE STATEMENT IS QUOTED NOW, AND SENDING IT BARE MADE `>` UNUSABLE FROM THIS CLI.
282
+ *
283
+ * Measured on staging, both spellings of the same question failed:
284
+ * beehaven sql "SELECT … WHERE created_at > 178…" -> `>` was read as a shell REDIRECT
285
+ * sql "SELECT …" (quotes reaching the handler) -> must begin with SELECT
286
+ * …while the identical statement through `workspace-sql` returned rows. Two shut doors on the
287
+ * one verb that answers what a keyword search cannot.
288
+ *
289
+ * ⚠⚠ IT REQUIRES THE WORKER THAT STRIPS THE WRAPPER — DEPLOY THAT FIRST. Against an older
290
+ * worker every `sql` from this CLI is refused for not beginning with SELECT. That is the
291
+ * standing worker-before-frontend rule, and this is a case where it genuinely bites.
292
+ *
293
+ * ⚠ AN EMPTY REST OF LINE STAYS BARE. `sql` with no statement returns the SCHEMA; `sql ""` is a
294
+ * quoted nothing, which is a different question.
295
+ */
296
+ const rest = args.join(' ').trim();
297
+ return rest ? `${command} ${sqlQuoteStatement(rest)}` : command;
298
+ }
299
+ export async function shorthand(command, args, opts = {}) {
300
+ const { buildingType, buildingId } = await resolveDefaultBuilding();
301
+ // ⚠ BEFORE the routing decision AND before the command string — see `unexpandHome`.
302
+ const shellArgs = args.map(unexpandHome);
303
+ /**
304
+ * ⚠⚠ ASK `grep` FOR THE ATTRIBUTION IT ALREADY KNOWS HOW TO SEND — see `grepAttributionFlags`.
305
+ * A directory target auto-recurses but the builtin only prints filenames when `-r` was TYPED,
306
+ * so every recursive search came back as bare lines with no way to tell which document each
307
+ * came from. Injected here rather than in `buildShellCommand` because it is a grep decision,
308
+ * not a quoting one, and `wantsNotesShell` below must still see the caller's own operands.
309
+ */
310
+ const cmdArgs = command === 'grep'
311
+ ? [...grepAttributionFlags(shellArgs), ...shellArgs]
312
+ : shellArgs;
313
+ /**
314
+ * ⚠ QUOTED, not joined raw — see `shellQuoteArg`. `wantsNotesShell` is still handed the
315
+ * UNQUOTED args below, because it inspects paths rather than re-parsing a command line.
316
+ *
317
+ * ⚠⚠ EXCEPT WHERE THE ARGUMENT IS THE REST OF THE LINE. `sql` does not take arguments, it takes
318
+ * a STATEMENT: the remote builtin reads everything after the verb verbatim, and quotes inside it
319
+ * are SQL syntax rather than shell syntax. Quoting the whole thing produced
320
+ * `sql 'SELECT 1 AS x'`, which the read-only guard then refused for *not beginning with SELECT* —
321
+ * an error naming the one thing the statement plainly does, about a statement typed correctly.
322
+ *
323
+ * ⚠ IT WAS HIDDEN, NOT NEW. From a connected session the call was refused earlier for developer
324
+ * scope and never reached the guard; on `home` it fails identically, so the bug is older than the
325
+ * routing that was concealing it. Measured both ways.
326
+ *
327
+ * ⚠ QUOTING REMAINS RIGHT FOR EVERYTHING ELSE. `cat "CLR-47 The Beaching.md"` is the case
328
+ * `shellQuoteArg` exists for, and a `grep` pattern containing spaces is genuinely ONE argument.
329
+ * Only a rest-of-line verb is exempt, which is why this is a named set rather than a heuristic.
330
+ */
331
+ const fullCommand = buildShellCommand(command, cmdArgs);
332
+ // ── The notes shell, when the path is a notes question ──
333
+ if (wantsNotesShell(command, shellArgs)) {
334
+ const viaNotes = await execNotesShell(buildingType, buildingId, fullCommand);
335
+ if (viaNotes.result) {
336
+ // ⚠ NAME THE FILESYSTEM. The two answer the same command with different worlds — titles
337
+ // here, uuids there — and without this the difference reads as a bug rather than a route.
338
+ process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} ${fullCommand} (notes)\x1b[0m\n`);
339
+ if (viaNotes.result.stdout)
340
+ process.stdout.write(viaNotes.result.stdout);
341
+ if (viaNotes.result.stderr)
342
+ process.stderr.write(viaNotes.result.stderr);
343
+ if (viaNotes.result.exitCode !== 0)
344
+ process.exit(viaNotes.result.exitCode);
345
+ return;
346
+ }
347
+ /**
348
+ * ⚠⚠ CBH2-61 — A FAILED CALL MUST NOT BE ANSWERED BY A DIFFERENT FILESYSTEM. Falling through
349
+ * here is what produced *"RPC shell:exec requires developer scope"* for a dropped socket: the
350
+ * wrong filesystem, a permission the caller does not need, and a fix that cannot help.
351
+ */
352
+ if (viaNotes.transportError) {
353
+ process.stderr.write(notesShellTransportRefusal(viaNotes.transportError, fullCommand));
354
+ process.exit(1);
355
+ }
356
+ // else: no `bash` on this DO — fall through to the VFS exactly as before.
357
+ /**
358
+ * ⚠⚠ A NULL FROM `execNotesShell` MEANS EITHER "no notes shell here" OR "no daemon", AND THE
359
+ * SECOND ONE MUST NOT FALL THROUGH.
360
+ *
361
+ * `resolveDefaultBuilding` now answers `home` instead of refusing, which is right — but it
362
+ * turned the daemon-down case into a `getWsUrl` fallback at `127.0.0.1:8787` and the message
363
+ * *"WebSocket error: connect ECONNREFUSED 127.0.0.1"*. That is strictly worse than the refusal
364
+ * it replaced: it names a local dev server the caller never asked for, on a workspace command.
365
+ * Caught immediately after the change by running `ls ~` against a daemon that was still coming
366
+ * up.
367
+ *
368
+ * ⚠ ONLY FOR `home`. A real `<type>/<id>` target may legitimately be served over `--url`
369
+ * without a daemon, which is what `execRemote` is for.
370
+ */
371
+ if (buildingType === 'home') {
372
+ const { isDaemonRunning } = await import('../daemon-client.js');
373
+ if (!(await isDaemonRunning())) {
374
+ console.error(' Cannot connect to daemon — your workspace is reached through it.');
375
+ console.error('');
376
+ console.error(' beehaven status # is it running, and is the relay up?');
377
+ console.error(' beehaven start --headless # start one without the Mac app');
378
+ console.error('');
379
+ process.exit(1);
380
+ }
381
+ }
382
+ }
383
+ // Routing breadcrumb on stderr so the VFS output on stdout stays clean
384
+ // and pipeable. Agents can read this to self-correct if they're on the
385
+ // wrong DO.
386
+ process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} ${fullCommand}\x1b[0m\n`);
387
+ const result = await execRemote(buildingType, buildingId, fullCommand, opts);
388
+ if (result.stdout)
389
+ process.stdout.write(result.stdout);
390
+ if (result.stderr)
391
+ process.stderr.write(result.stderr);
392
+ if (result.exitCode !== 0) {
393
+ // Give the agent a nav hint on any VFS command failure.
394
+ /**
395
+ * ⚠ THIS USED TO SUGGEST `beehaven ls /` — CLR-1148. A leading `/` outside the four
396
+ * notes roots is a RAW VFS path, which routes to `shell:exec` and needs DEVELOPER
397
+ * scope. So a consumer-scope user following the recovery hint traded one error for a
398
+ * scarier one about permissions. `ls ~` is the notes shell and needs no scope.
399
+ */
400
+ process.stderr.write('\n\x1b[2m Try:\n');
401
+ process.stderr.write(' beehaven pwd # confirm target\n');
402
+ process.stderr.write(' beehaven ls ~ # the notes workspace (no extra scope)\n');
403
+ process.stderr.write(' beehaven ls / # the raw VFS (needs developer scope)\x1b[0m\n');
404
+ process.exit(result.exitCode);
405
+ }
406
+ }
407
+ /**
408
+ * beehaven write <path> <content> — overwrite a VFS file.
409
+ *
410
+ * Marshals the content to the DO pseudo-shell as `echo '<content>' > <path>`
411
+ * (single-quoted, so the local shell never sees a `>` redirect to mangle).
412
+ * On a CompositionsMount path this lands in `this.fs.write` → `writeSync` →
413
+ * the canvas blob. JSON content is safe; single quotes can't be escaped by the
414
+ * DO tokenizer, so we reject them and point at `composition-patch`.
415
+ */
416
+ /**
417
+ * ⚠⚠ THE NOTES DOOR FOR `write`/`edit` — WITHOUT IT THEY DEMAND DEVELOPER SCOPE (fixed 2026-09-04).
418
+ *
419
+ * The read verbs (`ls`, `cat`, `grep`, `tree`, `head`, `tail`, `wc`, `sql`) and the organise verbs
420
+ * (`mkdir`, `cp`, `mv`) were repointed to the notes shell; `write` and `edit` were left on the OLD
421
+ * path, which builds `echo '<content>' > <path>` and runs it through `shell:exec`. Measured against
422
+ * a daemon with no `HIVE_DEV_MODE`: *"RPC 'shell:exec' requires developer scope"* on both, while
423
+ * every sibling verb worked. CLAUDE.md's note that these verbs no longer need dev scope is correct
424
+ * about the reads and silent about these two, so the gap was undocumented as well as unfixed.
425
+ *
426
+ * ⚠ `notes-write` / `notes-edit` ALREADY EXISTED, aliased `write`/`edit`, on the agent allowlist,
427
+ * needing no dev scope. Nothing new is built here — the CLI was calling the wrong thing.
428
+ *
429
+ * ⚠ AND IT REMOVES A REAL LIMITATION, NOT JUST A PERMISSION. The old path REFUSED any content
430
+ * containing a single quote, because it could not escape one into `echo '…'`. An apostrophe is
431
+ * ordinary English, so `write` could not write an ordinary sentence. The RPC takes the content as
432
+ * a JSON field, so there is nothing to escape.
433
+ *
434
+ * ⚠ IT ALSO GAINS THE THINGS THE SHELL PATH SKIPPED — version capture, FTS reindex, preview
435
+ * regeneration and the broadcast to open tabs — because it is the same verb the editor calls.
436
+ *
437
+ * ⚠ NON-NOTES PATHS STILL TAKE THE OLD ROUTE. `/compositions/<id>/blocks/<bid>.json` is a genuine
438
+ * VFS path with no notes equivalent, and that is what the usage line has always advertised.
439
+ */
440
+ /**
441
+ * ⚠ RETURNS THE SAME THREE-STATE OUTCOME AS `execNotesShell` — CBH2-61 lived here TOO. This ended
442
+ * `catch { return null }` and `write`/`edit` read `null` as "no notes shell", falling through to
443
+ * the `echo … > path` VFS route and its developer-scope refusal. Same wrong cause, same wrong fix,
444
+ * on the two verbs that MUTATE — where "did it half-happen?" is the first question a caller has.
445
+ * The workspace's own reply rides in `result`, so the existing `{error, code, hint}` handling
446
+ * downstream is unchanged.
447
+ */
448
+ async function notesWriteRpc(action, input) {
449
+ try {
450
+ const { isDaemonRunning, daemonRpc } = await import('../daemon-client.js');
451
+ // ⚠ See `execNotesShell` — no daemon stays `absent` so the `--url` fallback keeps working.
452
+ if (!(await isDaemonRunning()))
453
+ return { absent: true };
454
+ const { buildingType, buildingId } = await resolveDefaultBuilding();
455
+ let raw;
456
+ if (buildingType === 'home') {
457
+ raw = await daemonRpc(action, input);
458
+ }
459
+ else {
460
+ const { gatewayEnvelope } = await import('./workspace.js');
461
+ const gw = gatewayEnvelope(buildingType, buildingId, action, input);
462
+ raw = await daemonRpc(gw.gateway, gw.payload);
463
+ }
464
+ const r = raw?.result ?? raw;
465
+ // ⚠ Same probe `execNotesShell` uses — a DO with no notes palette must fall through, not fail.
466
+ if (/unknown (action|message type)/i.test(String(r?.error ?? '')))
467
+ return { absent: true };
468
+ return { result: r };
469
+ }
470
+ catch (e) {
471
+ // ⚠ CBH2-61 — was `return null`, i.e. a dropped socket answered as a permission problem.
472
+ return classifyNotesShellFailure(e);
473
+ }
474
+ }
475
+ export async function fsWrite(path, content, opts = {}) {
476
+ if (!path) {
477
+ console.error('Usage: beehaven write <path> <content>');
478
+ console.error(' e.g. beehaven write /compositions/<id>/blocks/<bid>.json \'{"id":"<bid>","type":"text",...}\'');
479
+ process.exit(1);
480
+ }
481
+ if (wantsNotesShell('write', [path])) {
482
+ const outcome = await notesWriteRpc('notes-write', { file_path: unexpandHome(path), content });
483
+ // ⚠ CBH2-61 — a failed CALL is not an absent shell, and on a mutation the caller must be told
484
+ // plainly that nothing was written rather than sent to fix a permission they already have.
485
+ if (outcome.transportError) {
486
+ process.stderr.write(notesShellTransportRefusal(outcome.transportError, 'write'));
487
+ process.exit(1);
488
+ }
489
+ const r = outcome.result;
490
+ if (r) {
491
+ const { buildingType, buildingId } = await resolveDefaultBuilding();
492
+ process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} write ${path} (notes)\x1b[0m\n`);
493
+ /**
494
+ * ⚠ THE SAME FORMATTER THE SHELL VERBS USE — it was dropping `hint` here and `hint` AND
495
+ * `code` in `fsEdit`, so two verbs in one family reported the same refusal differently and
496
+ * neither reported all of it. `notes-write` returns the same `{error, code, hint}` triple
497
+ * `mv`/`rm`/`cp` do.
498
+ */
499
+ if (r.error || r.ok === false) {
500
+ process.stderr.write(formatFailure(r, 'write'));
501
+ process.exit(1);
502
+ }
503
+ /**
504
+ * ⚠⚠ `file_path`, NOT `path` — AND NOT THE PATH THAT WAS TYPED. `Write` derives the title
505
+ * from the BODY's first line, so `write ~/Alpha.md 'first line…'` genuinely creates
506
+ * `~/S13-1 first line….md`; the RPC knows this and echoes where the note ACTUALLY landed.
507
+ * Reading a field it does not return fell back to the requested name, so the CLI printed an
508
+ * address that never existed and the next `cat` answered "No such file or directory" —
509
+ * measured on a live workspace during the 2026-09-04 sweep.
510
+ */
511
+ process.stdout.write(`wrote ${r.file_path ?? r.path ?? path}\n`);
512
+ return;
513
+ }
514
+ }
515
+ if (content.includes("'")) {
516
+ console.error("write: content has a single quote, which the DO shell can't escape. Use `beehaven call composition-patch` for that block.");
517
+ process.exit(1);
518
+ }
519
+ const { buildingType, buildingId } = await resolveDefaultBuilding();
520
+ process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} write ${path}\x1b[0m\n`);
521
+ const result = await execRemote(buildingType, buildingId, `echo '${content}' > ${path}`, opts);
522
+ if (result.stderr)
523
+ process.stderr.write(result.stderr);
524
+ if (result.exitCode !== 0)
525
+ process.exit(result.exitCode);
526
+ process.stdout.write(`wrote ${path}\n`);
527
+ }
528
+ /**
529
+ * beehaven edit <path> <old> <new> — find/replace inside a VFS file.
530
+ * cat → replace every occurrence of <old> with <new> → write back.
531
+ */
532
+ export async function fsEdit(path, oldStr, newStr, opts = {}) {
533
+ if (!path || oldStr === undefined || newStr === undefined) {
534
+ console.error('Usage: beehaven edit <path> <old> <new>');
535
+ process.exit(1);
536
+ }
537
+ /**
538
+ * ⚠ SEE `fsWrite` — same repoint, same reason. And `notes-edit` carries a property the
539
+ * cat-replace-write below cannot: it matches EXACTLY ONCE OR REFUSES. The old path used
540
+ * `split(old).join(new)`, i.e. a silent replace-all, so an edit meant for one occurrence
541
+ * changed every one of them and reported success.
542
+ */
543
+ if (wantsNotesShell('edit', [path])) {
544
+ const outcome = await notesWriteRpc('notes-edit', {
545
+ file_path: unexpandHome(path), old_string: oldStr, new_string: newStr,
546
+ });
547
+ // ⚠ CBH2-61 — see `fsWrite`. Falling through here would additionally hand the caller the
548
+ // OLD cat-replace-write path, whose silent replace-all is the very behaviour this repoint
549
+ // exists to remove — so a dropped socket would have quietly changed the edit's semantics.
550
+ if (outcome.transportError) {
551
+ process.stderr.write(notesShellTransportRefusal(outcome.transportError, 'edit'));
552
+ process.exit(1);
553
+ }
554
+ const r = outcome.result;
555
+ if (r) {
556
+ const { buildingType, buildingId } = await resolveDefaultBuilding();
557
+ process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} edit ${path} (notes)\x1b[0m\n`);
558
+ // ⚠ See `fsWrite` — one formatter, so the two halves of this verb family agree.
559
+ if (r.error || r.ok === false) {
560
+ process.stderr.write(formatFailure(r, 'edit'));
561
+ process.exit(1);
562
+ }
563
+ // ⚠ `file_path` first — same reason as `fsWrite` above.
564
+ process.stdout.write(`edited ${r.file_path ?? r.path ?? path}\n`);
565
+ return;
566
+ }
567
+ }
568
+ const { buildingType, buildingId } = await resolveDefaultBuilding();
569
+ process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} edit ${path}\x1b[0m\n`);
570
+ const read = await execRemote(buildingType, buildingId, `cat ${path}`, opts);
571
+ if (read.exitCode !== 0) {
572
+ if (read.stderr)
573
+ process.stderr.write(read.stderr);
574
+ process.exit(read.exitCode || 1);
575
+ }
576
+ const current = read.stdout.replace(/\n$/, '');
577
+ if (!current.includes(oldStr)) {
578
+ console.error(`edit: '${oldStr}' not found in ${path}`);
579
+ process.exit(1);
580
+ }
581
+ const next = current.split(oldStr).join(newStr);
582
+ if (next.includes("'")) {
583
+ console.error("edit: result has a single quote the DO shell can't escape. Use `beehaven call composition-patch`.");
584
+ process.exit(1);
585
+ }
586
+ const result = await execRemote(buildingType, buildingId, `echo '${next}' > ${path}`, opts);
587
+ if (result.stderr)
588
+ process.stderr.write(result.stderr);
589
+ if (result.exitCode !== 0)
590
+ process.exit(result.exitCode);
591
+ process.stdout.write(`edited ${path}\n`);
592
+ }
593
+ // ── Internal ─────────────────────────────────────────
594
+ async function resolveDefaultBuilding() {
595
+ // 1. CLI-side target file (stateless design — source of truth since the
596
+ // stateless-session PR). Survives daemon restarts.
597
+ const target = getTarget();
598
+ if (target?.address?.includes('/')) {
599
+ const slash = target.address.indexOf('/');
600
+ return {
601
+ buildingType: target.address.slice(0, slash),
602
+ buildingId: target.address.slice(slash + 1),
603
+ };
604
+ }
605
+ // 1b. `home` is the user's default workspace alias — no `<type>/<id>` shape
606
+ // because it's the default target the daemon's relay already binds
607
+ // to. Surface it as a special tuple so VFS shorthand (ls, cat,
608
+ // grep, tree) recognise the case in execRemote and dispatch via
609
+ // daemonRpc('shell:exec', …) directly instead of the admin gateway.
610
+ //
611
+ // `cd home` WRITES the canonical address 'workspace' (cli.ts case 'cd'),
612
+ // but this only matched the literal 'home' — so immediately after a
613
+ // successful `beehaven cd home`, `sql`/`ls`/`cat`/`grep`/`tree` all died
614
+ // with "Cannot determine target. You need to pick one:" and told you to
615
+ // run the `cd home` you had just run. Match every spelling the cd verb
616
+ // accepts, and keep them in one list so a future rename can't re-split
617
+ // the writer from the reader.
618
+ if (target?.address && HOME_ADDRESSES.has(target.address)) {
619
+ return { buildingType: 'home', buildingId: 'home' };
620
+ }
621
+ // 2. Legacy daemon session (for CLIs that predate the stateless design
622
+ // and haven't run `cd` since the upgrade). Retained for one release.
623
+ try {
624
+ const conn = await daemonGetConnection();
625
+ if (conn?.buildingType && conn?.buildingId) {
626
+ return { buildingType: conn.buildingType, buildingId: conn.buildingId };
627
+ }
628
+ }
629
+ catch {
630
+ /* daemon not running or not connected — try fallbacks */
631
+ }
632
+ // 2. beehaven.json in current directory.
633
+ try {
634
+ const config = JSON.parse(readFileSync(join(process.cwd(), 'beehaven.json'), 'utf8'));
635
+ const buildings = config.buildings ?? config.cells ?? {};
636
+ const types = Object.keys(buildings);
637
+ if (types.length > 0) {
638
+ return { buildingType: types[0], buildingId: config.defaultId ?? 'default' };
639
+ }
640
+ }
641
+ catch {
642
+ /* no beehaven.json — try credentials */
643
+ }
644
+ // 3. Legacy ~/.beehaven/credentials.json (admin `--url/--secret` flow).
645
+ const creds = readCredentials();
646
+ if (creds.building && creds.buildingId) {
647
+ return { buildingType: creds.building, buildingId: creds.buildingId };
648
+ }
649
+ /**
650
+ * ⚠⚠ NOTHING RESOLVED ⇒ HOME. THIS USED TO REFUSE, AND TWO SURFACES DISAGREED ABOUT THE SAME
651
+ * STATE.
652
+ *
653
+ * Measured repeatedly on 2026-09-04, one shell, same env, seconds apart:
654
+ *
655
+ * beehaven pwd -> "Target: home (default workspace alias)"
656
+ * beehaven call board-list -> works
657
+ * beehaven ls ~ -> "Cannot determine target. You need to pick one:"
658
+ *
659
+ * `pwd` REPORTS the home default and `call` USES it; only the shell verbs demanded an explicit
660
+ * target. So the CLI told the caller they had a target and then refused to act on it — and the
661
+ * remedy (`beehaven cd home`) is a command that writes the state `pwd` already claimed existed.
662
+ *
663
+ * ⚠ THE SECOND HALF, WHICH IS WHY IT LOOKED INTERMITTENT: the target file is scoped by AGENT
664
+ * NAME when one is bound and by PPID otherwise, so any script that shells out gets a different
665
+ * scope and silently loses the target. Defaulting to home makes that invisible rather than
666
+ * mysterious — and the routing breadcrumb (`→ home/home ls ~ (notes)`) prints the target on
667
+ * every shell command, so an unintended home is visible rather than silent.
668
+ *
669
+ * ⚠ HOME IS ALWAYS REACHABLE — it is the caller's own workspace, not a guess at somebody's DO.
670
+ * A wrong GUESS would be worse than a refusal; this is the same answer the other two verbs give.
671
+ */
672
+ return { buildingType: 'home', buildingId: 'home' };
673
+ }
674
+ function getWsUrl(buildingType, buildingId, opts) {
675
+ // Explicit --url flag takes priority
676
+ if (opts.url) {
677
+ const base = opts.url.replace(/^http/, 'ws');
678
+ return `${base}/workspace/${buildingType}/${buildingId}/ws`;
679
+ }
680
+ // Check saved credentials (beehaven login --url)
681
+ const creds = readCredentials();
682
+ if (creds.workerUrl) {
683
+ const base = creds.workerUrl.replace(/^http/, 'ws');
684
+ return `${base}/workspace/${buildingType}/${buildingId}/ws`;
685
+ }
686
+ // Fall back to local dev server
687
+ const port = opts.port ?? DEFAULT_PORT;
688
+ return `ws://127.0.0.1:${port}/workspace/${buildingType}/${buildingId}/ws`;
689
+ }
690
+ function readCredentials() {
691
+ const home = process.env.HOME ?? homedir();
692
+ const credPath = join(home, '.beehaven', 'credentials.json');
693
+ try {
694
+ const creds = JSON.parse(readFileSync(credPath, 'utf8'));
695
+ return {
696
+ token: creds.token ?? creds.adminSecret ?? '',
697
+ workerUrl: creds.workerUrl ?? '',
698
+ building: creds.building,
699
+ buildingId: creds.buildingId,
700
+ };
701
+ }
702
+ catch {
703
+ return { token: '', workerUrl: '' };
704
+ }
705
+ }
706
+ function getToken(opts) {
707
+ if (opts.token)
708
+ return opts.token;
709
+ return readCredentials().token;
710
+ }
711
+ /** The most useful string available from a thrown value. */
712
+ function errorText(e) {
713
+ if (!e)
714
+ return 'the connection dropped';
715
+ if (typeof e === 'string')
716
+ return e;
717
+ const m = e?.message;
718
+ return typeof m === 'string' && m ? m : String(e);
719
+ }
720
+ /**
721
+ * Absent shell, or failed call? The whole of CBH2-61 turns on this one question.
722
+ *
723
+ * ⚠ ABSENCE IS THE NARROW CASE AND MUST BE PROVEN, NOT ASSUMED. The workspace says so in its own
724
+ * words — `unknown action` / `unknown message type` — and nothing else counts. Defaulting the
725
+ * other way is exactly the old bug: an unrecognised failure became "no notes shell here" and was
726
+ * answered by a filesystem the caller never asked about.
727
+ *
728
+ * PURE, so the decision is testable with no daemon: a reason in, an outcome out.
729
+ */
730
+ export function classifyNotesShellFailure(reason) {
731
+ const msg = errorText(reason);
732
+ if (/unknown (action|message type)/i.test(msg))
733
+ return { absent: true };
734
+ return { transportError: msg };
735
+ }
736
+ /**
737
+ * What to print when the call never reached the workspace.
738
+ *
739
+ * ⚠ IT MUST NAME A CONNECTION, NEVER A PERMISSION. The failure this replaces sent readers to
740
+ * `HIVE_DEV_MODE=1 beehaven restart`, which cannot help — and a restart is the user's daemon to
741
+ * cycle, not ours, so the advice is to LOOK first (`status`) and retry.
742
+ *
743
+ * ⚠ IT SAYS NOTHING RAN. A caller who has just been told a mutation failed needs to know whether
744
+ * it half-happened; on this path it did not happen at all.
745
+ */
746
+ export function notesShellTransportRefusal(transportError, command) {
747
+ const verb = command.trim().split(/\s+/)[0] || 'the command';
748
+ return [
749
+ ` Could not reach your workspace — \`${verb}\` did not run, and nothing was changed.`,
750
+ '',
751
+ ` ${transportError}`,
752
+ '',
753
+ ' This is a connection problem, not a permission one. It is usually momentary —',
754
+ ' the same command often succeeds seconds later.',
755
+ '',
756
+ ' beehaven status # is the daemon up, and is the relay connected?',
757
+ '',
758
+ ].join('\n') + '\n';
759
+ }
760
+ /**
761
+ * Run a command through the workspace's NOTES shell (`bash`), or report why it could not.
762
+ *
763
+ * ⚠ A `bash` that RAN and failed (`no such file`) is a real answer and comes back as `result`; it
764
+ * must not be retried against a different filesystem, which would turn one honest "not found" into
765
+ * two confusing ones.
766
+ */
767
+ async function execNotesShell(buildingType, buildingId, command) {
768
+ try {
769
+ const { isDaemonRunning, daemonRpc } = await import('../daemon-client.js');
770
+ /**
771
+ * ⚠ NO DAEMON IS REPORTED AS `absent`, DELIBERATELY, AND IT IS NOT A TRANSPORT ERROR IN THIS
772
+ * SENSE. The caller below already handles it far better than a generic message could: for
773
+ * `home` it prints the start-the-daemon refusal, and for a real `<type>/<id>` target it falls
774
+ * through to `execRemote`, which can legitimately be served over `--url` with no daemon at
775
+ * all. Classifying it here would BREAK that `--url` path.
776
+ */
777
+ if (!(await isDaemonRunning()))
778
+ return { absent: true };
779
+ /**
780
+ * ⚠ THIS LOGIN'S cwd RIDES WITH THE COMMAND — see `shell-session.ts` for why the client owns
781
+ * it. Keyed by TARGET as well as login, so `cd` on a team does not move `home`.
782
+ */
783
+ const { attachShellCwd, absorbShellCwd, shellTargetKey } = await import('../shell-session.js');
784
+ const shellTarget = shellTargetKey(buildingType === 'home' ? 'home' : `${buildingType}/${buildingId}`);
785
+ const payload = attachShellCwd('bash', { command }, shellTarget);
786
+ /**
787
+ * ⚠⚠ THE SHELL FAMILY RAN UNATTRIBUTED — `ls cat grep tree find head tail wc jq rm mv cp
788
+ * mkdir write edit sql search`, SEVENTEEN VERBS, EVERY ONE OF THEM (fixed 2026-09-05).
789
+ *
790
+ * This called `daemonRpc('bash', …)` bare, which is precisely the defect `agentRpc`'s own
791
+ * docstring names for `rpcOnce`: no `agent-invoke`, so no per-agent gate, no `agent_activity`
792
+ * row, and the work filed against the ACCOUNT OWNER rather than the signed-in agent.
793
+ *
794
+ * ⚠ MEASURED AS A BEFORE/AFTER, WHICH IS THE ONLY THING THAT PROVES IT. The identical
795
+ * command — `beehaven ls ~`, same agent, same target, minutes apart — wrote NO row on the
796
+ * old build and one row on this one.
797
+ *
798
+ * ⚠ DO NOT READ THE DOOR OFF `input_summary`. Every historical `bash` row carries an
799
+ * `[mcp:…]` prefix, and that prefix means only that a SESSION ID was present — which the CLI
800
+ * sets too. It is not evidence of the transport, and reading it as such is precisely the
801
+ * mistake `agent_activity.source` exists to make unnecessary.
802
+ *
803
+ * ⚠⚠ AND IT WOULD HAVE POISONED THE VERY MEASUREMENT `source` EXISTS FOR. A door column over
804
+ * an unattributed shell reports zero CLI traffic for the busiest verbs on the surface, and
805
+ * the retirement list built from it deletes them for being unused — on data we generated
806
+ * ourselves. The attribution has to land BEFORE the column is trusted, not after.
807
+ *
808
+ * ⚠ WRAP FIRST, ROUTE SECOND — the ordering `cmdCall` documents at length. `agent-invoke`
809
+ * must travel INSIDE the proxy so it executes on the workspace being addressed; wrapped
810
+ * outside, it runs on the daemon's default DO and `_checkAgentScope` asks the wrong roster.
811
+ */
812
+ const { activeAgentWrap } = await import('./workspace.js');
813
+ const wrapped = activeAgentWrap('bash', payload, {});
814
+ let raw;
815
+ if (buildingType === 'home') {
816
+ raw = await daemonRpc(wrapped.action, wrapped.input);
817
+ }
818
+ else {
819
+ const { gatewayEnvelope } = await import('./workspace.js');
820
+ const gw = gatewayEnvelope(buildingType, buildingId, wrapped.action, wrapped.input);
821
+ raw = await daemonRpc(gw.gateway, gw.payload);
822
+ }
823
+ const r = raw?.result ?? raw;
824
+ absorbShellCwd('bash', r, shellTarget);
825
+ // No notes shell on this DO — a generic hive, or one predating the palette.
826
+ const err = String(r?.error ?? '');
827
+ if (/unknown (action|message type)/i.test(err))
828
+ return { absent: true };
829
+ /**
830
+ * ⚠ A SHAPELESS ERROR REPLY IS A FAILED CALL, NOT AN ABSENT SHELL. This used to `return null`
831
+ * too, i.e. fall through to the VFS and inherit the developer-scope refusal — the same
832
+ * CBH2-61 defect arriving through the non-throwing door. The workspace answered SOMETHING and
833
+ * it was not a shell result, so report what it actually said.
834
+ */
835
+ if (r == null || (typeof r === 'object' && !('ok' in r) && !('stdout' in r) && err)) {
836
+ return classifyNotesShellFailure(err || 'the workspace returned no result');
837
+ }
838
+ /**
839
+ * ⚠⚠ THE WHOLE RESULT → TERMINAL MAPPING LIVES IN `formatShellResult`, AND THAT MOVE IS THE
840
+ * FIX, not a tidy-up. Inline here it could only be asserted by grepping this file, and the
841
+ * defect it carried was invisible to source scanning: the mapping read `stdout`, `rows` and
842
+ * `stderr` and dropped `error`, `code`, `hint` and every structured success field, so
843
+ * `mkdir`, `mv`, `cp` and `rm` printed NOTHING — on success and on failure alike. A failed
844
+ * `mv` was byte-identical on screen to a successful one.
845
+ *
846
+ * ⚠ `sql`'s `rows` HANDLING MOVED WITH IT, unchanged — see `formatShellResult`.
847
+ */
848
+ return { result: formatShellResult(r, command.trim().split(/\s+/)[0] ?? '') };
849
+ }
850
+ catch (e) {
851
+ /**
852
+ * ⚠⚠ THE LINE CBH2-61 IS ABOUT. It read `return null` with the comment *"transport trouble —
853
+ * let the VFS path report it in its own words"*. Those words described a different failure:
854
+ * the VFS reports a DEVELOPER-SCOPE refusal, which is not what happened and not what to do
855
+ * about it. The reason travels with the outcome now.
856
+ */
857
+ return classifyNotesShellFailure(e);
858
+ }
859
+ }
860
+ async function execRemote(buildingType, buildingId, command, opts) {
861
+ // Why the daemon path gave up, so a later fallback failure can report the
862
+ // ORIGINAL cause rather than whatever the fallback happened to hit.
863
+ let daemonError = '';
864
+ // ── Daemon-first path ────────────────────────────────────────────────
865
+ // Route through `/tmp/beehaven-ipc.sock → daemon → relay → user's workspace
866
+ // DO → admin:store-rpc → target DO → shell:exec`. The daemon is already
867
+ // authed with the user's Firebase keychain JWT, so the caller never holds
868
+ // a long-lived bearer.
869
+ //
870
+ // Falls back to direct WebSocket only if the daemon isn't running. The
871
+ // direct path uses ~/.beehaven/credentials.json (legacy bearer pattern).
872
+ try {
873
+ const { isDaemonRunning, daemonRpc } = await import('../daemon-client.js');
874
+ if (await isDaemonRunning()) {
875
+ // Special case: `home` is the user's default workspace alias. The
876
+ // daemon's relay is bound there by default, so we call
877
+ // shell:exec directly instead of going through admin:*-rpc
878
+ // (which would gate on ADMIN_USER_IDS and isn't the right
879
+ // path for a user's own workspace).
880
+ if (buildingType === 'home') {
881
+ const result = await daemonRpc('shell:exec', {
882
+ command,
883
+ _timeout: 60_000,
884
+ });
885
+ const innerHome = result?.result ?? result;
886
+ if (innerHome?.error) {
887
+ return { stdout: '', stderr: innerHome.error + '\n', exitCode: 1 };
888
+ }
889
+ const out = innerHome?.stdout ?? innerHome?.output ?? innerHome ?? '';
890
+ const stdoutHome = typeof out === 'string' ? out : JSON.stringify(out, null, 2);
891
+ return {
892
+ stdout: stdoutHome.endsWith('\n') ? stdoutHome : stdoutHome + '\n',
893
+ stderr: innerHome?.stderr ?? '',
894
+ exitCode: innerHome?.exitCode ?? 0,
895
+ };
896
+ }
897
+ // ⚠ ONE envelope builder — this site had its own copy hardcoding `storeId`, so every
898
+ // `beehaven ls` against a TEAM answered "teamId is required". See `gatewayEnvelope`.
899
+ const { gatewayEnvelope } = await import('./workspace.js');
900
+ const gw = gatewayEnvelope(buildingType, buildingId, 'shell:exec', { command, _timeout: 60_000 });
901
+ const result = await daemonRpc(gw.gateway, gw.payload);
902
+ // Unwrap the {result: ...} envelope from admin:store-rpc
903
+ const inner = result?.result ?? result;
904
+ if (inner?.error) {
905
+ return { stdout: '', stderr: inner.error + '\n', exitCode: 1 };
906
+ }
907
+ const output = inner?.stdout ?? inner?.output ?? inner ?? '';
908
+ const stdout = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
909
+ return {
910
+ stdout: stdout.endsWith('\n') ? stdout : stdout + '\n',
911
+ stderr: inner?.stderr ?? '',
912
+ exitCode: inner?.exitCode ?? 0,
913
+ };
914
+ }
915
+ }
916
+ catch (err) {
917
+ // Daemon path failed — fall through to direct WebSocket. Common causes:
918
+ // daemon not authed, ADMIN_USER_IDS doesn't include the user, the
919
+ // target DO doesn't support shell:exec via the gateway.
920
+ if (process.env.BEEHAVEN_DEBUG) {
921
+ console.error(`[shell] daemon path failed, falling back: ${err.message}`);
922
+ }
923
+ // …but some failures the fallback CANNOT fix. A scope or auth denial is
924
+ // about WHO is asking, not WHERE — retrying against a local dev worker
925
+ // just swaps the real message for `ECONNREFUSED 127.0.0.1:8787`, which
926
+ // names a port the user never mentioned and hides the one-line fix.
927
+ // `beehaven sql` on home reported exactly that: the true cause ("requires
928
+ // developer scope") was visible only under BEEHAVEN_DEBUG.
929
+ const reason = String(err?.message ?? '');
930
+ if (/developer scope|requires .* scope|unauthorized|not authenticated|token/i.test(reason)) {
931
+ const hint = /developer scope/i.test(reason)
932
+ ? '\n Fix: HIVE_DEV_MODE=1 beehaven restart (or enable Developer Mode in the Hive Mac app)\n'
933
+ : '\n Fix: beehaven login (re-auth; a restart will NOT refresh an expired token)\n';
934
+ return { stdout: '', stderr: `${reason}${hint}`, exitCode: 1 };
935
+ }
936
+ daemonError = reason;
937
+ }
938
+ // ── Legacy direct-WebSocket path (fallback only) ─────────────────────
939
+ return new Promise((resolve, reject) => {
940
+ const token = getToken(opts);
941
+ const wsUrl = getWsUrl(buildingType, buildingId, opts) +
942
+ (token ? `?token=${encodeURIComponent(token)}` : '');
943
+ const ws = new WebSocket(wsUrl, ['beehaven-v1']);
944
+ const requestId = `shell_${Date.now()}`;
945
+ let settled = false;
946
+ const timeout = setTimeout(() => {
947
+ if (!settled) {
948
+ settled = true;
949
+ ws.close();
950
+ reject(new Error('Timeout: shell command did not respond within 30s'));
951
+ }
952
+ }, 30_000);
953
+ ws.on('open', () => {
954
+ ws.send(JSON.stringify({ type: 'shell:exec', command, requestId }));
955
+ });
956
+ ws.on('message', (data) => {
957
+ try {
958
+ const msg = JSON.parse(data.toString());
959
+ if (msg.requestId === requestId || msg.rid === requestId) {
960
+ clearTimeout(timeout);
961
+ settled = true;
962
+ ws.close();
963
+ if (msg.error) {
964
+ resolve({ stdout: '', stderr: msg.error + '\n', exitCode: 1 });
965
+ }
966
+ else {
967
+ const output = msg.data?.stdout ?? msg.data?.output ?? msg.data ?? '';
968
+ const stdout = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
969
+ resolve({
970
+ stdout: stdout.endsWith('\n') ? stdout : stdout + '\n',
971
+ stderr: msg.data?.stderr ?? '',
972
+ exitCode: msg.data?.exitCode ?? 0,
973
+ });
974
+ }
975
+ }
976
+ }
977
+ catch { }
978
+ });
979
+ ws.on('error', (err) => {
980
+ if (!settled) {
981
+ settled = true;
982
+ clearTimeout(timeout);
983
+ // The fallback dials a LOCAL dev worker when there are no saved
984
+ // credentials, so its failure is almost always `ECONNREFUSED
985
+ // 127.0.0.1:8787` — a port the user never asked about, standing in
986
+ // for whatever actually went wrong upstream. Lead with the daemon's
987
+ // reason when we have one.
988
+ const detail = daemonError
989
+ ? `${daemonError}\n (then the direct fallback failed too: ${err.message})`
990
+ : `WebSocket error: ${err.message}`;
991
+ reject(new Error(detail));
992
+ }
993
+ });
994
+ ws.on('close', () => {
995
+ if (!settled) {
996
+ settled = true;
997
+ clearTimeout(timeout);
998
+ reject(new Error('Connection closed before response'));
999
+ }
1000
+ });
1001
+ });
1002
+ }
1003
+ function execDebug(buildingType, buildingId, debugType, opts) {
1004
+ return new Promise((resolve, reject) => {
1005
+ const token = getToken(opts);
1006
+ const wsUrl = getWsUrl(buildingType, buildingId, opts) +
1007
+ (token ? `?token=${encodeURIComponent(token)}` : '');
1008
+ const ws = new WebSocket(wsUrl, ['beehaven-v1']);
1009
+ const requestId = `debug_${Date.now()}`;
1010
+ let settled = false;
1011
+ const timeout = setTimeout(() => {
1012
+ if (!settled) {
1013
+ settled = true;
1014
+ ws.close();
1015
+ reject(new Error('Timeout'));
1016
+ }
1017
+ }, 15_000);
1018
+ ws.on('open', () => {
1019
+ ws.send(JSON.stringify({ type: debugType, requestId }));
1020
+ });
1021
+ ws.on('message', (data) => {
1022
+ try {
1023
+ const msg = JSON.parse(data.toString());
1024
+ if (msg.requestId === requestId || msg.rid === requestId) {
1025
+ clearTimeout(timeout);
1026
+ settled = true;
1027
+ ws.close();
1028
+ resolve(msg.data ?? msg);
1029
+ }
1030
+ }
1031
+ catch { }
1032
+ });
1033
+ ws.on('error', (err) => {
1034
+ if (!settled) {
1035
+ settled = true;
1036
+ clearTimeout(timeout);
1037
+ reject(err);
1038
+ }
1039
+ });
1040
+ ws.on('close', () => {
1041
+ if (!settled) {
1042
+ settled = true;
1043
+ clearTimeout(timeout);
1044
+ reject(new Error('Connection closed'));
1045
+ }
1046
+ });
1047
+ });
1048
+ }