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,3354 @@
1
+ /**
2
+ * Workspace Commands — talk to the cloud DO through the daemon.
3
+ *
4
+ * These commands connect to the running daemon via IPC (/tmp/beehaven-ipc.sock)
5
+ * and forward RPCs to the user's cloud Durable Object.
6
+ *
7
+ * Core commands:
8
+ * beehaven actions — list available workspace actions
9
+ * beehaven call <action> '{}' — call a workspace action
10
+ * beehaven workspace — show workspace overview
11
+ * beehaven pipe — JSON-RPC 2.0 mode for Claude Code
12
+ * beehaven ls [path] — browse workspace VFS
13
+ * beehaven cat <path> — read a file from workspace VFS
14
+ * beehaven remember <k> <v> — store agent memory
15
+ * beehaven recall [query] — retrieve agent memories
16
+ * beehaven search <query> — search notes
17
+ * beehaven ask "<question>" — chat with cloud agent
18
+ * beehaven task "<title>" — create a task
19
+ * beehaven watch [patterns] — stream live workspace events
20
+ * beehaven teleport <file> — upload file to workspace
21
+ */
22
+ import { rpcOnce, connectClient } from '../ipc-client.js';
23
+ import { assertActionAllowed } from '../blocked-actions.js';
24
+ import { activeAgentWrap } from '../agent-attribution.js';
25
+ import { resolveActiveAgent } from '../agent-state.js';
26
+ import { isAgentRuntime } from '../agent-scope.js';
27
+ import { getTarget } from '../cli-state.js';
28
+ import { attachShellCwd, absorbShellCwd, shellTargetKey } from '../shell-session.js';
29
+ export function getTimeout(values) {
30
+ // Precedence: explicit --timeout flag → BEEHAVEN_RPC_TIMEOUT_MS env → default.
31
+ // The default is generous because real jobs (sticker-sheet gen, renders,
32
+ // long agent turns) routinely run well past the old 30s and shouldn't need a
33
+ // flag every time. The daemon honors the same value via the forwarded
34
+ // `_timeout`, so the relay-forward window matches the CLI's wait.
35
+ if (values.timeout)
36
+ return parseInt(values.timeout, 10);
37
+ const env = process.env.BEEHAVEN_RPC_TIMEOUT_MS;
38
+ if (env && Number.isFinite(Number(env)) && Number(env) > 0)
39
+ return parseInt(env, 10);
40
+ return 180_000;
41
+ }
42
+ /**
43
+ * True when `msg` says `action` isn't available on the target DO.
44
+ *
45
+ * The server has TWO ways of saying this and they don't share a prefix:
46
+ * `_rpc:invoke` and the admin gateways answer "Unknown action: X", while the
47
+ * WebSocket router answers "Unknown message type: X". Code that matched only
48
+ * the first string silently skipped its own fallback — `beehaven actions` had
49
+ * a retry-against-home path that never ran, so the command dead-ended and
50
+ * then advised the user to retry the same call. Match both spellings.
51
+ */
52
+ /**
53
+ * Is this payload carrying FILE BYTES inline, whatever the verb is called?
54
+ *
55
+ * ⚠⚠ THE SHAPE, NOT A NAME LIST. The oversized-payload refusal used to give upload advice only for
56
+ * `file-teleport-inline|library-upload|file-uploaded`, and there are at least five other
57
+ * byte-carrying verbs (`canvas-upload-image`, `canvas-add-image`, `deck-add-image`,
58
+ * `agent-avatar-upload`, `compose-upload-init`). Measured 2026-09-02: an agent uploading sixteen
59
+ * screenshots used `canvas-upload-image`, fell through to the generic *"split the input"*, and
60
+ * re-encoded everything to JPEG — degrading the images to work around a limit that does not apply
61
+ * to uploads at all, while `beehaven teleport` and the presigned PUT were both available.
62
+ *
63
+ * ⚠ A NAME LIST GOES STALE ON THE NEXT UPLOAD VERB, silently, and the cost is a caller abandoning
64
+ * a route that works. Same reasoning as `GENERATION_SHAPE` in `actionTimeouts.ts`: match what the
65
+ * thing IS, and let a new verb be covered by being shaped like what it is.
66
+ *
67
+ * ⚠ IT ONLY HAS TO BE RIGHT ABOUT WHICH ADVICE TO PRINT. A false positive prints the upload route
68
+ * on a large non-upload call — mildly wrong; a false negative sends someone to downscale their
69
+ * images. The threshold is high enough that ordinary prose never trips it.
70
+ */
71
+ export function carriesInlineBytes(input) {
72
+ if (!input || typeof input !== 'object')
73
+ return false;
74
+ const BYTES_MIN = 64 * 1024;
75
+ for (const v of Object.values(input)) {
76
+ if (typeof v !== 'string' || v.length < BYTES_MIN)
77
+ continue;
78
+ // A data: URI is unambiguous; otherwise require the head to be base64-shaped.
79
+ if (/^data:[^;,]*;base64,/i.test(v))
80
+ return true;
81
+ if (/^[A-Za-z0-9+/\r\n]+={0,2}$/.test(v.slice(0, 512)))
82
+ return true;
83
+ }
84
+ return false;
85
+ }
86
+ export function isMissingActionError(msg, action) {
87
+ if (!msg)
88
+ return false;
89
+ const s = String(msg);
90
+ return (s.includes(`Unknown action: ${action}`) ||
91
+ s.includes(`Unknown message type: ${action}`) ||
92
+ s.includes(`Unknown action ${action}`));
93
+ }
94
+ /**
95
+ * Throw when a resolved RPC payload is actually an error report.
96
+ *
97
+ * The daemon resolves `rpc-result` for anything the DO acked, so a body of
98
+ * `{error: 'Unknown message type: workspace-info'}` reaches callers as a
99
+ * SUCCESS. Commands that then rendered it through a display template printed
100
+ * a blank report and exited 0 — a missing RPC and an empty workspace looked
101
+ * identical, which is the failure mode that hid the unregistered discovery
102
+ * actions for as long as it did. Call this before rendering.
103
+ */
104
+ export function assertNoRpcError(result, action) {
105
+ if (!result || typeof result !== 'object')
106
+ return;
107
+ const err = result.error;
108
+ if (!err)
109
+ return;
110
+ const msg = typeof err === 'string' ? err : (err.message ?? JSON.stringify(err));
111
+ const suggestions = result.suggestions;
112
+ const tail = Array.isArray(suggestions) && suggestions.length
113
+ ? ` Did you mean: ${suggestions.join(', ')}?`
114
+ : '';
115
+ throw new Error(`${action}: ${msg}${tail}`);
116
+ }
117
+ /**
118
+ * Pull a row array out of a response whatever shape it arrived in — a bare
119
+ * array, one of the named keys, or the numeric-keyed object an array becomes
120
+ * when a server spreads it into the ack envelope ({"0":…,"1":…}).
121
+ */
122
+ export function normalizeRows(result, keys) {
123
+ if (Array.isArray(result))
124
+ return result;
125
+ if (!result || typeof result !== 'object')
126
+ return [];
127
+ for (const k of keys) {
128
+ if (Array.isArray(result[k]))
129
+ return result[k];
130
+ }
131
+ const numeric = Object.keys(result).filter((k) => /^\d+$/.test(k));
132
+ if (numeric.length) {
133
+ return numeric.sort((a, b) => Number(a) - Number(b)).map((k) => result[k]);
134
+ }
135
+ return [];
136
+ }
137
+ /**
138
+ * THE cross-DO envelope: which proxy action, and what that proxy calls the id.
139
+ *
140
+ * ⚠⚠ EVERY CALLER MUST COME THROUGH HERE. `admin:store-rpc` reads `storeId`, `admin:team-rpc`
141
+ * reads `teamId`, `admin:workspace-rpc` reads `workspaceId` — and this pairing was hand-written at
142
+ * NINE call sites across `cli.ts`, `commands/workspace.ts` and `commands/shell.ts`, every one of
143
+ * them hardcoding `storeId` while deriving the action from the type. So every non-store target
144
+ * computed the right proxy and handed it a key that proxy does not read: `beehaven connect
145
+ * team/<id>` followed by ANY command answered `{"error":"teamId is required"}`.
146
+ *
147
+ * ⚠ IT FAILS ONE COMMAND AFTER ITS CAUSE. `connect` succeeds and prints a session; the error lands
148
+ * on the next call, names a field the user never typed, on a wrapping they did not know happened.
149
+ * It reads as "that workspace is misconfigured", which is why it survived so long.
150
+ *
151
+ * One builder, so the pairing is stated once and a new building type either gets it right or
152
+ * fails in `workspace-routing.test.ts`.
153
+ */
154
+ export function gatewayEnvelope(buildingType, buildingId, action, input) {
155
+ // `shopifyStore` is an alias for `store`; the proxy is `admin:store-rpc` either way, so the id
156
+ // field must be derived from the RESOLVED type or it becomes `shopifyStoreId`.
157
+ const alias = buildingType === 'shopifyStore' ? 'store' : buildingType;
158
+ /**
159
+ * ⚠⚠ ROUTE BY ID SHAPE, NOT BY LABEL — and this is what stops ordinary work needing Developer
160
+ * Mode.
161
+ *
162
+ * A 64-hex id is a canonical workspace id, whatever the caller typed in front of it: `team/<id>`
163
+ * and `workspace/<id>` name the SAME Durable Object, and `workspaceStub` already routes on
164
+ * exactly this rule for exactly this reason. Only the ENVELOPE differed, and it differed in the
165
+ * one way that matters — `admin:team-rpc` is `ADMIN_USER_IDS`-gated and, locally, is an
166
+ * `admin:`-prefixed action that the daemon's IPC scope gate refuses without a developer token.
167
+ *
168
+ * So `beehaven connect team/<canonical id>` — the address the CLI itself prints and the one this
169
+ * account uses — turned every subsequent call into an ADMIN operation. Agent work then either
170
+ * required `HIVE_DEV_MODE=1` or slipped past the scope gate by hiding inside `agent-invoke`,
171
+ * which is the bypass this change exists to make unnecessary.
172
+ *
173
+ * `workspace-rpc` is the member-facing sibling: it checks the account ROSTER (the same list
174
+ * `beehaven workspaces` renders) rather than staff membership, and it re-wraps the forwarded call
175
+ * as `agent-invoke` on the target so an agent survives the hop. It is the right door for both
176
+ * spellings.
177
+ *
178
+ * ⚠ A LEGACY NAME-ADDRESSED TEAM STILL USES `admin:team-rpc`. Those ids are not resolvable in the
179
+ * workspace namespace, so the staff proxy remains the only way to reach one — which is what that
180
+ * verb is actually for.
181
+ */
182
+ const canonicalWorkspaceId = /^[0-9a-f]{64}$/i.test(String(buildingId || ''));
183
+ const resolved = canonicalWorkspaceId && (alias === 'team' || alias === 'workspace')
184
+ ? 'workspace'
185
+ : alias;
186
+ const gateway = resolved === 'workspace' ? 'workspace-rpc' : `admin:${resolved}-rpc`;
187
+ return {
188
+ gateway,
189
+ payload: { [`${resolved}Id`]: buildingId, action, input },
190
+ };
191
+ }
192
+ /**
193
+ * Route an action to a specific building via the Home DO's proxy.
194
+ *
195
+ * ⚠ THE ROUTE IS CHOSEN BY ID SHAPE, NOT BY LABEL — see `gatewayEnvelope`. A 64-hex id is a
196
+ * canonical workspace id whichever noun precedes it, and it goes through the MEMBER proxy. This
197
+ * list said `team:<id> → admin:team-rpc` after that stopped being true, which is the two-inventories
198
+ * failure: a reader trusts the table over the code.
199
+ *
200
+ * --building home → direct (default)
201
+ * --building team:<64-hex> → workspace-rpc (roster-gated)
202
+ * --building workspace:<64-hex> → workspace-rpc (roster-gated)
203
+ * --building team:<name> → admin:team-rpc (legacy name-addressed team; staff only)
204
+ * --building store:<id> → admin:store-rpc
205
+ * --building shopifyStore:<id> → admin:store-rpc (alias)
206
+ * --building <type>:<id> → admin:<type>-rpc
207
+ */
208
+ export function resolveBuilding(action, input, building) {
209
+ if (!building || building === 'home')
210
+ return { action, input };
211
+ const colonIdx = building.indexOf(':');
212
+ if (colonIdx === -1)
213
+ return { action, input };
214
+ const buildingType = building.slice(0, colonIdx);
215
+ const buildingId = building.slice(colonIdx + 1);
216
+ // ⚠ ONE builder for the proxy + its id field — see `gatewayEnvelope` for the bug that made it
217
+ // one. Do not re-derive `admin:<type>-rpc` here.
218
+ const { gateway, payload } = gatewayEnvelope(buildingType, buildingId, action, input);
219
+ return { action: gateway, input: payload };
220
+ }
221
+ function formatSize(bytes) {
222
+ if (bytes < 1024)
223
+ return `${bytes}B`;
224
+ if (bytes < 1024 * 1024)
225
+ return `${(bytes / 1024).toFixed(1)}KB`;
226
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
227
+ }
228
+ // ── beehaven actions ─────────────────────────────────────────────────────
229
+ /**
230
+ * ⚠⚠ THE COUNTING AND THE DISCLOSURE, AS A PURE FUNCTION — SO A GUARD CAN EXECUTE THEM.
231
+ *
232
+ * Both of `beehaven actions`' defects were decisions taken in one line each, buried in a render
233
+ * loop that needs a daemon, a relay and a live DO to reach. A test could only ever have grepped
234
+ * for them, and a grep passes against a build where the line has moved rather than been fixed.
235
+ * Extracted, the rules are callable with a plain object:
236
+ *
237
+ * - `matchCount` reads the server's `action_count`, NOT the length of the page it was handed.
238
+ * Reading the page and printing it as "available" is how a 40-row slice of 61 matches called
239
+ * itself complete, five different queries in a row.
240
+ * - a narrowed list SAYS it was narrowed — whether it was cut by a page bound (`truncated`) or
241
+ * by reachability (`hidden`). One shape, two causes, so they are decided together.
242
+ *
243
+ * ⚠ `total` IS A FALLBACK, NOT THE FIRST CHOICE. `--building store:<id>` reaches a different DO
244
+ * whose `list-actions` may answer with `total`; dropping it trades this bug for "undefined
245
+ * available" over there.
246
+ */
247
+ /**
248
+ * ⚠⚠ ASK FOR THE CALLABLE SET BY DEFAULT — `--all` IS THE OPT-OUT.
249
+ *
250
+ * MEASURED on staging 2026-09-05, and the cross-tabulation is the whole argument:
251
+ *
252
+ * reachable unreachable
253
+ * described 584 142
254
+ * bare 0 443
255
+ *
256
+ * 1,169 rows were listed, 585 of them registered-and-refused, with nothing on the row to say so —
257
+ * a caller picks one, spends a round trip, and is told the name is "outside the user action
258
+ * surface". And EVERY undescribed name was in the refused half, so the "62% description coverage"
259
+ * defect was never a documentation gap: it was this command printing rows nobody can call.
260
+ * Filtering takes coverage to 100% without writing a word of prose.
261
+ *
262
+ * ⚠ THE SERVER ALREADY HAD THE SWITCH AND THIS DOOR NEVER PULLED IT. `_rpc:catalog` has accepted
263
+ * `reachableOnly` (defaulting it ON for agents) for a while, which is why `beehaven call
264
+ * _rpc:catalog` answered 584 while `beehaven actions` answered 1,169 — same workspace, same
265
+ * session, two answers, and the bigger one was on the surface a person reads.
266
+ *
267
+ * ⚠ IT ONLY EVER NARROWS, which is what makes it safe to send from a client: nothing here can
268
+ * reveal something the caller could not otherwise reach.
269
+ *
270
+ * ⚠ EXPORTED SO A GUARD CAN EXECUTE IT. Inline in `cmdActions` this decision was reachable only
271
+ * through a daemon, a relay and a live DO, so a test could only grep for it — and a grep passes
272
+ * against a build where the line moved rather than got fixed.
273
+ */
274
+ export function actionsListInput(values, format) {
275
+ return {
276
+ category: values.category,
277
+ search: values.search,
278
+ format,
279
+ ...(values.all ? {} : { reachableOnly: true }),
280
+ };
281
+ }
282
+ /**
283
+ * ⚠⚠ "0 available" WAS A CONFIDENT LIE ABOUT THE WORKSPACE (CBH2-62).
284
+ *
285
+ * $ beehaven actions --category zzznope
286
+ * Workspace Actions — 0 available
287
+ * …the standard footer, unchanged…
288
+ *
289
+ * Nothing said a filter had been applied, nothing said it matched nothing, and nothing named a
290
+ * category that exists. "0 available" is a statement about the WORKSPACE; the true statement is
291
+ * about the FILTER. Same defect class as the rest of this cluster: a well-formed, successful,
292
+ * wrong answer that nothing downstream can tell from a real one. And it got easier to hit the day
293
+ * `beehaven actions board` started mapping a bare word onto `--category` (CBH2-23), because a
294
+ * mistyped bare word now reaches the category filter instead of being ignored.
295
+ *
296
+ * ⚠⚠ THE TWO CAUSES NEED DIFFERENT ANSWERS, WHICH IS WHY THIS IS NOT ONE SENTENCE. `--category
297
+ * bogus` and `--category board --search zzz` both return zero, and telling someone their category
298
+ * is wrong when it is their search sends them to fix the half that was right. So: if the category
299
+ * is not in the universe, say the category does not exist and offer near matches; if it IS, say
300
+ * the category is real and name the filter that emptied it.
301
+ *
302
+ * ⚠ THE UNIVERSE IS PASSED IN, NEVER FETCHED HERE. The server returns `categories` FILTERED, so a
303
+ * filtered call returns 0 or 1 of them and structurally cannot list the valid names — the caller
304
+ * has to make a second, unfiltered request. Keeping that in `cmdActions` leaves this function pure
305
+ * and executable by a guard, which is the property the rest of this module was extracted for.
306
+ *
307
+ * ⚠ AN UNKNOWN UNIVERSE IS NOT AN EMPTY ONE. `categories: undefined` means the second fetch failed
308
+ * or was not attempted; guessing "your category does not exist" from that would be a new confident
309
+ * wrong answer inside the fix for a confident wrong answer.
310
+ */
311
+ export function zeroResultNotices(opts) {
312
+ const { category, search, categories, hidden = 0 } = opts;
313
+ const filters = [];
314
+ if (category)
315
+ filters.push(`--category ${category}`);
316
+ if (search)
317
+ filters.push(`--search ${search}`);
318
+ if (!filters.length) {
319
+ /** ⚠ NO FILTER AND NO ROWS IS A DIFFERENT, WORSE EVENT — the workspace answered with nothing.
320
+ * Reporting it as a filter miss would hide a real outage behind a typo message. */
321
+ return [
322
+ '⚠ No filter was applied and the workspace still returned no actions. That is not a search miss — the listing itself came back empty.',
323
+ "Check the target with `beehaven pwd`, then `beehaven call _rpc:catalog '{}'` for the same catalog by another door.",
324
+ ];
325
+ }
326
+ const out = [`Nothing matched ${filters.join(' + ')}.`];
327
+ if (category && Array.isArray(categories)) {
328
+ if (!categories.includes(category)) {
329
+ const near = nearestCategories(category, categories);
330
+ out.push(near.length
331
+ ? `⚠ There is no category "${category}". Closest: ${near.join(', ')}.`
332
+ : `⚠ There is no category "${category}".`);
333
+ out.push(`${categories.length} categories exist — list them with \`beehaven actions --json\` and read \`categories\`.`);
334
+ }
335
+ else {
336
+ out.push(search
337
+ ? `The category "${category}" is real and has actions; it is --search ${search} that matched none of them. Drop the search first.`
338
+ : `The category "${category}" is real, so every action in it is filtered out for you rather than missing.`);
339
+ }
340
+ }
341
+ else if (category) {
342
+ // Universe unknown — say so rather than implying the name is wrong.
343
+ out.push(`Could not fetch the category list to check "${category}", so this may be a typo or a genuinely empty category.`);
344
+ }
345
+ if (search && !category) {
346
+ out.push(`No action name or description contains "${search}". Try a shorter stem — the match is a substring, not a fuzzy search.`);
347
+ }
348
+ if (hidden > 0) {
349
+ out.push(`${hidden} action(s) DO match but are not callable by you and were hidden. See them with --all.`);
350
+ }
351
+ return out;
352
+ }
353
+ /**
354
+ * Categories a typo most plausibly meant: substring either way first, then a cheap edit-distance
355
+ * neighbourhood. Capped at three — a list of twenty "close" matches is another wall of text.
356
+ *
357
+ * ⚠ It returns [] rather than the whole universe when nothing is close. Printing all 127 as
358
+ * "suggestions" is not help, and the caller already says how to list them.
359
+ */
360
+ export function nearestCategories(typed, universe, limit = 3) {
361
+ const t = typed.toLowerCase();
362
+ const contains = universe.filter((c) => c.includes(t) || t.includes(c));
363
+ if (contains.length)
364
+ return contains.slice(0, limit);
365
+ const scored = universe
366
+ .map((c) => ({ c, d: editDistance(t, c) }))
367
+ // A third of the typed length, so short words do not match everything.
368
+ .filter((x) => x.d <= Math.max(1, Math.floor(t.length / 3)))
369
+ .sort((a, b) => a.d - b.d);
370
+ return scored.slice(0, limit).map((x) => x.c);
371
+ }
372
+ /**
373
+ * ⚠⚠ DAMERAU, NOT PLAIN LEVENSHTEIN — A TRANSPOSITION IS THE COMMONEST TYPO AND PLAIN
374
+ * LEVENSHTEIN SCORES IT 2.
375
+ *
376
+ * Measured while verifying this fix: `--category boadr` (`board` with two letters swapped) got NO
377
+ * suggestion, because the threshold that keeps short words from matching everything is 1 and plain
378
+ * edit distance calls that pair 2. Adding the adjacent-transposition case makes it 1, which is what
379
+ * a human means by "one typo".
380
+ *
381
+ * ⚠ The 2D table is deliberate: the space-optimised two-row form cannot see the i-2/j-2 cell a
382
+ * transposition needs, and category lists are ~127 short strings — this is not a hot path.
383
+ */
384
+ function editDistance(a, b) {
385
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
386
+ for (let i = 1; i <= a.length; i++) {
387
+ for (let j = 1; j <= b.length; j++) {
388
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
389
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
390
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
391
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
392
+ }
393
+ }
394
+ }
395
+ return d[a.length][b.length];
396
+ }
397
+ export function summarizeActionsListing(result, opts = {}) {
398
+ const actions = result?.actions ?? [];
399
+ const shown = actions.length;
400
+ const matchCount = Number(result?.action_count ?? result?.total ?? shown);
401
+ const truncated = shown < matchCount;
402
+ const hidden = Number(result?.hidden ?? 0);
403
+ const unreachable = Number(result?.unreachable ?? 0);
404
+ const filtered = !!(opts.category || opts.search);
405
+ const notices = [];
406
+ /**
407
+ * ⚠ THE ZERO CASE RETURNS EARLY. Every notice below describes a NON-EMPTY listing ("N more
408
+ * match", "N of the rows above"), and appending them to an empty one produces sentences about
409
+ * rows that are not there.
410
+ */
411
+ if (matchCount === 0) {
412
+ return {
413
+ shown,
414
+ matchCount,
415
+ truncated: false,
416
+ heading: (label) => filtered
417
+ ? `${label} Actions — 0 of them match this filter`
418
+ : `${label} Actions — 0 available`,
419
+ notices: zeroResultNotices({ ...opts, hidden }),
420
+ };
421
+ }
422
+ if (truncated) {
423
+ notices.push(`⚠ ${matchCount - shown} more match and are not shown. Narrow with --search/--category, or raise the page with --json.`);
424
+ }
425
+ /**
426
+ * ⚠ REPORT WHAT CAME BACK, NEVER WHAT WAS SENT. `reachableOnly` is forced ON for an
427
+ * agent-shaped caller whatever the client asked for, so `--all` can be silently ineffective.
428
+ * Branching on the RESPONSE is the only version that cannot lie about its own filter.
429
+ */
430
+ if (hidden > 0) {
431
+ notices.push(opts.all
432
+ ? `⚠ ${hidden} action(s) are registered but not callable by you, and this connection hid them anyway — --all could not override it here.`
433
+ : `${hidden} more are registered but not callable by you (they exist and are refused — not a search miss). See them with --all.`);
434
+ }
435
+ if (unreachable > 0) {
436
+ notices.push(`${unreachable} of the rows above are marked [unreachable] — registered, and refused if you call them. Omit --all to hide them.`);
437
+ }
438
+ return {
439
+ shown,
440
+ matchCount,
441
+ truncated,
442
+ // ⚠ "N of M" ONLY WHEN THEY DIFFER. "40 of 40" on a complete list is noise that trains a
443
+ // reader to skip the one place a real truncation will eventually be announced.
444
+ heading: (label) => truncated
445
+ ? `${label} Actions — showing ${shown} of ${matchCount} matches`
446
+ : `${label} Actions — ${matchCount} available`,
447
+ notices,
448
+ };
449
+ }
450
+ /**
451
+ * ⚠⚠ `beehaven actions board` WAS AN EXPENSIVE NO-OP THAT LOOKED LIKE SUCCESS.
452
+ *
453
+ * `cli.ts` calls `cmdActions(values)` with no positionals, so a bare word after the verb was
454
+ * dropped on the floor and the command answered with the FULL catalogue — measured at 1,169 rows
455
+ * / ~180 KB / ~45k tokens, byte-identical to bare `actions`, where `--category board` returns 40.
456
+ * Nothing errored and nothing said the argument had been ignored, so the caller pays for the
457
+ * whole catalogue believing they filtered it.
458
+ *
459
+ * ⚠⚠ THE ARGV SCAN IS GONE — `cli.ts` PASSES THE POSITIONAL NOW (CBH2-63). It read
460
+ * `process.argv` only because that call site was owned by another agent mid-sprint, and it could
461
+ * see just the token immediately after the verb: `actions --json board` was still missed, because
462
+ * a flag sat where the scan looked. A real positional has no blind spot, so the helper it was
463
+ * isolated into has been deleted rather than left as a second, weaker answer to one question.
464
+ */
465
+ /**
466
+ * What a bare positional after `actions` means, and what to say about it.
467
+ *
468
+ * ⚠ INTERPRETED AS THE CATEGORY, AND SAID OUT LOUD. `actions board` is the natural first guess and
469
+ * `--category board` is plainly what it means, so honouring it beats refusing — but a silent
470
+ * REinterpretation is the same class of defect as a silent drop, so the breadcrumb names it.
471
+ *
472
+ * ⚠ AN EXPLICIT `--category` WINS AND THE CONFLICT IS REPORTED. Quietly preferring one of two
473
+ * filters the caller typed is a guess about intent; naming the one that was used is not.
474
+ */
475
+ export function resolveActionsCategory(values, positional) {
476
+ if (!positional)
477
+ return { category: values.category, note: '' };
478
+ if (!values.category) {
479
+ return { category: positional, note: ` (reading "${positional}" as --category ${positional})` };
480
+ }
481
+ if (values.category === positional)
482
+ return { category: values.category, note: '' };
483
+ return {
484
+ category: values.category,
485
+ note: ` (ignoring "${positional}" — --category ${values.category} was given explicitly)`,
486
+ };
487
+ }
488
+ export async function cmdActions(values, positional) {
489
+ const format = values.format || (values.json ? 'full' : 'summary');
490
+ const chosen = resolveActionsCategory(values, positional);
491
+ values.category = chosen.category;
492
+ // Routing breadcrumb — tell agents (and humans) which DO this hits.
493
+ if (!values.json) {
494
+ const target = values.building ? values.building : 'home';
495
+ console.error(`\x1b[2m→ ${target} list-actions${chosen.note}\x1b[0m`);
496
+ }
497
+ const listInput = actionsListInput(values, format);
498
+ /** Transparently retry against home when the primary target doesn't
499
+ * expose list-actions. Covers the stale-routing case: agent asks
500
+ * "what actions can I call?" expecting the home palette but the
501
+ * daemon had a lingering shopifyStore target; that DO returns
502
+ * "Unknown action: list-actions"; retry against home rather than
503
+ * dead-end the agent. Only fires when no --building flag was set
504
+ * (explicit --building is respected). */
505
+ async function fetchActions(building) {
506
+ const resolved = resolveBuilding('list-actions', listInput, building);
507
+ return rpcOnce(resolved.action, resolved.input, getTimeout(values));
508
+ }
509
+ try {
510
+ let raw = await fetchActions(values.building);
511
+ // Unwrap proxy envelope
512
+ let result = raw?.type === 'ack' && raw?.result !== undefined
513
+ ? raw.result
514
+ : raw?.result !== undefined && values.building
515
+ ? raw.result
516
+ : raw;
517
+ const firstError = raw?.type === 'error' || result?.error;
518
+ const firstMsg = raw?.message ?? result?.error ?? '';
519
+ if (firstError &&
520
+ isMissingActionError(firstMsg, 'list-actions') &&
521
+ values.building &&
522
+ !values.json) {
523
+ // Quiet retry against home — no --building explicit = we're allowed
524
+ // to look elsewhere. Log a breadcrumb so the agent sees it.
525
+ console.error(`\x1b[2m (${values.building} doesn't expose list-actions — retrying against home)\x1b[0m`);
526
+ raw = await fetchActions(undefined);
527
+ result = raw?.type === 'ack' && raw?.result !== undefined ? raw.result : raw;
528
+ // Swap the label so the printed output reflects where the result came from.
529
+ values.building = undefined;
530
+ }
531
+ // If retry ALSO fails, or if --building was explicit and failed, take the long hint path.
532
+ if (raw?.type === 'error' || result?.error) {
533
+ const msg = raw?.message ?? result?.error ?? 'Unknown error';
534
+ if (isMissingActionError(msg, 'list-actions')) {
535
+ console.log('');
536
+ console.log(` This building doesn't expose an action listing.`);
537
+ // ⚠ The three raw-VFS commands below all need DEVELOPER scope (they route to
538
+ // `shell:exec`). Lead with the one that works for everyone and label the rest,
539
+ // rather than sending a consumer-scope caller into a permissions error — CLR-1148.
540
+ console.log(` Discover what's there:`);
541
+ console.log(` beehaven ls ~ # your notes + projects as files`);
542
+ console.log(` beehaven actions --search <word> # find a verb by name`);
543
+ console.log(` Raw VFS (needs developer scope — HIVE_DEV_MODE=1 beehaven restart):`);
544
+ console.log(` beehaven cat /.workspace.json # manifest with every action + folder`);
545
+ console.log(` beehaven ls /apps/ # installed apps as files`);
546
+ console.log(` beehaven tree / # full VFS tree`);
547
+ console.log(` Then call a specific action:`);
548
+ console.log(` beehaven call <action-name> '{}'`);
549
+ console.log(` beehaven rpc admin:store-rpc '{"storeId":"<id>","action":"<name>","input":{}}'`);
550
+ console.log('');
551
+ return;
552
+ }
553
+ console.error(` Error: ${msg}`);
554
+ console.error('');
555
+ console.error(' Try:');
556
+ console.error(' beehaven pwd # confirm current target');
557
+ console.error(' beehaven disconnect # fall back to home');
558
+ // Only suggest `--building home` when we are NOT already on home.
559
+ // Without this guard the hint told you to re-run, against the same DO,
560
+ // the exact command that had just failed — the advice looked actionable
561
+ // and was a no-op, which is worse than no advice at all.
562
+ if (values.building && values.building !== 'home') {
563
+ console.error(" beehaven actions --building home # list home's actions specifically");
564
+ }
565
+ else {
566
+ console.error(" beehaven call _rpc:catalog '{}' # the same catalog, direct");
567
+ console.error(' beehaven cat /.workspace.json # manifest: actions + folders');
568
+ }
569
+ process.exit(1);
570
+ }
571
+ if (values.json || format === 'full' || format === 'tool-use') {
572
+ console.log(JSON.stringify(result, null, 2));
573
+ return;
574
+ }
575
+ const actions = result?.actions ?? [];
576
+ /**
577
+ * ⚠⚠ THE ZERO CASE COSTS ONE EXTRA FETCH, AND ONLY THE ZERO CASE (CBH2-62). The server returns
578
+ * `categories` FILTERED, so the response to a filtered call carries 0 or 1 of them and cannot
579
+ * name the valid ones — the only way to tell "your category is a typo" from "your search
580
+ * emptied a real category" is to ask again without the filter. A zero-result listing is rare
581
+ * and already a dead end, so the round trip is free where it matters and never paid otherwise.
582
+ *
583
+ * ⚠ IT FAILS SOFT. If the second fetch throws, `categories` stays undefined and
584
+ * `zeroResultNotices` says it could not check rather than guessing — an unknown universe must
585
+ * not become a confident claim about the name.
586
+ */
587
+ let categories = Array.isArray(result?.categories) && result.categories.length
588
+ ? result.categories
589
+ : undefined;
590
+ const matched = Number(result?.action_count ?? result?.total ?? actions.length);
591
+ if (matched === 0 && (values.category || values.search)) {
592
+ try {
593
+ const probe = await rpcOnce(resolveBuilding('list-actions', { format: 'summary' }, values.building).action, resolveBuilding('list-actions', { format: 'summary' }, values.building).input, getTimeout(values));
594
+ const unwrapped = probe?.type === 'ack' && probe?.result !== undefined ? probe.result : probe;
595
+ if (Array.isArray(unwrapped?.categories) && unwrapped.categories.length)
596
+ categories = unwrapped.categories;
597
+ }
598
+ catch { /* fails soft — see above */ }
599
+ }
600
+ // ⚠ THE COUNTING AND THE DISCLOSURE LIVE IN `summarizeActionsListing`, a pure function, so a
601
+ // guard can EXECUTE the rules rather than grep for them. See its docstring for the two
602
+ // defects it encodes.
603
+ const summary = summarizeActionsListing(result, {
604
+ all: !!values.all,
605
+ category: values.category,
606
+ search: values.search,
607
+ categories,
608
+ });
609
+ const label = values.building ? values.building : 'Workspace';
610
+ console.log('');
611
+ console.log(` ${summary.heading(label)}`);
612
+ console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
613
+ console.log('');
614
+ const grouped = new Map();
615
+ for (const action of actions) {
616
+ const cat = action.category ?? 'general';
617
+ if (!grouped.has(cat))
618
+ grouped.set(cat, []);
619
+ grouped.get(cat).push(action);
620
+ }
621
+ for (const [category, catActions] of grouped) {
622
+ const catLabel = category.charAt(0).toUpperCase() + category.slice(1);
623
+ console.log(` \x1b[36m${catLabel}\x1b[0m (${catActions.length})`);
624
+ for (const a of catActions) {
625
+ const desc = a.description ? ` \x1b[90m${a.description}\x1b[0m` : '';
626
+ const aliasLabel = Array.isArray(a.aliases) && a.aliases.length > 0
627
+ ? ` \x1b[2m(also: ${a.aliases.join(', ')})\x1b[0m`
628
+ : '';
629
+ /**
630
+ * ⚠ ONLY `--all` CAN PRODUCE THIS ROW, AND IT MUST NOT LOOK LIKE THE OTHERS. Hiding the
631
+ * uncallable half by default and then handing an operator an unlabelled superset would
632
+ * trade one silent list for another — the escape hatch has to distinguish what it adds,
633
+ * or `--all` is just a longer version of the same lie.
634
+ */
635
+ const unreachable = a.reachable === false ? ` \x1b[33m[unreachable]\x1b[0m` : '';
636
+ console.log(` ${a.name.padEnd(30)}${unreachable}${desc}${aliasLabel}`);
637
+ }
638
+ console.log('');
639
+ }
640
+ /**
641
+ * ⚠⚠ A LIST THAT LEAVES THINGS OUT HAS TO SAY SO, WHICHEVER WAY IT WAS NARROWED. Both of this
642
+ * command's defects were the same shape — a partial answer wearing the word that means
643
+ * complete — so `summarizeActionsListing` decides both and this only paints them.
644
+ */
645
+ for (const n of summary.notices) {
646
+ console.log(n.startsWith('⚠') ? ` \x1b[33m${n}\x1b[0m` : ` \x1b[2m${n}\x1b[0m`);
647
+ }
648
+ if (summary.notices.length)
649
+ console.log('');
650
+ console.log(' ─────────────────────────────────────────────────');
651
+ console.log(' Call one: beehaven call <action> \'{"key":"value"}\'');
652
+ console.log(' Full schemas: beehaven actions --format full --json');
653
+ console.log(' Tool-use JSON: beehaven actions --format tool-use');
654
+ console.log(' Filter: beehaven actions --category <cat> --search <substr>');
655
+ console.log('');
656
+ console.log(' Still lost? beehaven pwd · beehaven workspace · beehaven ls /');
657
+ console.log('');
658
+ }
659
+ catch (err) {
660
+ console.error(` Error: ${err.message}`);
661
+ console.error('');
662
+ console.error(' Try:');
663
+ console.error(' beehaven pwd # confirm target + state');
664
+ console.error(' beehaven status # daemon + relay health');
665
+ process.exit(1);
666
+ }
667
+ }
668
+ // ── beehaven call <action> [json] ────────────────────────────────────────
669
+ /**
670
+ * ⚠⚠ THE ATTRIBUTION MACHINERY MOVED TO `agent-attribution.ts` — AND THE MOVE IS THE FIX.
671
+ *
672
+ * `activeAgentWrap`, `setCliDoor`/`cliDoor` and `BOOTSTRAP_ACTIONS` all used to live here, and
673
+ * `rpcOnce` did not apply them: a verb was attributed only if its module remembered to import
674
+ * `agentRpc` instead of `rpcOnce`. Three modules remembered; twelve did not; **28 of 47 remote
675
+ * verbs wrote no `agent_activity` row at all** and therefore no `source`. A traffic report over
676
+ * that column then read their silence as "nobody uses this", and the recorded retirement criterion
677
+ * would have deleted `task` and `search` on the strength of it — measuring the instrumentation and
678
+ * calling it usage.
679
+ *
680
+ * The wrap now lives at the THREE places an rpc frame is built — `IpcClient.call`
681
+ * (`ipc-client.ts`), `daemonRpc` (`daemon-client.ts`) and `ClaudeBridge.rpc` — because those, not
682
+ * `rpcOnce`, are what every path to the workspace actually passes through. These re-exports keep
683
+ * `cli.ts` (`setCliDoor`) and `commands/shell.ts` (`activeAgentWrap`) importing from where they
684
+ * always did.
685
+ *
686
+ * ⚠ IT SITS IN ITS OWN MODULE ONLY TO AVOID A CYCLE — this file imports `rpcOnce`, so
687
+ * `ipc-client.ts` cannot import this one.
688
+ */
689
+ export { activeAgentWrap, setCliDoor, cliDoor, alreadyAttributed, BOOTSTRAP_ACTIONS, DAEMON_LOCAL_ACTIONS } from '../agent-attribution.js';
690
+ /**
691
+ * ⚠⚠ NO LONGER THE THING THAT MAKES A CALL ATTRIBUTED — `IpcClient.call` DOES THAT NOW.
692
+ *
693
+ * This was the opt-in: `beehaven call` wrapped every action in `agent-invoke`, and a verb that
694
+ * reached for bare `rpcOnce` did NOT, so it ran as the ACCOUNT OWNER rather than as the signed-in
695
+ * agent. Measured 2026-09-04 on two comments left seconds apart on one card:
696
+ *
697
+ * beehaven call ticket-comment … -> author agent:jinri-claude
698
+ * beehaven ticket comment … -> author oKB2ZCc3nwXwQg5ps8AQLmLkUAA2
699
+ *
700
+ * ⚠⚠ THE DIAGNOSIS WAS RIGHT AND THE REMEDY WAS THE WRONG SHAPE. "Use this in a verb, never bare
701
+ * `rpcOnce`" is a rule enforced by memory, and it was forgotten in twelve of fifteen modules — so
702
+ * the wrap moved DOWN to the transport, where forgetting is not an available failure. Both
703
+ * functions now do the same thing.
704
+ *
705
+ * ⚠ IT IS KEPT, NOT DELETED, AND NOT BECAUSE OF THE CALL SITES. `values` is the one thing
706
+ * `rpcOnce` cannot see: `resolveActiveAgent(values)` reads `--agent <name>`, so a caller that
707
+ * parsed flags can name a DIFFERENT identity than the ambient one. Bare `rpcOnce` resolves the
708
+ * ambient identity and is correct for every verb that does not offer that flag.
709
+ */
710
+ export async function agentRpc(action, input, timeout, values = {}) {
711
+ const wrapped = activeAgentWrap(action, input, values);
712
+ return rpcOnce(wrapped.action, wrapped.input, timeout);
713
+ }
714
+ /**
715
+ * Does this result payload represent a failure? Mirrors `isFailurePayload` in the worker's
716
+ * mcp-server.ts on purpose — the CLI door and the MCP door must agree about what "failed" means,
717
+ * or the same call is a failure over one transport and a success over the other.
718
+ *
719
+ * ⚠ `error: ''` IS NOT A FAILURE — some handlers set the field empty on success.
720
+ */
721
+ export function isFailureResult(data) {
722
+ if (!data || typeof data !== 'object')
723
+ return false;
724
+ const d = data;
725
+ if (d.ok === false)
726
+ return true;
727
+ if (typeof d.error === 'string' && d.error.length > 0)
728
+ return true;
729
+ if (d.error && typeof d.error === 'object')
730
+ return true;
731
+ return false;
732
+ }
733
+ /**
734
+ * ⚠⚠ AN ARGUMENT TYPO CAME BACK AS AN INTERNAL STORAGE ERROR — CBH2-51.
735
+ *
736
+ * $ beehaven call project-delete '{"projectId":"e1915d41-…"}'
737
+ * { "error": "[projects] Primary key value is null/undefined.
738
+ * Ensure the row includes the primary key field." }
739
+ *
740
+ * The verb takes `id`. The refusal never says so — it is the storage layer complaining about a
741
+ * ROW, so it reads as data corruption rather than a mistyped argument, and it sends the reader to
742
+ * inspect a table instead of their own JSON.
743
+ *
744
+ * ⚠⚠ THE CONFUSION IS EARNED, WHICH IS WHY THIS IS NOT JUST A BAD MESSAGE. The SIBLING verb
745
+ * `project-copy` REQUIRES `projectId` (verified live: `required: ['projectId']`). So the two
746
+ * project verbs genuinely disagree about what the project argument is called, and the one that
747
+ * refuses is the one that will not say.
748
+ */
749
+ const PK_LEAK = /primary key value is null|ensure the row includes the primary key/i;
750
+ /** Is this refusal about the caller's ARGUMENTS rather than about their data? */
751
+ export function looksLikeArgShapeError(errorText) {
752
+ const s = typeof errorText === 'string' ? errorText : '';
753
+ return PK_LEAK.test(s);
754
+ }
755
+ /** `projectId` → `project id`; lets a camelCase key be compared word-wise. */
756
+ function words(k) {
757
+ return k.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
758
+ }
759
+ /**
760
+ * Which field did the caller most likely MEAN by `sent`, out of `candidates`?
761
+ *
762
+ * ⚠ NAME A SUBSTITUTION ONLY WHEN IT IS OBVIOUS. A wrong guess here is the same class of defect
763
+ * being fixed — a confident, actionable, false cause — so the bar is a shared word
764
+ * (`projectId` → `project` `id` ⊃ `id`), not a fuzzy distance that will pair anything with
765
+ * anything on a schema of six similar names.
766
+ */
767
+ export function nearestField(sent, candidates) {
768
+ const sw = words(sent);
769
+ for (const c of candidates) {
770
+ if (c.toLowerCase() === sent.toLowerCase())
771
+ return c;
772
+ const cw = words(c);
773
+ if (cw.length && cw.every((w) => sw.includes(w)))
774
+ return c; // `id` ⊂ `project id`
775
+ if (sw.length && sw.every((w) => cw.includes(w)))
776
+ return c; // and the reverse
777
+ }
778
+ return null;
779
+ }
780
+ /**
781
+ * The advice, from the action's OWN schema — derived, never a hand-kept table of verb quirks.
782
+ *
783
+ * ⚠ A LIST OF KNOWN-BAD FIELD NAMES WOULD GO STALE THE WAY EVERY MIRROR IN THIS PACKAGE HAS
784
+ * (`JSON_CAPABLE`, the help coverage list). `describe-action` already publishes `required` and
785
+ * `properties`; comparing the caller's keys against them covers every verb, including ones written
786
+ * after this code.
787
+ *
788
+ * ⚠ IT ALSO REPORTS UNKNOWN KEYS EVEN WHEN NOTHING IS MISSING, because `z.object()` STRIPS them
789
+ * silently — the caller's value never reaches the handler and nothing anywhere says so.
790
+ *
791
+ * PURE: schema + input in, lines out. No daemon, no round trip.
792
+ */
793
+ export function argShapeAdvice(action, input, schema) {
794
+ const required = Array.isArray(schema?.required) ? schema.required : [];
795
+ const props = Object.keys(schema?.properties ?? {});
796
+ if (!required.length && !props.length)
797
+ return [];
798
+ const sent = Object.keys(input ?? {});
799
+ const missing = required.filter((r) => !sent.includes(r));
800
+ const unknown = props.length ? sent.filter((s) => !props.includes(s)) : [];
801
+ if (!missing.length && !unknown.length)
802
+ return [];
803
+ const out = [''];
804
+ for (const m of missing) {
805
+ const mistyped = nearestField(m, unknown);
806
+ out.push(mistyped
807
+ ? ` \`${action}\` takes \`${m}\` — you sent \`${mistyped}\`.`
808
+ : ` \`${action}\` requires \`${m}\`, which was not in your input.`);
809
+ }
810
+ const stray = unknown.filter((u) => !missing.some((m) => nearestField(m, [u])));
811
+ if (stray.length) {
812
+ out.push(` Ignored (not in this verb's schema, so it was stripped before the handler saw it): `
813
+ + stray.map((s) => `\`${s}\``).join(', '));
814
+ }
815
+ if (props.length)
816
+ out.push(` Accepts: ${props.join(', ')}`);
817
+ out.push(` Full schema: beehaven explain ${action}`);
818
+ out.push('');
819
+ return out;
820
+ }
821
+ export async function cmdCall(action, rawInput, values) {
822
+ let input = {};
823
+ // Payload may come from a file (@path) or stdin (-) so large/escaped JSON
824
+ // (e.g. a markdown body, a doc) doesn't have to survive shell quoting.
825
+ if (rawInput === '-' || (rawInput && rawInput.startsWith('@'))) {
826
+ const { readFileSync } = await import('fs');
827
+ const { resolve } = await import('path');
828
+ try {
829
+ rawInput =
830
+ rawInput === '-'
831
+ ? readFileSync(0, 'utf8')
832
+ : readFileSync(resolve(rawInput.slice(1)), 'utf8');
833
+ }
834
+ catch (err) {
835
+ console.error(` Could not read input: ${err.message}`);
836
+ process.exit(1);
837
+ }
838
+ }
839
+ if (rawInput) {
840
+ try {
841
+ input = JSON.parse(rawInput);
842
+ }
843
+ catch {
844
+ console.error(` Invalid JSON input: ${rawInput}`);
845
+ console.error(' Example: beehaven call note-create \'{"title":"My Note"}\'');
846
+ console.error(' Tip: beehaven actions # see valid action names + schemas');
847
+ process.exit(1);
848
+ }
849
+ }
850
+ // These are plausible names for existing actions, not extra workspace RPCs.
851
+ // Resolve them only when DESCRIBING an action so discovery leads to the real
852
+ // schema without silently changing the meaning of a direct write call.
853
+ if (['action-describe', 'describe-action', 'explain-action'].includes(action)) {
854
+ const requested = String(input?.action ?? input?.name ?? '');
855
+ const canonical = requested === 'ticket-search' ? 'ticket-list'
856
+ : requested === 'document-move' ? 'note-update' : '';
857
+ if (canonical) {
858
+ if (!values.json)
859
+ console.error(` ${requested} is not a workspace action; showing ${canonical}.`);
860
+ input = { ...input, action: canonical, name: canonical };
861
+ }
862
+ }
863
+ // Confirm spend BEFORE resolving a building or touching the daemon — a cancelled call must
864
+ // cost nothing and behave identically whether the input came from argv, a file, or stdin.
865
+ // It runs after the parse because the gate is input-aware: `engine:'handroll'` and any call
866
+ // carrying `svgContent` are free and never prompt.
867
+ /**
868
+ * ⚠⚠ REFUSE AN OVERSIZED PAYLOAD HERE, BECAUSE THE FRAME DIES BEFORE ANYONE CAN REPORT ON IT.
869
+ *
870
+ * The relay caps a WebSocket message at 1 MB. Measured against the live worker: a 900 KB
871
+ * `document-create` succeeds, a 1 MB one answers **"Connection closed"**, and a 3 MB one produces
872
+ * NO OUTPUT AT ALL — the socket drops and the promise never settles. The worker has its own
873
+ * size guard, and on this path it can never run: the message is discarded in transport, so the
874
+ * handler is never entered and there is nothing server-side left to explain the failure.
875
+ *
876
+ * ⚠ NOT A DUPLICATE OF THE SERVER CHECK — a DIFFERENT LAYER. MCP arrives over HTTP POST and does
877
+ * reach the handler, so that guard is what protects those callers. Deleting either one leaves a
878
+ * door where an oversized write fails without saying why.
879
+ *
880
+ * ⚠ IT NAMES THE ROUTE THAT WORKS. A document is NOT limited to 1 MB — only one WRITE is; a 2 MB
881
+ * note is reachable by appending in chunks, verified end to end. A refusal that omits that reads
882
+ * as "documents cannot be this big" and an agent gives up on a document it could have written.
883
+ *
884
+ * ⚠⚠ AND THE UPLOAD ADVICE IS CHOSEN BY THE PAYLOAD, NOT BY AN ALLOWLIST OF VERB NAMES.
885
+ *
886
+ * It was `/^(file-teleport-inline|library-upload|file-uploaded)$/` — three names, and there are
887
+ * at least five other byte-carrying verbs (`canvas-upload-image`, `canvas-add-image`,
888
+ * `deck-add-image`, `agent-avatar-upload`, `compose-upload-init`). Measured 2026-09-02: an agent
889
+ * uploading sixteen screenshots to a composition used `canvas-upload-image`, fell through to the
890
+ * generic *"split the input"*, and re-encoded everything to JPEG — losing quality to work around
891
+ * a limit that does not apply to uploads at all, while `beehaven teleport` and the presigned PUT
892
+ * were both available and unmentioned.
893
+ *
894
+ * A name list goes stale on the next upload verb, silently, and the failure is a caller giving up
895
+ * on a route that works. So it asks the INPUT whether it is carrying bytes — the same shape rule
896
+ * `actionTimeouts.ts` uses for slow actions, and for the same reason.
897
+ */
898
+ const MAX_CALL_BYTES = 768_000;
899
+ const payloadBytes = Buffer.byteLength(JSON.stringify(input ?? {}), 'utf8');
900
+ if (payloadBytes > MAX_CALL_BYTES) {
901
+ console.error(` Payload is ${payloadBytes.toLocaleString()} bytes; one call carries at most ${MAX_CALL_BYTES.toLocaleString()}`);
902
+ console.error(' (the relay caps a WebSocket message at 1 MB — a larger frame is dropped in');
903
+ console.error(' transport, which is why an oversized call used to fail with no message at all).');
904
+ console.error('');
905
+ if (/^(document|note)-/.test(action)) {
906
+ console.error(' A document has no such limit — only one write does. Build it in pieces:');
907
+ console.error('');
908
+ console.error(` beehaven call document-create '{"title":"…","markdown":"<first chunk>"}'`);
909
+ console.error(` beehaven call document-append @chunk-2.json # repeat under 768,000 chars`);
910
+ console.error('');
911
+ console.error(' A 2 MB document built that way reads back normally.');
912
+ }
913
+ else if (/^(file-teleport-inline|library-upload|file-uploaded)$/.test(action) || carriesInlineBytes(input)) {
914
+ // ⚠ AN UPLOAD IS NOT SIZE-LIMITED — only this ONE way of carrying it is. A refusal that
915
+ // stops at "too big" reads as "the workspace cannot hold this file", and the caller
916
+ // abandons an upload that has always been available. `teleport` picks the right carrier on
917
+ // its own, which is why it is named first.
918
+ console.error(' Uploads are NOT limited to this size — inline bytes are. Two ways through:');
919
+ console.error('');
920
+ console.error(' beehaven teleport ./the-file.pdf # picks inline vs direct-to-R2 for you');
921
+ console.error('');
922
+ console.error(' Or drive it yourself: get-upload-url → HTTP PUT the bytes → file-uploaded.');
923
+ console.error(' That path streams straight to R2 and never crosses the WebSocket.');
924
+ }
925
+ else {
926
+ console.error(' Split the input, or write the payload to a file the workspace can read.');
927
+ }
928
+ process.exitCode = 1;
929
+ return;
930
+ }
931
+ await assertActionAllowed(action, input, { yes: values.yes });
932
+ /**
933
+ * ⚠⚠ THE AGENT WRAP GOES INSIDE THE BUILDING ENVELOPE, SO ROUTING HAPPENS LAST.
934
+ *
935
+ * This used to route FIRST and then wrap the envelope in `agent-invoke`, producing
936
+ * { agent-invoke → { admin:team-rpc → { document-list } } }
937
+ * — so `agent-invoke` itself carried NO building and executed on the daemon's DEFAULT DO, while
938
+ * only the inner verb was proxied onward. `_checkAgentScope` therefore asked the WRONG
939
+ * WORKSPACE whether the agent was a member.
940
+ *
941
+ * ⚠ IT COST FOUR WRONG HYPOTHESES, because the refusal did not name the roster it consulted.
942
+ * Measured once it did: the cursor said `604b981e`, the gate answered
943
+ * `unknown agent 'finalcheck' (workspace 17645b8e, 88 agents)`.
944
+ *
945
+ * ⚠ AND IT WAS ALREADY KNOWN ONE LAYER DOWN, in a different form. `agent-grant-rpcs.ts` unwraps
946
+ * `proxiedAction` precisely because the gate and the log were reading the ENVELOPE rather than
947
+ * the work — "a `tool_allowlist` silently stopped applying the moment a caller connected to a
948
+ * workspace". That was this defect seen from the server, patched where it surfaced. Nesting the
949
+ * other way round removes the cause: the proxy now carries `agent-invoke` to the addressed DO,
950
+ * so the gate, the activity log and the work all happen in one place.
951
+ */
952
+ /**
953
+ * ⚠⚠ ATTACH THIS LOGIN'S SHELL cwd BEFORE THE AGENT WRAP, NOT AFTER.
954
+ *
955
+ * `activeAgentWrap` nests the payload inside `agent-invoke`, so anything added afterwards lands
956
+ * on the ENVELOPE and never reaches the handler — the same shape as the `proxiedAction` bug
957
+ * documented just above, where the gate read the envelope instead of the work.
958
+ *
959
+ * ⚠ THE RAW `call` DOOR NEEDS THIS AS MUCH AS THE SHORTHAND. `beehaven ls ~` goes through
960
+ * `execNotesShell`, but `beehaven call bash '{"command":"cd Q1"}'` comes through here — and that
961
+ * is the spelling used all over this repo's own notes. Wiring only the shorthand would leave the
962
+ * cwd shared for exactly the callers most likely to be agents.
963
+ */
964
+ const shellTarget = shellTargetKey(values.building || getTarget()?.address);
965
+ input = attachShellCwd(action, input, shellTarget);
966
+ const wrapped = activeAgentWrap(action, input, values);
967
+ let resolved = resolveBuilding(wrapped.action, wrapped.input, values.building);
968
+ /**
969
+ * ⚠⚠ RUN AS THE AGENT WHEN ONE IS LOGGED IN — the last gap in the identity story.
970
+ *
971
+ * `agent login` minted an identity, opened a workspace session and stored the session id
972
+ * — and `beehaven call` ignored all of it. `rpcOnce` sends `{action, input}` with no
973
+ * principal, so every ordinary call ran as the USER. Only `say`/`speak` ever attached the
974
+ * token, and only against the avatar building. An agent could be fully authenticated and
975
+ * still leave no trace of having done anything.
976
+ *
977
+ * `agent-invoke` is the mechanism that already exists for this — it is what MCP uses for
978
+ * an agent-scoped token. It applies the per-agent gate (`permission_scope` /
979
+ * `tool_allowlist` on the agents row) and writes the activity log, so routing through it
980
+ * is what makes an agent's calls both ATTRIBUTED and GOVERNED rather than indistinguishable
981
+ * from the account owner's.
982
+ *
983
+ * ⚠ NOT WHEN `--building` IS SET: that path is already wrapped in an `admin:*-rpc`
984
+ * envelope, and nesting agent-invoke inside a cross-DO proxy would attribute the call in
985
+ * the wrong workspace's activity log.
986
+ */
987
+ /**
988
+ * ⚠⚠ AND THE FALL-THROUGH IS GONE — AN AGENT NEVER RUNS UN-ATTRIBUTED (2026-08-28).
989
+ *
990
+ * This used to read `if (activeForCall && !activeForCall.expired && …)` with NO else, so every
991
+ * way of not having a usable identity quietly dispatched the action DIRECTLY: no `agent-invoke`,
992
+ * no `permission_scope` / `tool_allowlist` gate, no activity row — running as the ACCOUNT OWNER.
993
+ * Three doors led there and each is now a refusal:
994
+ *
995
+ * 1. an agent runtime with no identity → `beehaven agent login <name>`
996
+ * 2. an identity whose token has EXPIRED → log in again
997
+ * 3. `--building`, which skips agent-invoke → connect to that workspace instead
998
+ *
999
+ * ⚠⚠ (2) IS THE SHARPEST AND IS WHY THIS IS NOT COSMETIC: PRIVILEGE WENT **UP** ON EXPIRY. An
1000
+ * agent bound by a read-only allowlist, whose token lapsed, silently became the account owner and
1001
+ * kept working — the one state where a credential must do LESS, it did more, with nothing on
1002
+ * screen to say so. An expired token is a re-login, never an escalation.
1003
+ *
1004
+ * ⚠⚠ A HUMAN IS NOT REFUSED, AND THE PREDICATE IS WHY. `isAgentRuntime()` answers only to a
1005
+ * POSITIVE marker; `deriveAgentScope()` would have been the obvious test and is wrong, because it
1006
+ * falls back to `pid-<ppid>` for any non-TTY — so `beehaven call … | jq`, every script and every
1007
+ * CI step would have been told to log in as a robot to read their own workspace.
1008
+ *
1009
+ * ⚠ `--building` IS ONLY REFUSED WHEN AN AGENT IS ACTUALLY ACTIVE. The reason it skips
1010
+ * agent-invoke is real and unchanged (nesting inside an `admin:*-rpc` proxy files the activity in
1011
+ * the wrong workspace's log) — but "the attribution would be wrong there" is an argument for
1012
+ * refusing the call, not for silently making it un-attributed. A human's `--building` is
1013
+ * untouched.
1014
+ */
1015
+ const activeForCall = resolveActiveAgent(values);
1016
+ if (activeForCall?.expired) {
1017
+ console.error(` Your agent identity for "${activeForCall.name}" has expired.`);
1018
+ console.error(` Run: beehaven agent login ${activeForCall.name}`);
1019
+ console.error(' (Calls are not run as the account owner when an agent identity lapses.)');
1020
+ process.exit(1);
1021
+ }
1022
+ if (!activeForCall && isAgentRuntime()) {
1023
+ console.error(' No agent identity — this call would run as the account owner, ungated and unattributed.');
1024
+ console.error(' Run: beehaven agent login <name>');
1025
+ console.error(' Or: beehaven agent list # identities on this machine');
1026
+ process.exit(1);
1027
+ }
1028
+ /**
1029
+ * ⚠⚠ THE FLAG IS READ FROM `process.argv`, NOT FROM `values.building` — THEY ARE NOT THE
1030
+ * SAME QUESTION, AND THIS GUARD SHIPPED BROKEN BECAUSE IT ASKED THE WRONG ONE.
1031
+ *
1032
+ * `resolveTargetBuilding()` (cli.ts) WRITES `values.building` before dispatch: from the target
1033
+ * file when the address has a slash, and from the daemon's own focus when no target was ever
1034
+ * set. So by the time this runs, that field usually holds a value the caller never typed — and
1035
+ * the guard refused EVERY call the moment an agent signed in, naming a flag that was not on the
1036
+ * command line. Reproduced live: `beehaven call get-upload-url '{...}'` as `claude`.
1037
+ *
1038
+ * The invariant is about INTENT ("the caller asked to proxy"), and only argv still carries it.
1039
+ * Same lesson as every other guard in this tree: pin the claim, never a field that something
1040
+ * upstream is also allowed to write.
1041
+ */
1042
+ const buildingFlagTyped = process.argv.some((a) => a === '--building' || a === '-b' || a.startsWith('--building='));
1043
+ if (activeForCall && buildingFlagTyped) {
1044
+ console.error(` Cannot use --building while signed in as "${activeForCall.name}".`);
1045
+ console.error(' A proxied call cannot be attributed to an agent in the target workspace.');
1046
+ console.error(' Run: beehaven connect <address> # then call it directly');
1047
+ process.exit(1);
1048
+ }
1049
+ // ⚠ The wrap now happens ABOVE, before routing — see `activeAgentWrap`. Wrapping here would put
1050
+ // `agent-invoke` outside the building envelope again, which is the bug that cost four
1051
+ // hypotheses; the assignment is left out deliberately rather than reordered in place.
1052
+ // Surface routing — one dim line at the top so agents (and humans) know
1053
+ // exactly which DO the call lands on. Skip in --json mode to keep output
1054
+ // machine-parseable.
1055
+ if (!values.json) {
1056
+ /**
1057
+ * ⚠ THE BREADCRUMB USED TO SAY `home` WHENEVER `--building` WAS ABSENT — including when
1058
+ * the call was about to be routed by the DAEMON's focus to somewhere else entirely. It
1059
+ * was the one line that could have caught the wrong-workspace drift (CLR-1161) and it
1060
+ * was asserting the very thing that was false. `resolveTargetBuilding` now guarantees
1061
+ * that an absent `building` really does mean home, so this is finally true.
1062
+ */
1063
+ const target = values.building ? values.building : 'home';
1064
+ // ⚠ Name the PRINCIPAL as well as the target. "as <agent>" is the only on-screen
1065
+ // difference between a call the workspace attributes to an agent and one it books
1066
+ // against the account owner, and that distinction was previously invisible.
1067
+ // ⚠ `!activeForCall.expired` USED TO BE PART OF THIS TEST AND IS NOW DEAD — an expired
1068
+ // identity is refused above, so it can never reach the breadcrumb. Leaving it in kept a stale
1069
+ // guard green by accident: the suite's "an EXPIRED token does not silently act as the agent"
1070
+ // assertion was matching THIS line rather than the routing condition it meant to pin.
1071
+ const who = activeForCall ? ` ${'\x1b[36m'}as ${activeForCall.name}\x1b[0m\x1b[2m` : '';
1072
+ console.error(`\x1b[2m→ ${target} ${action}${who}\x1b[0m`);
1073
+ }
1074
+ try {
1075
+ const raw = await rpcOnce(resolved.action, resolved.input, getTimeout(values));
1076
+ // Unwrap proxy envelope: admin:*-rpc wraps results in { type: 'ack', result: ... }
1077
+ const result = raw?.type === 'ack' && raw?.result !== undefined
1078
+ ? raw.result
1079
+ : raw?.result !== undefined && values.building
1080
+ ? raw.result
1081
+ : raw;
1082
+ // ⚠ PERSIST BEFORE THE ERROR BRANCHES BELOW. `cd Q1 && ls nope` exits non-zero having already
1083
+ // moved, and the handler returns the new cwd on its failure path precisely for that reason —
1084
+ // absorbing only on success would silently drop a directory change the shell really made.
1085
+ absorbShellCwd(action, result, shellTarget);
1086
+ // Check for proxied errors
1087
+ if (raw?.type === 'error') {
1088
+ console.error(JSON.stringify({ error: raw.message ?? 'Action failed' }, null, 2));
1089
+ printCallErrorHints(raw.message, action, values);
1090
+ process.exit(1);
1091
+ }
1092
+ if (result && typeof result === 'object') {
1093
+ console.log(JSON.stringify(result, null, 2));
1094
+ }
1095
+ else {
1096
+ console.log(result);
1097
+ }
1098
+ /**
1099
+ * ⚠⚠ A FAILURE INSIDE A SUCCESSFUL ENVELOPE IS STILL A FAILURE — `beehaven call` EXITED 0 FOR
1100
+ * EVERYTHING.
1101
+ *
1102
+ * The two exits above cover a thrown transport error and a proxied `type:'error'`. Neither is
1103
+ * the common case: an unknown action, a handler refusal and an internal SQL error all come back
1104
+ * as an ordinary `ack` carrying `{ error }` or `{ ok:false }` in the RESULT, which landed here
1105
+ * and exited 0. Measured — unknown action, ok:false, `code:'bad-path'`, SQL error and genuine
1106
+ * success all returned 0, while `beehaven sql "SELECT"` and `beehaven cat <missing>` correctly
1107
+ * returned 1. `call` is the verb the docs call canonical, and it was the one that could not be
1108
+ * tested by a script: `beehaven call x || handle` never fired.
1109
+ *
1110
+ * ⚠ THE CODEBASE ALREADY DEPENDS ON THIS WORKING. The `AGENT_LOGIN_EXEMPT` note beside
1111
+ * `case 'characters'` states that routed through `call` the Mac app "gets a login prompt on
1112
+ * stderr and a non-zero exit" — a false premise holding up an exemption.
1113
+ *
1114
+ * ⚠ `error: ''` IS NOT A FAILURE. Some handlers set the field empty on success, so an
1115
+ * emptiness check is required rather than mere presence — the same rule `isFailurePayload`
1116
+ * follows in mcp-server.ts, which this mirrors deliberately so the two doors agree.
1117
+ *
1118
+ * ⚠ THE RESULT IS STILL PRINTED FIRST. The caller needs the payload to know WHY; exiting
1119
+ * before printing would trade a silent success for a silent failure.
1120
+ */
1121
+ /**
1122
+ * ⚠⚠ AN IDENTITY IS PER WORKSPACE, AND `unknown agent 'X'` IS THE SERVER SAYING SO.
1123
+ *
1124
+ * The credential is per-USER — the token carries `agentId` + `userId` and NO workspace — but
1125
+ * the agent ROW lives in the workspace DO, and `_checkAgentScope` refuses a principal it has
1126
+ * no row for. So an identity minted in workspace A is genuinely unusable in workspace B until
1127
+ * you log in THERE, which is the act that creates the row.
1128
+ *
1129
+ * ⚠ THE RAW REFUSAL READS AS "THAT AGENT DOES NOT EXIST", which is the opposite of the truth:
1130
+ * it exists, it is valid, it is simply not a member here. Reproduced live — connecting to a
1131
+ * second workspace and calling `document-list` answered `unknown agent 'jinri-claude'` with
1132
+ * nothing to act on, so the recovery is to re-mint under a new name or conclude the login
1133
+ * broke.
1134
+ *
1135
+ * ⚠ THE HINT BELONGS ON THIS SIDE because neither half can answer alone: only the client knows
1136
+ * the identity is present on this machine, only the server knows the workspace roster.
1137
+ */
1138
+ /**
1139
+ * ⚠⚠ SELF-HEAL: ADMIT THE IDENTITY AND RETRY ONCE. Identity is minted at the ACCOUNT
1140
+ * (`agent-account-mint`), and a workspace still decides for itself who may act in it — so the
1141
+ * first call into a workspace you have not joined is legitimately refused. Admitting here, in
1142
+ * the ONE routing this CLI exercises on every invocation, is what turns that into a step the
1143
+ * person never has to know about.
1144
+ *
1145
+ * ⚠ THIS IS NOT AUTO-ADMISSION INTO WORKSPACES YOU DID NOT ASK FOR. It fires only on a
1146
+ * workspace you explicitly connected to and are actively calling, and `agent-admit` still
1147
+ * charges `maxAgents` and refuses at the cap — which is then REPORTED rather than retried.
1148
+ *
1149
+ * ⚠ ONCE. A second refusal is a real one (revoked identity, entitlement, a genuinely unknown
1150
+ * name), and retrying past it turns one clear error into a loop.
1151
+ */
1152
+ if (result && typeof result === 'object' && result.scope === 'unknown-agent'
1153
+ && activeForCall && !values.__admitRetry) {
1154
+ const admit = resolveBuilding('agent-admit', { agentId: activeForCall.agentId }, values.building);
1155
+ const ar = await rpcOnce(admit.action, admit.input, 20_000).catch(() => null);
1156
+ const ares = ar?.result ?? ar;
1157
+ if (ares?.ok) {
1158
+ if (!values.json) {
1159
+ console.error(` ${activeForCall.name} joined this workspace — retrying.`);
1160
+ }
1161
+ return await cmdCall(action, rawInput, { ...values, __admitRetry: true });
1162
+ }
1163
+ console.error('');
1164
+ console.error(` "${activeForCall.name}" could not join this workspace: ${ares?.error ?? 'unknown reason'}`);
1165
+ if (String(ares?.code) === 'unknown-identity') {
1166
+ console.error(` Mint it first: beehaven agent login ${activeForCall.name}`);
1167
+ }
1168
+ }
1169
+ /**
1170
+ * ⚠⚠ CBH2-51 — TRANSLATE A STORAGE-LAYER LEAK INTO THE ARGUMENT IT IS REALLY ABOUT.
1171
+ *
1172
+ * ⚠ ON FAILURE ONLY, AND ONLY FOR THIS ERROR SHAPE. It costs one `describe-action` round trip,
1173
+ * which is free on a call that has already failed and would be a tax on every call that has
1174
+ * not. `looksLikeArgShapeError` is what keeps it narrow.
1175
+ *
1176
+ * ⚠ NON-FATAL, AND SILENT WHEN IT HAS NOTHING TO ADD. If the schema cannot be fetched the
1177
+ * caller still gets the original payload and the original exit code — an extra diagnosis must
1178
+ * never be able to turn a reported failure into a crash, or into a second wrong cause.
1179
+ */
1180
+ if (isFailureResult(result) && !values.json) {
1181
+ const errText = result?.error;
1182
+ if (looksLikeArgShapeError(errText)) {
1183
+ try {
1184
+ const d = resolveBuilding('describe-action', { name: action }, values.building);
1185
+ const dr = await rpcOnce(d.action, d.input, 15_000).catch(() => null);
1186
+ const schema = (dr?.result ?? dr)?.input;
1187
+ for (const line of argShapeAdvice(action, input, schema))
1188
+ console.error(line);
1189
+ }
1190
+ catch { /* the payload above is still the answer */ }
1191
+ }
1192
+ printKnownActionAdvice(action, input, result);
1193
+ }
1194
+ if (isFailureResult(result))
1195
+ process.exit(1);
1196
+ }
1197
+ catch (err) {
1198
+ console.error(JSON.stringify({ error: err.message }, null, 2));
1199
+ printCallErrorHints(err.message, action, values);
1200
+ process.exit(1);
1201
+ }
1202
+ }
1203
+ /** Give exact existing verbs for names a caller reasonably guessed. */
1204
+ function printKnownActionAdvice(action, input, result) {
1205
+ const described = ['action-describe', 'describe-action', 'explain-action'].includes(action);
1206
+ const requested = described ? String(input?.action ?? input?.name ?? '') : action;
1207
+ if (result?.code === 'unknown_action') {
1208
+ if (requested === 'ticket-search') {
1209
+ console.error(' Search tickets with: beehaven call ticket-list \'{"text":"<query>","limit":20}\'');
1210
+ }
1211
+ else if (requested === 'document-move') {
1212
+ console.error(' Move a document with: beehaven call note-update \'{"id":"<document-id>","updates":{"projectId":"<project-id>"}}\'');
1213
+ }
1214
+ }
1215
+ if (action === 'ticket-get' && result?.code === 'not-found') {
1216
+ console.error(' If this key belongs to a document, read it with document-get; ticket-get reads board cards only.');
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Emit navigation hints for common call failures. Agents parse these to
1221
+ * self-correct without a round-trip back to the user.
1222
+ */
1223
+ function printCallErrorHints(msg, action, values) {
1224
+ if (!msg)
1225
+ return;
1226
+ const s = String(msg).toLowerCase();
1227
+ if (!values.json) {
1228
+ console.error('');
1229
+ console.error('\x1b[2m Try:\x1b[0m');
1230
+ if (s.includes('unknown action') || s.includes('404')) {
1231
+ console.error(`\x1b[2m beehaven actions --search <name> # find the mounted action\x1b[0m`);
1232
+ console.error(`\x1b[2m beehaven pwd # confirm you\'re on the right DO\x1b[0m`);
1233
+ }
1234
+ else if (s.includes('timeout') || s.includes('websocket send failed')) {
1235
+ if (action === 'document-create') {
1236
+ console.error(' The write outcome is unknown. Search for the document title and inspect its ticket link before retrying; a blind retry can create a duplicate.');
1237
+ }
1238
+ else {
1239
+ console.error(`\x1b[2m beehaven pwd # confirm the target before retrying\x1b[0m`);
1240
+ }
1241
+ console.error(`\x1b[2m beehaven status # confirm daemon + relay are up\x1b[0m`);
1242
+ }
1243
+ else if (s.includes('unauthorized') || s.includes('forbidden')) {
1244
+ console.error(`\x1b[2m beehaven pwd # are you on the right target?\x1b[0m`);
1245
+ console.error(`\x1b[2m beehaven status # check auth state\x1b[0m`);
1246
+ }
1247
+ else if (s.includes('rate limit')) {
1248
+ console.error(`\x1b[2m wait a few seconds and retry\x1b[0m`);
1249
+ }
1250
+ else {
1251
+ console.error(`\x1b[2m beehaven pwd # where am I?\x1b[0m`);
1252
+ console.error(`\x1b[2m beehaven actions # what can I call here?\x1b[0m`);
1253
+ }
1254
+ }
1255
+ }
1256
+ /**
1257
+ * Execute one JSON-RPC line without tying the work to readline's lifetime.
1258
+ *
1259
+ * The old implementation started this promise from `rl.on('line')` and let the
1260
+ * close handler call `process.exit(0)`. A finite stdin therefore closed the
1261
+ * socket while the first request was still in flight. Keeping parsing and
1262
+ * dispatch in a pure-ish helper makes the drain contract testable and keeps
1263
+ * all methods on `client.call`, where agent attribution and target routing live.
1264
+ */
1265
+ export async function executePipeRequest(client, line) {
1266
+ if (!line.trim())
1267
+ return { response: null, failed: false };
1268
+ let request;
1269
+ try {
1270
+ request = JSON.parse(line);
1271
+ }
1272
+ catch {
1273
+ return {
1274
+ response: {
1275
+ jsonrpc: '2.0',
1276
+ id: null,
1277
+ error: { code: -32700, message: 'Parse error' },
1278
+ },
1279
+ failed: true,
1280
+ };
1281
+ }
1282
+ const { id, method, params } = request ?? {};
1283
+ if (!method) {
1284
+ return {
1285
+ response: {
1286
+ jsonrpc: '2.0',
1287
+ id: id ?? null,
1288
+ error: { code: -32600, message: 'Missing method' },
1289
+ },
1290
+ failed: true,
1291
+ };
1292
+ }
1293
+ try {
1294
+ let result;
1295
+ switch (method) {
1296
+ case 'ping':
1297
+ result = { pong: true, timestamp: Date.now() };
1298
+ break;
1299
+ case 'actions.list':
1300
+ result = await client.call('list-actions', params || {});
1301
+ break;
1302
+ case 'actions.describe':
1303
+ result = await client.call('describe-action', params || {});
1304
+ break;
1305
+ case 'actions.search':
1306
+ // Agent-friendly fuzzy match: "action that creates a task" →
1307
+ // ranks note-create with board fileType above note-update.
1308
+ // Implemented client-side so it works on any DO with
1309
+ // list-actions (doesn't require a server-side actions.search
1310
+ // handler). Returns top matches with scores.
1311
+ result = await actionsSearch(client, params || {});
1312
+ break;
1313
+ case 'workspace.info':
1314
+ result = await client.call('workspace-info', params || {});
1315
+ break;
1316
+ default:
1317
+ result = await client.call(method, params || {});
1318
+ break;
1319
+ }
1320
+ return { response: { jsonrpc: '2.0', id, result }, failed: false };
1321
+ }
1322
+ catch (err) {
1323
+ const message = err?.message ?? String(err);
1324
+ const code = message.includes('timeout') ? -32001 : -32000;
1325
+ return {
1326
+ response: { jsonrpc: '2.0', id, error: { code, message } },
1327
+ failed: true,
1328
+ };
1329
+ }
1330
+ }
1331
+ /**
1332
+ * Drain a finite set of JSON-RPC lines in input order.
1333
+ *
1334
+ * Sequential dispatch is deliberate: callers get deterministic output and
1335
+ * the CLI never creates unbounded in-flight requests from a large script.
1336
+ * Errors are emitted as JSON-RPC error responses, but the remaining input is
1337
+ * still drained; the returned boolean controls the eventual non-zero exit.
1338
+ */
1339
+ export async function drainPipeLines(lines, client, write) {
1340
+ let ok = true;
1341
+ for (const line of lines) {
1342
+ const { response, failed } = await executePipeRequest(client, line);
1343
+ if (response)
1344
+ write(response);
1345
+ if (failed)
1346
+ ok = false;
1347
+ }
1348
+ return ok;
1349
+ }
1350
+ /**
1351
+ * Run the workspace's declarative batch action through the canonical call
1352
+ * path. This is intentionally only an adapter: validation, active-target
1353
+ * resolution, agent attribution, spend confirmation and failure handling all
1354
+ * remain in `cmdCall`.
1355
+ */
1356
+ export function normaliseBatchPayload(payload) {
1357
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.steps)) {
1358
+ return payload;
1359
+ }
1360
+ return {
1361
+ ...payload,
1362
+ steps: payload.steps.map((step) => {
1363
+ if (!step || typeof step !== 'object')
1364
+ return step;
1365
+ const source = step;
1366
+ const method = source.method ?? source.action;
1367
+ const params = source.params ?? source.input ?? {};
1368
+ const canonical = { ...source, method, params };
1369
+ delete canonical.action;
1370
+ delete canonical.input;
1371
+ return canonical;
1372
+ }),
1373
+ };
1374
+ }
1375
+ export async function cmdBatch(rawInput, values) {
1376
+ if (!rawInput) {
1377
+ console.error(' Usage: beehaven batch <json|@file|-> [--json]');
1378
+ process.exit(1);
1379
+ }
1380
+ // The workspace action's wire format is `{ method, params }`, while every
1381
+ // other Beehaven surface calls the pair `{ action, input }`. The first CLI
1382
+ // release documented the latter but forwarded it untouched, so the exact
1383
+ // example in `beehaven help batch` failed with "method is required". Accept
1384
+ // both spellings here and send one canonical payload to every worker version.
1385
+ let source = rawInput;
1386
+ if (source === '-' || source.startsWith('@')) {
1387
+ const { readFileSync } = await import('fs');
1388
+ const { resolve } = await import('path');
1389
+ try {
1390
+ source = source === '-'
1391
+ ? readFileSync(0, 'utf8')
1392
+ : readFileSync(resolve(source.slice(1)), 'utf8');
1393
+ }
1394
+ catch (err) {
1395
+ console.error(` Could not read batch input: ${err.message}`);
1396
+ process.exit(1);
1397
+ }
1398
+ }
1399
+ let payload;
1400
+ try {
1401
+ payload = JSON.parse(source);
1402
+ }
1403
+ catch {
1404
+ console.error(` Invalid JSON input: ${source}`);
1405
+ console.error(' Example: beehaven batch \'{"steps":[{"action":"ticket-list","input":{"limit":10}}]}\'');
1406
+ process.exit(1);
1407
+ }
1408
+ await cmdCall('batch', JSON.stringify(normaliseBatchPayload(payload)), values);
1409
+ }
1410
+ /** Convert source text (or a JSON code-run payload) into the server input. */
1411
+ export function codeRunPayload(source) {
1412
+ const input = { code: source };
1413
+ try {
1414
+ const parsed = JSON.parse(source);
1415
+ if (parsed && typeof parsed === 'object' && typeof parsed.code === 'string') {
1416
+ return parsed;
1417
+ }
1418
+ }
1419
+ catch {
1420
+ /* source text, not a JSON payload */
1421
+ }
1422
+ return input;
1423
+ }
1424
+ /**
1425
+ * Adapt a source file/stdin stream to the workspace's `code-run` action.
1426
+ *
1427
+ * A source file is the ergonomic form (`code-run @script.js` or
1428
+ * `code-run -`). For callers that need `maxCalls`/`timeoutMs`, a JSON payload
1429
+ * containing `code` is also accepted and passed through unchanged. Either
1430
+ * form still enters `cmdCall`, so this command cannot create a second policy
1431
+ * or routing path beside the normal action verb.
1432
+ */
1433
+ export async function cmdCodeRun(rawInput, values) {
1434
+ if (!rawInput) {
1435
+ console.error(' Usage: beehaven code-run <file|-> [--json]');
1436
+ process.exit(1);
1437
+ }
1438
+ let source = rawInput;
1439
+ if (rawInput === '-' || rawInput.startsWith('@')) {
1440
+ const { readFileSync } = await import('fs');
1441
+ const { resolve } = await import('path');
1442
+ try {
1443
+ source = rawInput === '-'
1444
+ ? readFileSync(0, 'utf8')
1445
+ : readFileSync(resolve(rawInput.slice(1)), 'utf8');
1446
+ }
1447
+ catch (err) {
1448
+ console.error(` Could not read code input: ${err.message}`);
1449
+ process.exit(1);
1450
+ }
1451
+ }
1452
+ const input = codeRunPayload(source);
1453
+ await cmdCall('code-run', JSON.stringify(input), values);
1454
+ }
1455
+ export async function cmdPipe(values) {
1456
+ const readline = await import('readline');
1457
+ let client;
1458
+ try {
1459
+ client = await connectClient({ timeout: getTimeout(values) });
1460
+ }
1461
+ catch (err) {
1462
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32000, message: err.message } }) +
1463
+ '\n');
1464
+ process.exitCode = 1;
1465
+ return;
1466
+ }
1467
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
1468
+ let shuttingDown = false;
1469
+ let failed = false;
1470
+ const writeResponse = (response) => {
1471
+ process.stdout.write(JSON.stringify(response) + '\n');
1472
+ };
1473
+ client.subscribe(['*']);
1474
+ client.on('broadcast', (msg) => {
1475
+ if (shuttingDown)
1476
+ return;
1477
+ writeResponse({ jsonrpc: '2.0', method: 'workspace.broadcast', params: msg });
1478
+ });
1479
+ const finish = (ok) => {
1480
+ if (shuttingDown)
1481
+ return;
1482
+ shuttingDown = true;
1483
+ client.disconnect();
1484
+ failed = failed || !ok;
1485
+ process.exitCode = failed ? 1 : 0;
1486
+ };
1487
+ client.on('close', () => {
1488
+ if (shuttingDown)
1489
+ return;
1490
+ failed = true;
1491
+ writeResponse({
1492
+ jsonrpc: '2.0',
1493
+ method: 'workspace.disconnected',
1494
+ params: { reason: 'Daemon connection lost' },
1495
+ });
1496
+ // Wake a TTY reader if the daemon disappeared while it was waiting for a
1497
+ // line. The finite-input path will naturally finish its async iterator.
1498
+ try {
1499
+ rl.close();
1500
+ }
1501
+ catch { }
1502
+ });
1503
+ writeResponse({ jsonrpc: '2.0', method: 'workspace.ready', params: { pid: process.pid } });
1504
+ if (!process.stdin.isTTY) {
1505
+ // A pipe has finite input. Collect it before dispatching anything so EOF
1506
+ // cannot race the first asynchronous RPC, then drain it deterministically.
1507
+ const lines = [];
1508
+ try {
1509
+ for await (const line of rl) {
1510
+ const text = String(line).trim();
1511
+ if (text)
1512
+ lines.push(text);
1513
+ }
1514
+ }
1515
+ catch (err) {
1516
+ failed = true;
1517
+ writeResponse({
1518
+ jsonrpc: '2.0',
1519
+ id: null,
1520
+ error: { code: -32000, message: err?.message ?? String(err) },
1521
+ });
1522
+ }
1523
+ const ok = await drainPipeLines(lines, client, writeResponse);
1524
+ finish(ok);
1525
+ return;
1526
+ }
1527
+ // A TTY is a long-lived stream. Pause around each request so interactive
1528
+ // callers still get one response per line without concurrent reordering.
1529
+ let queue = Promise.resolve();
1530
+ let closed = false;
1531
+ rl.on('line', (line) => {
1532
+ rl.pause();
1533
+ queue = queue.then(async () => {
1534
+ const result = await executePipeRequest(client, line);
1535
+ if (result.response)
1536
+ writeResponse(result.response);
1537
+ if (result.failed)
1538
+ failed = true;
1539
+ }).catch((err) => {
1540
+ failed = true;
1541
+ writeResponse({
1542
+ jsonrpc: '2.0',
1543
+ id: null,
1544
+ error: { code: -32000, message: err?.message ?? String(err) },
1545
+ });
1546
+ }).finally(() => {
1547
+ if (!closed && !shuttingDown)
1548
+ rl.resume();
1549
+ });
1550
+ });
1551
+ await new Promise((resolve) => {
1552
+ rl.on('close', () => {
1553
+ closed = true;
1554
+ resolve();
1555
+ });
1556
+ });
1557
+ await queue;
1558
+ finish(!failed);
1559
+ }
1560
+ // ── beehaven watch [patterns] ────────────────────────────────────────────
1561
+ export async function cmdWatch(patterns, values) {
1562
+ let client;
1563
+ try {
1564
+ client = await connectClient();
1565
+ }
1566
+ catch (err) {
1567
+ console.error(` Error: ${err.message}`);
1568
+ process.exit(1);
1569
+ }
1570
+ client.subscribe(patterns);
1571
+ console.error(` Watching: ${patterns.join(', ')} (Ctrl+C to stop)`);
1572
+ // --format tool-use: agent-friendly event shapes. Instead of the raw
1573
+ // WebSocket broadcast message, emit structured {event, table?, id?,
1574
+ // delta?, action?} one per line so an LLM tool reading the stream can
1575
+ // switch on `event` without parsing `data.type`.
1576
+ // --trace: human-readable timeline mode for lifecycle debugging. Prints
1577
+ // one compact line per event with the elapsed-ms since the previous
1578
+ // event, indented by causal depth so cascade patterns (RPC → trigger →
1579
+ // broadcast) are visible at a glance.
1580
+ // --record <path>: tees every broadcast to a JSONL file. Each line is
1581
+ // a {ts, msg} pair suitable for replay via reshapeForToolUse or a
1582
+ // custom test harness. Works alongside --trace / --format.
1583
+ const format = values.format ?? 'raw';
1584
+ const trace = values.trace === true;
1585
+ const recordPath = typeof values.record === 'string' ? values.record : null;
1586
+ // Open a write stream for --record if requested. Append mode so
1587
+ // re-running the watcher with the same path extends the log instead
1588
+ // of truncating (useful for long-running captures across restarts).
1589
+ let recordStream = null;
1590
+ if (recordPath) {
1591
+ const { createWriteStream, mkdirSync } = await import('fs');
1592
+ const { dirname } = await import('path');
1593
+ try {
1594
+ mkdirSync(dirname(recordPath), { recursive: true });
1595
+ }
1596
+ catch {
1597
+ /* directory may already exist */
1598
+ }
1599
+ recordStream = createWriteStream(recordPath, { flags: 'a', encoding: 'utf-8' });
1600
+ console.error(` Recording → ${recordPath}`);
1601
+ }
1602
+ const recordEvent = (msg) => {
1603
+ if (!recordStream)
1604
+ return;
1605
+ recordStream.write(JSON.stringify({ ts: Date.now(), msg }) + '\n');
1606
+ };
1607
+ if (trace) {
1608
+ // ── trace mode ────────────────────────────────────────────────────
1609
+ // Track first-event t0 + last-event tick so each new line can show
1610
+ // `+Δms since previous`. Infer indent depth from event family:
1611
+ // RPC start/end = 0, row-changed / hydration = 1, session/building = 2.
1612
+ // Not a true flame graph, but groups cascades visually in the terminal
1613
+ // without server-side span instrumentation.
1614
+ let lastTick = null;
1615
+ const t0 = Date.now();
1616
+ const divider = ' ' + '─'.repeat(60);
1617
+ console.error(divider);
1618
+ console.error(` ${'time'.padEnd(12)} ${'Δms'.padStart(6)} event`);
1619
+ console.error(divider);
1620
+ client.on('broadcast', (msg) => {
1621
+ recordEvent(msg);
1622
+ const event = reshapeForToolUse(msg);
1623
+ if (!event)
1624
+ return;
1625
+ const now = Date.now();
1626
+ const absMs = now - t0;
1627
+ const deltaMs = lastTick == null ? 0 : now - lastTick;
1628
+ lastTick = now;
1629
+ const clock = new Date(now).toISOString().slice(11, 23);
1630
+ const depth = traceDepth(event.event);
1631
+ const indent = ' ' + ' '.repeat(depth);
1632
+ const summary = traceSummary(event);
1633
+ const deltaStr = deltaMs === 0 ? ' — ' : `+${String(deltaMs).padStart(4)}ms`;
1634
+ console.log(`${indent}${clock} ${deltaStr} ${summary} (t+${absMs}ms)`);
1635
+ });
1636
+ client.on('close', () => {
1637
+ recordStream?.end();
1638
+ console.error(' Connection lost.');
1639
+ process.exit(1);
1640
+ });
1641
+ process.on('SIGINT', () => {
1642
+ recordStream?.end();
1643
+ client.disconnect();
1644
+ process.exit(0);
1645
+ });
1646
+ return;
1647
+ }
1648
+ client.on('broadcast', (msg) => {
1649
+ recordEvent(msg);
1650
+ if (format === 'tool-use') {
1651
+ const event = reshapeForToolUse(msg);
1652
+ if (event)
1653
+ console.log(JSON.stringify(event));
1654
+ return;
1655
+ }
1656
+ const ts = new Date().toISOString().slice(11, 23);
1657
+ console.log(JSON.stringify({ timestamp: ts, type: msg?.type || 'unknown', data: msg }));
1658
+ });
1659
+ client.on('close', () => {
1660
+ recordStream?.end();
1661
+ console.error(' Connection lost.');
1662
+ process.exit(1);
1663
+ });
1664
+ process.on('SIGINT', () => {
1665
+ recordStream?.end();
1666
+ client.disconnect();
1667
+ process.exit(0);
1668
+ });
1669
+ }
1670
+ /**
1671
+ * Heuristic indent for trace mode. Events higher in the causal chain
1672
+ * (RPC start/end — the root that kicked off the work) sit at depth 0;
1673
+ * the data mutations and broadcasts they triggered nest under them.
1674
+ *
1675
+ * Not a real call tree — the server doesn't emit parent ids on broadcasts
1676
+ * today. But the depth grouping is useful to eyeball "this RPC fired, then
1677
+ * 3 rows changed, then a broadcast went out" patterns in the terminal.
1678
+ *
1679
+ * Exported so the watch-trace tests can pin the mapping.
1680
+ */
1681
+ export function traceDepth(event) {
1682
+ if (event === 'other')
1683
+ return 0;
1684
+ if (event.startsWith('stream'))
1685
+ return 0;
1686
+ if (event === 'row-changed' || event === 'bulk-change')
1687
+ return 1;
1688
+ if (event.startsWith('session'))
1689
+ return 2;
1690
+ if (event.startsWith('building'))
1691
+ return 2;
1692
+ return 0;
1693
+ }
1694
+ /** One-line human summary of a reshaped event. Short enough to fit the trace column. */
1695
+ export function traceSummary(event) {
1696
+ switch (event.event) {
1697
+ case 'row-changed':
1698
+ return `row-changed ${event.table}/${event.id} ${event.op}`;
1699
+ case 'bulk-change':
1700
+ return `bulk-change ${event.table} ×${event.count ?? '?'}`;
1701
+ case 'session-join':
1702
+ return `session-join ${event.clientId}${event.app ? ` (${event.app})` : ''}`;
1703
+ case 'session-leave':
1704
+ return `session-leave ${event.clientId}`;
1705
+ case 'building-connected':
1706
+ return `building-connected ${event.buildingType}/${event.buildingId}`;
1707
+ case 'building-disconnected':
1708
+ return `building-disconnected ${event.buildingType}/${event.buildingId}`;
1709
+ case 'stream-chunk':
1710
+ return `stream-chunk ${event.streamId}`;
1711
+ case 'stream-complete':
1712
+ return `stream-complete ${event.streamId}`;
1713
+ case 'other':
1714
+ return `other ${event.type ?? '?'}`;
1715
+ default:
1716
+ return event.event;
1717
+ }
1718
+ }
1719
+ /**
1720
+ * Normalize a broadcast message into a flat {event, ...fields} shape
1721
+ * that LLM agents can switch on without re-parsing internals.
1722
+ *
1723
+ * Known event mappings (best-effort — we don't know every broadcast
1724
+ * type the SDK emits, so unknown shapes fall through to a catch-all):
1725
+ *
1726
+ * sql:delta {table, op: 'insert'|'update'|'delete', id, delta}
1727
+ * → { event: 'row-changed', table, op, id, delta, ts }
1728
+ *
1729
+ * sql:hydration / sql:batch
1730
+ * → { event: 'bulk-change', table, op: 'batch', count, ts }
1731
+ *
1732
+ * session:join / session:leave
1733
+ * → { event: 'session-<join|leave>', clientId, label, app, ts }
1734
+ *
1735
+ * building:connected / building:disconnected
1736
+ * → { event: 'building-<connected|disconnected>', buildingType, buildingId, ts }
1737
+ *
1738
+ * anything else → { event: 'other', type, data, ts }
1739
+ *
1740
+ * Returns null for messages we want to suppress (heartbeats, etc.).
1741
+ */
1742
+ export function reshapeForToolUse(msg) {
1743
+ if (!msg || typeof msg !== 'object')
1744
+ return null;
1745
+ const ts = new Date().toISOString();
1746
+ const type = msg.type;
1747
+ // Suppress pure noise — agents don't want to see keepalives.
1748
+ if (type === 'pong' || type === 'ping' || type === 'status')
1749
+ return null;
1750
+ if (type === 'sql:delta') {
1751
+ return {
1752
+ event: 'row-changed',
1753
+ table: msg.table,
1754
+ op: msg.op,
1755
+ id: msg.id,
1756
+ delta: msg.delta ?? msg.data,
1757
+ ts,
1758
+ };
1759
+ }
1760
+ if (type === 'sql:hydration' || type === 'sql:batch') {
1761
+ return {
1762
+ event: 'bulk-change',
1763
+ table: msg.table,
1764
+ op: 'batch',
1765
+ count: Array.isArray(msg.rows) ? msg.rows.length : (msg.count ?? undefined),
1766
+ ts,
1767
+ };
1768
+ }
1769
+ if (type === 'session:join' || type === 'session:leave') {
1770
+ return {
1771
+ event: type === 'session:join' ? 'session-join' : 'session-leave',
1772
+ clientId: msg.clientId,
1773
+ label: msg.label,
1774
+ app: msg.app,
1775
+ ts,
1776
+ };
1777
+ }
1778
+ if (type === 'building:connected' || type === 'building:disconnected') {
1779
+ return {
1780
+ event: type === 'building:connected' ? 'building-connected' : 'building-disconnected',
1781
+ buildingType: msg.buildingType,
1782
+ buildingId: msg.buildingId,
1783
+ ts,
1784
+ };
1785
+ }
1786
+ if (type === 'stream:chunk') {
1787
+ return {
1788
+ event: 'stream-chunk',
1789
+ streamId: msg.streamId,
1790
+ chunk: msg.chunk ?? msg.data,
1791
+ ts,
1792
+ };
1793
+ }
1794
+ if (type === 'stream:complete') {
1795
+ return {
1796
+ event: 'stream-complete',
1797
+ streamId: msg.streamId,
1798
+ result: msg.result,
1799
+ ts,
1800
+ };
1801
+ }
1802
+ // Catch-all — preserve the raw message under `data` so nothing is lost,
1803
+ // but give the agent a stable `event: 'other'` to switch against.
1804
+ return { event: 'other', type, data: msg, ts };
1805
+ }
1806
+ // ── beehaven workspace ───────────────────────────────────────────────────
1807
+ export async function cmdWorkspace(values) {
1808
+ try {
1809
+ const result = await rpcOnce('workspace-info', {}, getTimeout(values));
1810
+ if (values.json) {
1811
+ console.log(JSON.stringify(result, null, 2));
1812
+ return;
1813
+ }
1814
+ // A DO that answers `{error: …}` still resolves the RPC — the ack itself
1815
+ // succeeded. Rendering that straight into the template printed an empty
1816
+ // table list and "Total: undefined" AND EXITED 0, so a workspace that
1817
+ // couldn't be reached was indistinguishable from one that was empty.
1818
+ // Fail loudly instead.
1819
+ assertNoRpcError(result, 'workspace-info');
1820
+ console.log('');
1821
+ console.log(' Workspace');
1822
+ console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
1823
+ console.log('');
1824
+ console.log(' \x1b[36mTables\x1b[0m');
1825
+ for (const [name, info] of Object.entries(result.tables || {})) {
1826
+ const t = info;
1827
+ console.log(` ${name.padEnd(20)} \x1b[33m${t.count}\x1b[0m rows \x1b[90m(${t.columns.slice(0, 5).join(', ')}${t.columns.length > 5 ? '…' : ''})\x1b[0m`);
1828
+ }
1829
+ console.log('');
1830
+ console.log(' \x1b[36mActions\x1b[0m');
1831
+ const actions = result.actions || {};
1832
+ console.log(` Total: \x1b[33m${actions.total}\x1b[0m`);
1833
+ if (actions.categories) {
1834
+ for (const [cat, count] of Object.entries(actions.categories)) {
1835
+ console.log(` ${cat.padEnd(20)} ${count}`);
1836
+ }
1837
+ }
1838
+ console.log('');
1839
+ if (result.agent) {
1840
+ console.log(' \x1b[36mAgent\x1b[0m');
1841
+ console.log(` State: ${result.agent.state}`);
1842
+ console.log(` Model: ${result.agent.model}`);
1843
+ console.log('');
1844
+ }
1845
+ console.log(' ─────────────────────────────────────────────────');
1846
+ console.log(' Next steps:');
1847
+ console.log(' beehaven pwd Confirm current target');
1848
+ console.log(' beehaven actions Browse callable RPCs');
1849
+ console.log(' beehaven ls / Explore the VFS');
1850
+ console.log(' beehaven call <action> [json] Run one');
1851
+ console.log('');
1852
+ }
1853
+ catch (err) {
1854
+ console.error(` Error: ${err.message}`);
1855
+ console.error('');
1856
+ console.error(' Try:');
1857
+ console.error(' beehaven pwd confirm daemon + target');
1858
+ console.error(' beehaven status daemon / relay health');
1859
+ process.exit(1);
1860
+ }
1861
+ }
1862
+ // ── beehaven ls [path] ───────────────────────────────────────────────────
1863
+ export async function cmdLs(path, values) {
1864
+ try {
1865
+ const result = await rpcOnce('browse-vfs', { path, op: 'ls' }, getTimeout(values));
1866
+ if (values.json) {
1867
+ console.log(JSON.stringify(result, null, 2));
1868
+ return;
1869
+ }
1870
+ if (result.error) {
1871
+ console.error(` Error: ${result.error}`);
1872
+ process.exit(1);
1873
+ }
1874
+ const entries = result.entries || result;
1875
+ if (!Array.isArray(entries) || entries.length === 0) {
1876
+ console.log(` (empty) ${path}`);
1877
+ return;
1878
+ }
1879
+ for (const entry of entries) {
1880
+ const icon = entry.kind === 'dir' ? 'd' : '-';
1881
+ const name = entry.name || entry.path?.split('/').pop() || '?';
1882
+ const size = entry.size > 0 ? ` ${formatSize(entry.size)}` : '';
1883
+ console.log(` ${icon} ${name}${size}`);
1884
+ }
1885
+ }
1886
+ catch (err) {
1887
+ console.error(` Error: ${err.message}`);
1888
+ process.exit(1);
1889
+ }
1890
+ }
1891
+ // ── beehaven cat <path> ──────────────────────────────────────────────────
1892
+ export async function cmdCat(path, values) {
1893
+ try {
1894
+ const result = await rpcOnce('browse-vfs', { path, op: 'read' }, getTimeout(values));
1895
+ if (result.error) {
1896
+ console.error(` Error: ${result.error}`);
1897
+ process.exit(1);
1898
+ }
1899
+ const content = result.content ?? result;
1900
+ if (typeof content === 'string')
1901
+ console.log(content);
1902
+ else
1903
+ console.log(JSON.stringify(content, null, 2));
1904
+ }
1905
+ catch (err) {
1906
+ console.error(` Error: ${err.message}`);
1907
+ process.exit(1);
1908
+ }
1909
+ }
1910
+ // ── beehaven remember/recall/search/ask/task ─────────────────────────────
1911
+ /**
1912
+ * ⚠⚠ `rpcOnce` RETURNS `{ error }` AS A VALUE — IT DOES NOT THROW. A `try/catch` around it is
1913
+ * therefore not error handling, and a `console.log(' Done')` after it reports success on failure.
1914
+ *
1915
+ * ⚠ THIS SHIPPED. `cmdRemember` called `rpcOnce('remember', …)` and printed "Remembered: <key>"
1916
+ * unconditionally. `remember` is not a registered action (it was renamed `agent-memory-remember`),
1917
+ * so every invocation got back *"Unknown action: remember"*, printed success, exited 0, and wrote
1918
+ * nothing. Measured: 0 rows across `agent_memories`, `agent-memory` and `claude_memory`.
1919
+ *
1920
+ * ⚠ THE UNKNOWN-ACTION CASE IS THE ONE TO KEEP IN MIND, because it is what a RENAME produces and
1921
+ * a rename leaves no other trace. The daemon answers, the transport succeeds, and only the payload
1922
+ * says the verb is gone.
1923
+ */
1924
+ function rpcFailure(result) {
1925
+ if (!result || typeof result !== 'object')
1926
+ return null;
1927
+ if (typeof result.error === 'string' && result.error)
1928
+ return result.error;
1929
+ if (result.ok === false)
1930
+ return String(result.message ?? result.code ?? 'the workspace refused the call');
1931
+ return null;
1932
+ }
1933
+ export async function cmdRemember(key, value, values) {
1934
+ try {
1935
+ // ⚠ `agent-memory-remember`, NOT `remember` — see rpcFailure above.
1936
+ const result = await rpcOnce('agent-memory-remember', { key, value }, getTimeout(values));
1937
+ const failure = rpcFailure(result);
1938
+ if (failure) {
1939
+ console.error(` Error: ${failure}`);
1940
+ process.exit(1);
1941
+ }
1942
+ console.log(` Remembered: ${key}`);
1943
+ /**
1944
+ * ⚠ THE SERVER CLAMPS `importance` TO 0..1 AND SAYS SO. Swallowing that turns a visible
1945
+ * coercion back into a silent one, which is the exact trade `agent-memory-remember` refused.
1946
+ */
1947
+ if (result?.importanceCoerced) {
1948
+ const c = result.importanceCoerced;
1949
+ console.log(` \x1b[33m⚠\x1b[0m importance ${c.sent} stored as ${c.stored} — scale is ${c.scale}`);
1950
+ }
1951
+ }
1952
+ catch (err) {
1953
+ console.error(` Error: ${err.message}`);
1954
+ process.exit(1);
1955
+ }
1956
+ }
1957
+ export async function cmdRecall(query, values) {
1958
+ try {
1959
+ /**
1960
+ * ⚠⚠ TWO VERBS, AND THE QUERY DECIDES WHICH. `agent-memory-recall` takes NO query — it lists
1961
+ * by importance. `agent-memory-search` is the one that takes `query`. Sending a query to
1962
+ * `recall` would silently list everything and look like a search that matched broadly.
1963
+ */
1964
+ const result = query
1965
+ ? await rpcOnce('agent-memory-search', { query, limit: 20 }, getTimeout(values))
1966
+ : await rpcOnce('agent-memory-recall', { limit: 20 }, getTimeout(values));
1967
+ const failure = rpcFailure(result);
1968
+ if (failure) {
1969
+ console.error(` Error: ${failure}`);
1970
+ process.exit(1);
1971
+ }
1972
+ if (values.json) {
1973
+ console.log(JSON.stringify(result, null, 2));
1974
+ return;
1975
+ }
1976
+ const memories = result.memories || result.results || [];
1977
+ if (memories.length === 0) {
1978
+ /**
1979
+ * ⚠ SAY WHICH QUESTION WAS ASKED. A bare "No memories found." is what a BROKEN verb printed
1980
+ * for weeks, and it reads as *you have no memories* rather than *nothing matched*.
1981
+ */
1982
+ console.log(query ? ` No memories match "${query}".` : ' No memories yet.');
1983
+ return;
1984
+ }
1985
+ console.log('');
1986
+ for (const m of memories)
1987
+ console.log(` \x1b[36m${m.key}\x1b[0m: ${m.value}`);
1988
+ console.log('');
1989
+ }
1990
+ catch (err) {
1991
+ console.error(` Error: ${err.message}`);
1992
+ process.exit(1);
1993
+ }
1994
+ }
1995
+ /** The server's own ceiling — `searchNotes` does `Math.min(input.limit ?? 20, 200)`. */
1996
+ export const SEARCH_LIMIT_MAX = 200;
1997
+ export const SEARCH_LIMIT_DEFAULT = 20;
1998
+ /**
1999
+ * ⚠⚠ `search --limit N` WAS ACCEPTED AND IGNORED, WHICH IS WORSE THAN NOT SUPPORTING IT (CBH2-29).
2000
+ *
2001
+ * `limit` is declared as a GLOBAL option in `cli.ts`, so `parseArgs` takes it on every verb while
2002
+ * only a handful read it — and `cmdSearch` sent a hardcoded `limit: 20`. Measured: `--limit 1`,
2003
+ * `--limit 2` and `--limit 5` all returned the same 20 rows.
2004
+ *
2005
+ * ⚠ THE SILENCE IS THE DEFECT, NOT THE MISSING FEATURE. This CLI REJECTS an unknown flag
2006
+ * (`--bogus-flag` → *"Unknown option"*) and REFUSES `--json` on a verb that would ignore it. Against
2007
+ * that background, a flag accepted without complaint is a positive signal that it does something.
2008
+ *
2009
+ * ⚠⚠ AND THE FIX MUST NOT RE-CREATE THE CLASS ONE LAYER DOWN. Three ways to be silent again, all
2010
+ * refused here instead:
2011
+ * · `--limit abc` → `Number()` is `NaN`, `NaN || 20` falls back to 20 and the caller is ignored
2012
+ * exactly as before, now with a validation step that looks like diligence.
2013
+ * · `--limit 0` / `--limit -5` → asks for nothing; falling back to 20 answers a different question.
2014
+ * · `--limit 500` → the SERVER clamps to 200 regardless, so accepting it quietly hands back 200
2015
+ * rows to a caller who asked for 500 and has no way to tell. It is clamped here and SAID.
2016
+ *
2017
+ * PURE, so the decision is testable without a daemon: values in, `{limit}` or `{error}` out.
2018
+ */
2019
+ export function resolveSearchLimit(values) {
2020
+ const raw = values?.limit;
2021
+ if (raw === undefined || raw === null || raw === '')
2022
+ return { limit: SEARCH_LIMIT_DEFAULT };
2023
+ const n = Number(String(raw).trim());
2024
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
2025
+ return { error: `--limit must be a whole number of results, 1-${SEARCH_LIMIT_MAX} (got "${raw}"). Nothing was searched.` };
2026
+ }
2027
+ if (n > SEARCH_LIMIT_MAX) {
2028
+ return { limit: SEARCH_LIMIT_MAX, note: `--limit ${n} exceeds the server maximum — showing ${SEARCH_LIMIT_MAX}.` };
2029
+ }
2030
+ return { limit: n };
2031
+ }
2032
+ export async function cmdSearch(query, values) {
2033
+ const bound = resolveSearchLimit(values);
2034
+ if ('error' in bound) {
2035
+ console.error(` ${bound.error}`);
2036
+ process.exit(1);
2037
+ }
2038
+ if (bound.note)
2039
+ console.error(` ${bound.note}`);
2040
+ try {
2041
+ const result = await rpcOnce('search-notes', { query, limit: bound.limit }, getTimeout(values));
2042
+ if (values.json) {
2043
+ console.log(JSON.stringify(result, null, 2));
2044
+ return;
2045
+ }
2046
+ assertNoRpcError(result, 'search-notes');
2047
+ // Accept every shape this RPC has ever returned. It used to hand back a
2048
+ // bare ARRAY on the FTS branch (and `{results}` on the other two); the
2049
+ // array arrived here as `{"0":…,"1":…}` after the ack envelope spread it,
2050
+ // so neither `.results` nor `.notes` existed and search reported "No
2051
+ // results" for documents it had just matched. Server-side both are fixed,
2052
+ // but reading defensively costs nothing and keeps an older worker working.
2053
+ const notes = normalizeRows(result, ['results', 'notes', 'items']);
2054
+ if (notes.length === 0) {
2055
+ console.log(` No results for "${query}".`);
2056
+ return;
2057
+ }
2058
+ console.log('');
2059
+ for (const n of notes) {
2060
+ const ts = n.updatedAt ?? n.updated_at;
2061
+ const date = ts ? new Date(ts).toISOString().slice(0, 10) : '';
2062
+ /**
2063
+ * ⚠ THE KEY IS PRINTED SO A HIT CAN BE ACTED ON (CBH2-29). `search` used to print a title
2064
+ * and a date, and a title is not an address — two notes can share one, so chaining into
2065
+ * `cat` / `open` needed a second lookup the caller had no obvious way to perform. `CLR-42`
2066
+ * is what `cat ~/CLR-42.md`, `document-get` and the agent in chat all accept.
2067
+ * ⚠ An older worker returns no `key`; the column then simply does not print, rather than
2068
+ * showing `undefined` beside every row.
2069
+ */
2070
+ const key = typeof n.key === 'string' && n.key ? `\x1b[90m${n.key}\x1b[0m ` : '';
2071
+ console.log(` ${key}\x1b[36m${n.title || n.id}\x1b[0m \x1b[90m${date}\x1b[0m`);
2072
+ const preview = n.preview || n.snippet || n.matchSnippet;
2073
+ if (preview)
2074
+ console.log(` ${String(preview).replace(/<\/?b>/g, '').slice(0, 120)}`);
2075
+ }
2076
+ console.log(`\n ${notes.length} result${notes.length === 1 ? '' : 's'}${notes.length >= bound.limit ? ` (limit ${bound.limit} — raise it with --limit N)` : ''}\n`);
2077
+ }
2078
+ catch (err) {
2079
+ console.error(` Error: ${err.message}`);
2080
+ process.exit(1);
2081
+ }
2082
+ }
2083
+ export async function cmdAsk(question, values) {
2084
+ // `ask` is the one-shot face of the agent chat. On HOME it drives the SAME
2085
+ // `agent:message` entrypoint the web FAB/canvas uses — fire it over the relay
2086
+ // (CLI → daemon → DO) and collect the streamed `agent:*` events back, exactly
2087
+ // like a web client. On a BUILDING (Shopify store, etc.) it falls back to that
2088
+ // building's request/response chat action, since those live on a different DO.
2089
+ if (values.building && values.building !== 'home') {
2090
+ return askViaBuildingRpc(question, values);
2091
+ }
2092
+ return askViaAgentMessage(question, values);
2093
+ }
2094
+ /**
2095
+ * HOME `ask` — drive the unified `agent:message` entrypoint and collect the
2096
+ * streamed reply. This is the literal same entrypoint the browser uses; the only
2097
+ * difference is transport (daemon relay vs. the web socket). Flow:
2098
+ * client.send({type:'send', msg:{type:'agent:message', …}}) ← fire-and-forget
2099
+ * → daemon ipc.on('send') → relay.wsSend → DO agent:message handler
2100
+ * → DO _runAgentChat → broadcastToWeb({type:'agent:text'|'agent:done'|…})
2101
+ * → reaches the daemon session → ipc.broadcast → here via on('broadcast').
2102
+ * Correlation is by `sessionId` carried in the message body (broadcasts are not
2103
+ * requestId-correlated), so we filter on the id we minted.
2104
+ */
2105
+ async function askViaAgentMessage(question, values) {
2106
+ const { randomUUID } = await import('node:crypto');
2107
+ // --composition <id> targets a specific composition OR thread (the reply lands
2108
+ // in THAT conversation). Reuse it as the sessionId so the turn resolves to that
2109
+ // row and continues there — switch threads by passing a different id. Bare ask
2110
+ // → a fresh uuid (a new chat composition).
2111
+ const target = typeof values.composition === 'string' && values.composition.trim()
2112
+ ? values.composition.trim()
2113
+ : '';
2114
+ const sessionId = target || randomUUID();
2115
+ // --model is an OPTIONAL explicit override; omit it by default. The model is
2116
+ // an attribute of the agent IDENTITY — a bare `ask` (no --model) carries no
2117
+ // model field, so the DO resolves it from --agent's identity or the workspace
2118
+ // default 'omni' agent (which is the Gemini responder), rather than the CLI
2119
+ // hardcoding a model. Pass --model only to force a specific brain.
2120
+ const model = typeof values.model === 'string' && values.model.trim() ? values.model.trim() : '';
2121
+ // --agent <id|slug> targets a specific RESPONDER agent (one created in the
2122
+ // AgentsModal). The DO's _resolveResponder turns it into that agent's OWN
2123
+ // configured model + persona + systemPrompt, which win over --model. This
2124
+ // mirrors what the web FAB/canvas sends (agent:message.agentId); without it
2125
+ // the turn only honors the generic --model pick (responder=null).
2126
+ const agentId = typeof values.agent === 'string' && values.agent.trim() ? values.agent.trim() : '';
2127
+ const timeout = Math.max(getTimeout(values), 120_000);
2128
+ let client;
2129
+ try {
2130
+ client = await connectClient({ timeout });
2131
+ }
2132
+ catch (err) {
2133
+ console.error(` Error: ${err.message}`);
2134
+ console.error(' Is the daemon running? Open the Hive Mac app, or run: beehaven status');
2135
+ process.exit(1);
2136
+ }
2137
+ let answer;
2138
+ try {
2139
+ answer = await new Promise((resolve, reject) => {
2140
+ let acc = '';
2141
+ let settled = false;
2142
+ const finish = (fn) => {
2143
+ if (settled)
2144
+ return;
2145
+ settled = true;
2146
+ clearTimeout(timer);
2147
+ fn();
2148
+ };
2149
+ let timer = setTimeout(() => finish(() => reject(new Error(`No agent response after ${Math.round(timeout / 1000)}s`))), timeout);
2150
+ /**
2151
+ * ⚠⚠ A STREAM THAT NEVER STARTS MUST FAIL FAST — otherwise the fallback below costs the
2152
+ * WHOLE timeout before it runs. Measured: with the fallback but no first-frame deadline,
2153
+ * `ask` answered correctly and took **181 seconds** to do it, because it sat out the full
2154
+ * 180s broadcast wait first. The answer was already available in one.
2155
+ *
2156
+ * ⚠ A STARTED STREAM KEEPS THE FULL BUDGET. The deadline is armed only until the FIRST
2157
+ * `agent:*` frame; once the DO is talking, a long tool-heavy turn must not be cut off. That
2158
+ * is why this re-arms `timer` rather than adding a second competing one.
2159
+ */
2160
+ const FIRST_FRAME_MS = 20_000;
2161
+ let started = false;
2162
+ const noteFirstFrame = () => {
2163
+ if (started)
2164
+ return;
2165
+ started = true;
2166
+ clearTimeout(timer);
2167
+ timer = setTimeout(() => finish(() => reject(new Error(`No agent response after ${Math.round(timeout / 1000)}s`))), timeout);
2168
+ };
2169
+ clearTimeout(timer);
2170
+ timer = setTimeout(() => finish(() => reject(new Error(`the agent stream never opened (${FIRST_FRAME_MS / 1000}s)`))), FIRST_FRAME_MS);
2171
+ // Subscribe + listen BEFORE sending so we never miss the first event.
2172
+ // `error` is included so a gate rejection (e.g. a worker not yet redeployed
2173
+ // with the agent:message daemon allowance) fails fast instead of hanging.
2174
+ client.subscribe(['agent:*', 'error']);
2175
+ client.on('broadcast', (msg) => {
2176
+ if (!msg)
2177
+ return;
2178
+ // Gate rejection comes back as a type:'error' frame with no sessionId.
2179
+ if (msg.type === 'error' &&
2180
+ /unauthorized message type|daemon_unauthorized|unknown action/i.test(String(msg.message ?? ''))) {
2181
+ finish(() => reject(new Error(String(msg.message))));
2182
+ return;
2183
+ }
2184
+ if (msg.sessionId !== sessionId)
2185
+ return; // not our turn
2186
+ /**
2187
+ * ⚠⚠ AFTER THE SESSION FILTER, NOT BEFORE. The subscription is `agent:*` — every turn in
2188
+ * the workspace arrives here, including other clients' — so counting ANY frame as "our
2189
+ * stream opened" re-armed the full 180s budget off somebody else's traffic. Measured: the
2190
+ * deadline never fired and `ask` still took 182s.
2191
+ */
2192
+ noteFirstFrame();
2193
+ switch (msg.type) {
2194
+ case 'agent:text':
2195
+ // The server loop emits the full text once with done:true; keep the
2196
+ // latest so we have a fallback if agent:done carries no finalText.
2197
+ if (typeof msg.text === 'string')
2198
+ acc = msg.text;
2199
+ break;
2200
+ case 'agent:done':
2201
+ finish(() => resolve(typeof msg.finalText === 'string' && msg.finalText ? msg.finalText : acc));
2202
+ break;
2203
+ case 'agent:error':
2204
+ finish(() => reject(new Error(msg.errorMessage || msg.error || 'agent error')));
2205
+ break;
2206
+ }
2207
+ });
2208
+ client.on('close', () => finish(() => reject(new Error('Daemon connection lost'))));
2209
+ client.send({
2210
+ type: 'send',
2211
+ msg: {
2212
+ type: 'agent:message',
2213
+ prompt: question,
2214
+ ...(model ? { model } : {}), // omitted unless --model — identity drives it
2215
+ sessionId,
2216
+ ...(target ? { compositionId: target } : {}),
2217
+ ...(agentId ? { agentId } : {}),
2218
+ source: 'cli-ask',
2219
+ },
2220
+ });
2221
+ process.stderr.write('\x1b[2m …thinking\x1b[0m\n');
2222
+ });
2223
+ }
2224
+ catch (err) {
2225
+ try {
2226
+ client.disconnect();
2227
+ }
2228
+ catch { }
2229
+ /**
2230
+ * ⚠⚠ THE BROADCAST PATH CANNOT SERVE A HEADLESS CALLER, AND `agent-prompt` SAYS SO IN ITS OWN
2231
+ * RESULT: *"Pass wait:true to await the turn and get the reply inline — REQUIRED for a headless
2232
+ * caller, whose connection would otherwise close and let the DO hibernate mid-turn."*
2233
+ *
2234
+ * MEASURED 2026-09-04: `beehaven ask "Reply with the single word OK"` printed `…thinking` and
2235
+ * then **"No agent response after 180s"**, three times. The same question through
2236
+ * `agent-prompt { wait: true }` answered `"reply": "OK"` in ONE SECOND. The turn was never the
2237
+ * problem — the CLI was listening on a socket the DO had stopped talking to.
2238
+ *
2239
+ * ⚠ THE STREAM IS TRIED FIRST AND KEPT. When a live socket DOES stay up (the Mac app is
2240
+ * driving the same session) the broadcast gives incremental text, which is the nicer surface.
2241
+ * This is a FALLBACK on the failure, not a replacement — so nothing that works today changes.
2242
+ *
2243
+ * ⚠ ONE BILLED TURN, NOT TWO. It only runs when the stream produced NOTHING; a turn that
2244
+ * answered and then failed to close would not reach here, because `finish()` resolves on the
2245
+ * first `agent:done`.
2246
+ */
2247
+ try {
2248
+ const res = await rpcOnce('agent-prompt', {
2249
+ message: question, wait: true, sessionId,
2250
+ ...(target ? { compositionId: target } : {}),
2251
+ ...(agentId ? { agentId } : {}),
2252
+ ...(model ? { model } : {}),
2253
+ }, timeout);
2254
+ const inline = String(res?.reply ?? res?.text ?? '').trim();
2255
+ if (inline) {
2256
+ if (values.json) {
2257
+ console.log(JSON.stringify({ response: inline, sessionId }, null, 2));
2258
+ return;
2259
+ }
2260
+ console.log('');
2261
+ console.log(inline);
2262
+ console.log('');
2263
+ return;
2264
+ }
2265
+ const failure = rpcFailure(res);
2266
+ console.error(` Error: ${failure ?? err.message}`);
2267
+ }
2268
+ catch (e2) {
2269
+ console.error(` Error: ${err.message}`);
2270
+ if (e2?.message && e2.message !== err.message)
2271
+ console.error(` (fallback also failed: ${e2.message})`);
2272
+ }
2273
+ process.exit(1);
2274
+ }
2275
+ try {
2276
+ client.disconnect();
2277
+ }
2278
+ catch { }
2279
+ if (values.json) {
2280
+ console.log(JSON.stringify({ response: answer, sessionId }, null, 2));
2281
+ return;
2282
+ }
2283
+ console.log('');
2284
+ console.log(answer);
2285
+ console.log('');
2286
+ }
2287
+ /**
2288
+ * BUILDING `ask` — request/response chat against a non-home DO (Shopify store,
2289
+ * etc.). Tries that building's known chat action names in order; the first one
2290
+ * that isn't "Unknown action" wins.
2291
+ */
2292
+ async function askViaBuildingRpc(question, values) {
2293
+ const chatActions = ['chat-message', 'chat', 'agent-chat', 'ask'];
2294
+ const timeout = Math.max(getTimeout(values), 60_000);
2295
+ for (const chatAction of chatActions) {
2296
+ try {
2297
+ const resolved = resolveBuilding(chatAction, { message: question }, values.building);
2298
+ const raw = await rpcOnce(resolved.action, resolved.input, timeout);
2299
+ // Unwrap proxy envelope
2300
+ const result = raw?.type === 'ack' && raw?.result !== undefined
2301
+ ? raw.result
2302
+ : raw?.result !== undefined && values.building
2303
+ ? raw.result
2304
+ : raw;
2305
+ // If the action doesn't exist, try the next one
2306
+ if (raw?.type === 'error' && raw?.message?.includes('Unknown action'))
2307
+ continue;
2308
+ if (raw?.type === 'error') {
2309
+ console.error(` Error: ${raw.message ?? 'Action failed'}`);
2310
+ process.exit(1);
2311
+ }
2312
+ if (values.json) {
2313
+ console.log(JSON.stringify(result, null, 2));
2314
+ return;
2315
+ }
2316
+ const response = result?.response || result?.reply || result?.text || result?.content || result?.message;
2317
+ if (response) {
2318
+ console.log('');
2319
+ console.log(response);
2320
+ console.log('');
2321
+ }
2322
+ else
2323
+ console.log(JSON.stringify(result, null, 2));
2324
+ return;
2325
+ }
2326
+ catch (err) {
2327
+ // Unknown action errors → try next. Other errors → fail.
2328
+ if (err.message?.includes('Unknown action'))
2329
+ continue;
2330
+ console.error(` Error: ${err.message}`);
2331
+ process.exit(1);
2332
+ }
2333
+ }
2334
+ console.error(' This building does not have a chat action.');
2335
+ console.error(' Available action names tried: ' + chatActions.join(', '));
2336
+ process.exit(1);
2337
+ }
2338
+ export async function cmdTask(title, values) {
2339
+ try {
2340
+ const result = await rpcOnce('note-create', {
2341
+ title,
2342
+ fileType: 'board',
2343
+ status: values.status || 'active',
2344
+ source: 'beehaven-cli',
2345
+ }, getTimeout(values));
2346
+ if (values.json) {
2347
+ console.log(JSON.stringify(result, null, 2));
2348
+ return;
2349
+ }
2350
+ /**
2351
+ * ⚠⚠ A REFUSAL MUST NOT PRINT "Created task" (CBH2-4). `rpcOnce` resolves for an ordinary
2352
+ * `{ ok:false, error }` payload — the transport succeeded — so without this an invalid status
2353
+ * would be announced as a successful create, which is the same lie in a new place.
2354
+ */
2355
+ if (result && result.ok === false) {
2356
+ console.error(` Error: ${result.error ?? 'note-create refused the task'}`);
2357
+ process.exit(1);
2358
+ }
2359
+ /**
2360
+ * ⚠⚠ PRINT WHAT THE SERVER STORED, NEVER WHAT WE ASKED FOR (CBH2-4).
2361
+ *
2362
+ * This line was `${values.status || 'active'}` — the CLI's own argument, echoed back — while
2363
+ * `note-create` hardcoded `'active'` on the row. So `--status shipped` printed
2364
+ * *"Created task: … (shipped)"* over a row storing `active`, and `--status wibble` printed
2365
+ * *(wibble)* over the same. **The receipt confirmed a write that had not happened**, which is
2366
+ * the one shape of this bug a caller cannot detect from the terminal.
2367
+ *
2368
+ * ⚠ FALL BACK ONLY TO `'active'` — the server's own default — never to `values.status`. An
2369
+ * older worker returns no `status` field, and re-echoing the request there would quietly
2370
+ * restore the exact defect for anyone who had not upgraded.
2371
+ */
2372
+ const stored = typeof result?.status === 'string' && result.status
2373
+ ? result.status
2374
+ : 'active';
2375
+ console.log(` Created task: ${title} (${stored})`);
2376
+ if (result.id)
2377
+ console.log(` ID: ${result.id}`);
2378
+ else if (result.noteId)
2379
+ console.log(` ID: ${result.noteId}`);
2380
+ }
2381
+ catch (err) {
2382
+ console.error(` Error: ${err.message}`);
2383
+ process.exit(1);
2384
+ }
2385
+ }
2386
+ // ── beehaven doc add <file> — local file → searchable project document ───
2387
+ //
2388
+ // One-shot: reads a local file, mints a note (note-create) + seeds its body
2389
+ // (note-content-seed). Content lands in the workspace SQLite note-content
2390
+ // table + FTS5 index, so `search-notes` and the cloud agent can retrieve it.
2391
+ // This is the DOCUMENT path; `teleport` is the raw-blob (R2) path. Markdown is
2392
+ // stored verbatim; code is wrapped in a fenced block (with the language) so it
2393
+ // renders and stays full-text searchable.
2394
+ const DOC_LANG_BY_EXT = {
2395
+ ts: 'ts',
2396
+ tsx: 'tsx',
2397
+ js: 'js',
2398
+ jsx: 'jsx',
2399
+ py: 'python',
2400
+ rs: 'rust',
2401
+ go: 'go',
2402
+ java: 'java',
2403
+ rb: 'ruby',
2404
+ sh: 'bash',
2405
+ json: 'json',
2406
+ yaml: 'yaml',
2407
+ yml: 'yaml',
2408
+ toml: 'toml',
2409
+ sql: 'sql',
2410
+ css: 'css',
2411
+ html: 'html',
2412
+ c: 'c',
2413
+ cpp: 'cpp',
2414
+ h: 'c',
2415
+ swift: 'swift',
2416
+ kt: 'kotlin',
2417
+ php: 'php',
2418
+ };
2419
+ const DOC_MAX_BYTES = 1024 * 1024; // 1MB single-document guideline
2420
+ export async function cmdDocAdd(filePath, values) {
2421
+ if (!filePath) {
2422
+ console.error(' Usage: beehaven doc add <file> [--project <id>] [--title <t>]');
2423
+ process.exit(1);
2424
+ }
2425
+ const { readFileSync, statSync } = await import('fs');
2426
+ const { basename, extname, resolve } = await import('path');
2427
+ const abs = resolve(filePath);
2428
+ let stat;
2429
+ try {
2430
+ stat = statSync(abs);
2431
+ }
2432
+ catch {
2433
+ console.error(` File not found: ${abs}`);
2434
+ process.exit(1);
2435
+ }
2436
+ if (stat.size > DOC_MAX_BYTES) {
2437
+ console.error(` ${basename(abs)} is ${(stat.size / 1024).toFixed(0)}KB — over the ${DOC_MAX_BYTES / 1024}KB single-document guideline.`);
2438
+ console.error(' Large/whole-folder content belongs in the doc-indexer (chunked, agent memory) or in R2 via `beehaven teleport`.');
2439
+ process.exit(1);
2440
+ }
2441
+ const raw = readFileSync(abs, 'utf8');
2442
+ const fileName = basename(abs);
2443
+ const ext = extname(fileName).slice(1).toLowerCase();
2444
+ const isMarkdown = ext === 'md' || ext === 'markdown' || ext === 'txt' || ext === '';
2445
+ const lang = DOC_LANG_BY_EXT[ext];
2446
+ const data = isMarkdown ? raw : '```' + (lang || ext || '') + '\n' + raw + '\n```\n';
2447
+ const title = values.title ||
2448
+ (isMarkdown ? raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || fileName : fileName);
2449
+ try {
2450
+ const created = await rpcOnce('note-create', {
2451
+ noteData: { projectId: values.project || undefined, title },
2452
+ fileType: 'document',
2453
+ }, getTimeout(values));
2454
+ const noteId = created?.noteId || created?.id;
2455
+ if (!noteId) {
2456
+ console.error(` note-create failed: ${JSON.stringify(created)}`);
2457
+ process.exit(1);
2458
+ }
2459
+ await rpcOnce('note-content-seed', { noteId, data, title }, getTimeout(values));
2460
+ if (values.json) {
2461
+ console.log(JSON.stringify({
2462
+ noteId,
2463
+ title,
2464
+ fileName,
2465
+ project: values.project || null,
2466
+ bytes: data.length,
2467
+ kind: isMarkdown ? 'markdown' : 'code',
2468
+ }, null, 2));
2469
+ return;
2470
+ }
2471
+ console.log(` Added ${isMarkdown ? 'document' : 'code document'}: ${title}`);
2472
+ console.log(` noteId: ${noteId}${values.project ? ` project: ${values.project}` : ''}`);
2473
+ }
2474
+ catch (err) {
2475
+ console.error(` Error: ${err.message}`);
2476
+ process.exit(1);
2477
+ }
2478
+ }
2479
+ // ── beehaven teleport <file> ─────────────────────────────────────────────
2480
+ /**
2481
+ * Threshold above which we switch from inline base64 upload to a direct
2482
+ * R2 PUT via a presigned URL. The relay WebSocket has a per-message size
2483
+ * cap (~1MB in practice); base64-encoded payloads larger than that
2484
+ * crash the WS and take the daemon's relay connection down with them.
2485
+ *
2486
+ * Below threshold: one `file-teleport-inline` RPC (base64 over WS).
2487
+ * At/above: `get-upload-url` → PUT binary to R2 → `file-uploaded` to
2488
+ * record the row. Binary never touches the relay WS.
2489
+ */
2490
+ const INLINE_MAX_BYTES = 900 * 1024; // 900KB — safety margin under the WS cap
2491
+ /**
2492
+ * Formats worth projecting into a searchable document after an upload.
2493
+ *
2494
+ * ⚠ KEPT IN STEP WITH `DOCUMENTABLE` IN `components/gallery/GalleryGrid.tsx` — the browser's
2495
+ * drag-drop applies the same test, and a file dropped on the grid and the same file teleported
2496
+ * from a terminal must end up in the same state. Images, audio, video and archives are
2497
+ * deliberately excluded: turning a PNG into a "document" fills search with rows that have no text.
2498
+ */
2499
+ const DOCUMENTABLE_EXT = /\.(pdf|md|markdown|txt|rtf|csv|tsv|json|ya?ml|html?|xml|tex|ts|tsx|js|jsx|py|rb|go|rs|java|kt|swift|c|h|cpp|cs|php|sql|sh|toml|ini|log)$/i;
2500
+ /**
2501
+ * ⚠⚠ THE UPLOAD IS NOT FINISHED WHEN THE BYTES LAND — THIS IS THE STEP THAT MAKES IT FINDABLE.
2502
+ *
2503
+ * `document-search` is FTS5 over the note BODY, so a file that never becomes a document is an
2504
+ * opaque blob: stored, listed in `/recents`, and invisible to every search the person or the agent
2505
+ * will actually run. The browser's grid drop has called `document-from-file` since it was
2506
+ * re-plumbed; `beehaven teleport` never did, on either branch. Same gesture, two outcomes.
2507
+ *
2508
+ * ⚠ A CONVERSION FAILURE IS NOT AN UPLOAD FAILURE. The bytes are in R2 and the `files` row exists;
2509
+ * only the searchable projection is missing. Reporting it as a failed upload would send the caller
2510
+ * to re-upload a file that is already there. Warn, and say what is missing.
2511
+ *
2512
+ * ⚠ PDFs ARE EXPECTED TO PRODUCE A STUB. The worker extracts text server-side for text-y formats
2513
+ * but mints a viewer stub for PDF — the real extraction runs client-side in the notes editor, which
2514
+ * an agent has no equivalent of. So a teleported PDF is stored and openable but NOT searchable, and
2515
+ * that is a known gap (server-side PDF extraction belongs in the container), not a bug in this call.
2516
+ */
2517
+ async function projectUploadedFileAsDocument(fileId, fileName, values) {
2518
+ if (!fileId || !DOCUMENTABLE_EXT.test(fileName))
2519
+ return;
2520
+ if (values['no-document'])
2521
+ return;
2522
+ try {
2523
+ const res = await rpcOnce('document-from-file', { fileId, title: fileName }, getTimeout(values));
2524
+ const out = res?.type === 'ack' && res?.result !== undefined ? res.result : res;
2525
+ if (out?.error) {
2526
+ console.log(` (stored, but not searchable: ${out.error})`);
2527
+ return;
2528
+ }
2529
+ const key = out?.key ? ` ${out.key}` : '';
2530
+ console.log(` Indexed for search as a document${key}`);
2531
+ }
2532
+ catch (err) {
2533
+ console.log(` (stored, but not searchable: ${err?.message || 'document-from-file failed'})`);
2534
+ }
2535
+ }
2536
+ /**
2537
+ * ⚠⚠ PRINT THE FILE ID — THE FOURTH THING `beehaven help teleport` PROMISES AND THE ONLY ONE
2538
+ * THAT DID NOT HAPPEN (CBH2-38).
2539
+ *
2540
+ * That page lists what teleport does: put the bytes in R2 · insert the `files` row · index it for
2541
+ * search · **hand back the file id**. The output was a VFS path and nothing else, which broke the
2542
+ * two-step printed verbatim in `beehaven help sheet`:
2543
+ *
2544
+ * beehaven teleport ./data.csv && beehaven sheet import <fileId>
2545
+ *
2546
+ * — there was no `<fileId>` to substitute. Every obvious substitute is refused (`sheet import
2547
+ * /uploads/x.csv` → "No file with id /uploads/x.csv"), so the only route was
2548
+ * `beehaven sql "SELECT id FROM files WHERE name LIKE …"` — dropping out of the CLI surface
2549
+ * entirely, to the escape hatch `beehaven --help` calls *"the ESCAPE HATCH, not the workflow"*.
2550
+ *
2551
+ * ⚠ ONE HELPER, BOTH UPLOAD PATHS. The inline and presigned branches print their own success line
2552
+ * and the id was available in both; a fix in one is a fix for small files only, and which branch
2553
+ * runs depends on a size threshold the caller never sees.
2554
+ *
2555
+ * ⚠⚠ THE `next:` LINE IS ONLY PRINTED FOR A FILE `sheet import` CAN ACTUALLY TAKE. Suggesting it
2556
+ * after teleporting a PNG would be a second broken two-step — this whole ticket is about a
2557
+ * documented chain that does not complete, and answering it with another one that does not
2558
+ * complete is worse than printing the id alone. `beehaven open <fileId>` is deliberately NOT
2559
+ * suggested either: `open` has no route for a file (see `pathForKind`), so it would refuse.
2560
+ *
2561
+ * ⚠ SILENT WHEN THERE IS NO ID — a composition asset has no `files` row, and a server that
2562
+ * returned none must not be papered over with `undefined`.
2563
+ */
2564
+ const SHEET_IMPORTABLE = /\.(csv|tsv)$/i;
2565
+ function printTeleportedFileId(fileId, compositionId, fileName) {
2566
+ const id = typeof fileId === 'string' ? fileId.trim() : '';
2567
+ if (!id)
2568
+ return;
2569
+ console.log(` id: ${id}`);
2570
+ if (!compositionId && fileName && SHEET_IMPORTABLE.test(fileName)) {
2571
+ console.log(` next: beehaven sheet import ${id} --title "${fileName.replace(/\.[^.]+$/, '')}"`);
2572
+ }
2573
+ }
2574
+ export async function cmdTeleport(filePath, values) {
2575
+ const { readFileSync, statSync } = await import('fs');
2576
+ const { basename, extname, resolve } = await import('path');
2577
+ const absPath = resolve(filePath);
2578
+ try {
2579
+ statSync(absPath);
2580
+ }
2581
+ catch {
2582
+ console.error(` File not found: ${absPath}`);
2583
+ process.exit(1);
2584
+ }
2585
+ const fileName = basename(absPath);
2586
+ const ext = extname(fileName).slice(1).toLowerCase();
2587
+ const mimeMap = {
2588
+ txt: 'text/plain',
2589
+ md: 'text/markdown',
2590
+ json: 'application/json',
2591
+ js: 'text/javascript',
2592
+ ts: 'text/typescript',
2593
+ html: 'text/html',
2594
+ css: 'text/css',
2595
+ csv: 'text/csv',
2596
+ xml: 'application/xml',
2597
+ png: 'image/png',
2598
+ jpg: 'image/jpeg',
2599
+ jpeg: 'image/jpeg',
2600
+ gif: 'image/gif',
2601
+ svg: 'image/svg+xml',
2602
+ webp: 'image/webp',
2603
+ mp3: 'audio/mpeg',
2604
+ mp4: 'video/mp4',
2605
+ wav: 'audio/wav',
2606
+ pdf: 'application/pdf',
2607
+ zip: 'application/zip',
2608
+ // Mobile builds — when teleported onto a composition these go
2609
+ // through the mobile-install block path (see wantMobileInstall
2610
+ // branch below) instead of the generic file upload.
2611
+ ipa: 'application/octet-stream', // Apple iOS app archive
2612
+ apk: 'application/vnd.android.package-archive', // Android package
2613
+ aab: 'application/octet-stream', // Android App Bundle — rejected by finalize
2614
+ };
2615
+ const contentType = mimeMap[ext] || 'application/octet-stream';
2616
+ // `--composition <id>` mode: store the file under the composition's R2
2617
+ // prefix and register it with FileManager instead of the app-level `files`
2618
+ // table. Two effects:
2619
+ // 1. `beehaven call composition-fs '{"compositionId":"…","action":"list"}'`
2620
+ // can actually see its own internal files.
2621
+ // 2. The asset no longer pollutes /recents with a workspace-level row
2622
+ // per file — the composition itself is the single entity.
2623
+ // Without this flag, we keep the original behavior (workspace-level asset
2624
+ // visible in /recents and the Files UI).
2625
+ const compositionId = values.composition;
2626
+ const rawTo = values.to;
2627
+ // Path-traversal guard for the user-supplied --to flag. The server's
2628
+ // FileManager also validates this (see packages/core/src/server/FileManager
2629
+ // canonical actions — they reject '..' and leading '/'), but we should fail
2630
+ // fast at the CLI so the error message names the actual flag the user
2631
+ // typed instead of a cryptic server-side "Invalid key".
2632
+ if (rawTo && (rawTo.includes('..') || rawTo.startsWith('/') || rawTo.includes('\0'))) {
2633
+ console.error(` Invalid --to path: ${rawTo}`);
2634
+ console.error(` The --to flag can't contain '..', absolute paths, or null bytes.`);
2635
+ process.exit(1);
2636
+ }
2637
+ const destDir = compositionId
2638
+ ? `compositions/${compositionId}${rawTo ? `/${String(rawTo).replace(/^\/|\/$/g, '')}` : ''}`
2639
+ : rawTo || 'uploads';
2640
+ const key = `${destDir.replace(/^\/|\/$/g, '')}/${fileName}`;
2641
+ const content = readFileSync(absPath);
2642
+ // Overall cap — R2 presigned PUT handles much more, but we stop here to
2643
+ // avoid accidentally uploading gigabyte-sized files.
2644
+ const MAX_BYTES = 100 * 1024 * 1024; // 100MB
2645
+ if (content.length > MAX_BYTES) {
2646
+ console.error(` File too large for teleport (${(content.length / 1024 / 1024).toFixed(1)}MB, max ${MAX_BYTES / 1024 / 1024}MB)`);
2647
+ process.exit(1);
2648
+ }
2649
+ const sizeKB = (content.length / 1024).toFixed(1);
2650
+ // Precheck: --composition requires FileManager + composition-fs wired on
2651
+ // the target DO. If those aren't there, the upload will eventually fail
2652
+ // with "Unknown action: composition-fs" or a silent no-op. We surface a
2653
+ // clear error before burning the upload instead. The precheck itself is
2654
+ // best-effort — if `list-actions` is unavailable we proceed and let the
2655
+ // teleport surface the real error (keeps us from hard-failing on DOs that
2656
+ // disable action listing by design).
2657
+ if (compositionId) {
2658
+ let actions = [];
2659
+ try {
2660
+ const listRaw = await rpcOnce('list-actions', {}, getTimeout(values));
2661
+ const list = listRaw?.type === 'ack' && listRaw?.result !== undefined ? listRaw.result : listRaw;
2662
+ actions = list?.actions ?? [];
2663
+ }
2664
+ catch {
2665
+ // list-actions RPC failed entirely — nothing to precheck against.
2666
+ // Fall through; the teleport will surface the real error if the DO
2667
+ // truly lacks composition-fs.
2668
+ }
2669
+ // Only block if we got a non-empty list that DOESN'T contain composition-fs.
2670
+ // An empty list is ambiguous (DO may disable action listing) — don't hard-fail on it.
2671
+ // The process.exit MUST be outside the try/catch above, or the catch would swallow it.
2672
+ if (actions.length > 0 && !actions.some((a) => a.name === 'composition-fs')) {
2673
+ console.error(` This DO does not support compositions (no 'composition-fs' action registered).`);
2674
+ console.error(` Retry without --composition, or target a DO where FileManager + composition-fs are wired.`);
2675
+ process.exit(1);
2676
+ }
2677
+ }
2678
+ // ── Mobile-install branch: `.ipa` / `.apk` onto a composition turn
2679
+ // into a `mobile-install` block (icon + version + install QR).
2680
+ //
2681
+ // Flow: upload binary via presigned URL → call mobile-build-finalize
2682
+ // with caller-supplied metadata (--bundle-id, --version, etc) →
2683
+ // call canvas-add-mobile-install to drop the block on the canvas.
2684
+ //
2685
+ // Auto-trigger: any .ipa / .apk with --composition set. Override
2686
+ // with `--as attachment` to keep the generic opaque-file behaviour.
2687
+ //
2688
+ // Metadata parsing TODO: today the dev supplies bundle-id/version
2689
+ // via flags (or accepts defaults). A future iteration should parse
2690
+ // Info.plist (binary plist) / AndroidManifest.xml (binary AXML)
2691
+ // client-side here so the metadata is auto-populated. Until then,
2692
+ // the block UI is editable so the dev can fill in gaps.
2693
+ const ipaApk = ext === 'ipa' ? 'ios' : ext === 'apk' ? 'android' : null;
2694
+ const explicitAs = String(values.as || '').toLowerCase();
2695
+ const wantMobileInstall = !!compositionId && !!ipaApk && explicitAs !== 'attachment';
2696
+ if (wantMobileInstall) {
2697
+ if (ext === 'aab') {
2698
+ // Belt-and-braces: finalize will also reject, but failing here
2699
+ // saves the round-trip + upload bytes.
2700
+ console.error(` .aab can only be installed via Play Store. Upload an .apk for direct sideload.`);
2701
+ process.exit(1);
2702
+ }
2703
+ // 1) Upload the binary via presigned URL (mobile builds are
2704
+ // always >900KB so the inline path is never used).
2705
+ console.log(` Uploading ${fileName} (${sizeKB}KB) → R2…`);
2706
+ const intent = await rpcOnce('get-upload-url', {
2707
+ key,
2708
+ contentType,
2709
+ maxSize: content.length,
2710
+ }, getTimeout(values));
2711
+ if (!intent?.uploadUrl) {
2712
+ console.error(` Failed to get upload URL: ${intent?.error || 'no uploadUrl in response'}`);
2713
+ process.exit(1);
2714
+ }
2715
+ let uploadUrl = intent.uploadUrl;
2716
+ if (uploadUrl.startsWith('/')) {
2717
+ // Mirror the existing large-file path (line ~1115) — read
2718
+ // `endpoint` (not `relayHost`!) from ~/.beehaven/config.json
2719
+ // and translate wss:// → https://. The `endpoint` field
2720
+ // honours `beehaven env staging` etc., which means PUTs go
2721
+ // to the right host instead of always hitting prod.
2722
+ const { readFileSync: rfs, existsSync: exf } = await import('fs');
2723
+ const { join: jn } = await import('path');
2724
+ const { homedir: hd } = await import('os');
2725
+ let relayHost = 'https://relay.clearly.sh';
2726
+ try {
2727
+ const cfgPath = jn(hd(), '.beehaven', 'config.json');
2728
+ if (exf(cfgPath)) {
2729
+ const cfg = JSON.parse(rfs(cfgPath, 'utf-8'));
2730
+ if (cfg.endpoint) {
2731
+ relayHost = String(cfg.endpoint)
2732
+ .replace(/^wss:/, 'https:')
2733
+ .replace(/^ws:/, 'http:')
2734
+ .replace(/\/$/, '');
2735
+ }
2736
+ }
2737
+ }
2738
+ catch {
2739
+ /* fall through with default */
2740
+ }
2741
+ uploadUrl = relayHost + uploadUrl;
2742
+ }
2743
+ const putRes = await fetch(uploadUrl, {
2744
+ method: 'PUT',
2745
+ body: content,
2746
+ headers: { 'Content-Type': contentType },
2747
+ });
2748
+ if (!putRes.ok) {
2749
+ const body = await putRes.text().catch(() => '');
2750
+ console.error(` R2 PUT failed: HTTP ${putRes.status}`);
2751
+ console.error(` url: ${uploadUrl.slice(0, 160)}…`);
2752
+ console.error(` body: ${body.slice(0, 240)}`);
2753
+ process.exit(1);
2754
+ }
2755
+ // 2) Finalize: register the build row + (for iOS) generate the
2756
+ // install-manifest plist.
2757
+ //
2758
+ // Metadata sourcing — precedence: CLI flag → parsed from binary
2759
+ // → filename-based default. The parser handles XML and binary
2760
+ // Info.plist for iOS; for Android it currently no-ops and the
2761
+ // dev fills metadata via flags.
2762
+ const { parseMobileBinary } = await import('../lib/mobile-build-parser.js');
2763
+ const parsed = parseMobileBinary(absPath);
2764
+ if (parsed.ok === false) {
2765
+ // Inside this branch parsed is ParseFailure — TS narrows on
2766
+ // the discriminated union via the boolean comparison.
2767
+ console.log(` (couldn't auto-parse metadata: ${parsed.reason}${parsed.detail ? ' — ' + parsed.detail : ''})`);
2768
+ }
2769
+ const platform = ipaApk;
2770
+ const displayName = values['display-name'] ||
2771
+ (parsed.ok ? parsed.displayName : undefined) ||
2772
+ fileName.replace(/\.(ipa|apk|aab)$/i, '');
2773
+ const bundleId = values['bundle-id'] ||
2774
+ (parsed.ok ? parsed.bundleId : undefined) ||
2775
+ `com.unknown.${fileName
2776
+ .replace(/[^a-z0-9]/gi, '')
2777
+ .toLowerCase()
2778
+ .slice(0, 16) || 'build'}`;
2779
+ // `--app-version` is the mobile-build version string. `--version`
2780
+ // is the global boolean (`beehaven --version`) — they DON'T collide
2781
+ // because we read app-version explicitly here.
2782
+ const version = values['app-version'] || (parsed.ok ? parsed.version : undefined) || '0.0.0';
2783
+ const buildNumber = values.build || (parsed.ok ? parsed.buildNumber : undefined) || '1';
2784
+ const minOsVersion = parsed.ok ? parsed.minOsVersion : undefined;
2785
+ const signingMethod = values.signing || (platform === 'ios' ? 'adhoc' : 'release');
2786
+ const udidsRaw = values.udids;
2787
+ const adHocUdids = udidsRaw
2788
+ ? String(udidsRaw)
2789
+ .split(',')
2790
+ .map((s) => s.trim())
2791
+ .filter(Boolean)
2792
+ : [];
2793
+ const finalize = await rpcOnce('mobile-build-finalize', {
2794
+ platform,
2795
+ binaryKey: key,
2796
+ displayName,
2797
+ bundleId,
2798
+ version,
2799
+ buildNumber,
2800
+ ...(minOsVersion ? { minOsVersion } : {}),
2801
+ signingMethod,
2802
+ ...(adHocUdids.length ? { adHocUdids } : {}),
2803
+ sizeBytes: content.length,
2804
+ }, getTimeout(values));
2805
+ if (!finalize?.ok) {
2806
+ console.error(` mobile-build-finalize failed: ${finalize?.error || finalize?.message || JSON.stringify(finalize)}`);
2807
+ process.exit(1);
2808
+ }
2809
+ // 3) Add the block to the canvas.
2810
+ const block = await rpcOnce('canvas-add-mobile-install', {
2811
+ compositionId,
2812
+ buildId: finalize.buildId,
2813
+ }, getTimeout(values));
2814
+ if (!block?.ok) {
2815
+ console.error(` canvas-add-mobile-install failed: ${block?.error || JSON.stringify(block)}`);
2816
+ process.exit(1);
2817
+ }
2818
+ console.log(` ${fileName} → ${platform} build ${finalize.buildId}`);
2819
+ console.log(` bundle: ${bundleId} version: ${version} (${buildNumber})`);
2820
+ console.log(` block: ${block.blockId} on composition ${compositionId}`);
2821
+ if (platform === 'ios' && !adHocUdids.length) {
2822
+ console.log(` Hint: pass --udids <UDID1,UDID2> so testers' devices appear in the block's allow-list.`);
2823
+ }
2824
+ // Only nudge the metadata-flags hint when we genuinely had to
2825
+ // guess (no flag AND parser couldn't extract). Parser-derived
2826
+ // values are real; flagging them as defaults is misleading.
2827
+ const guessedBundleId = !values['bundle-id'] && !(parsed.ok && parsed.bundleId);
2828
+ const guessedVersion = !values['app-version'] && !(parsed.ok && parsed.version);
2829
+ if (guessedBundleId || guessedVersion) {
2830
+ console.log(` Hint: re-run with --bundle-id <id> --app-version <v> --build <n> --display-name "<name>" to pre-fill metadata (binary parse failed to extract everything).`);
2831
+ }
2832
+ else if (parsed.ok) {
2833
+ console.log(` Auto-detected metadata from the binary's Info.plist.`);
2834
+ }
2835
+ return;
2836
+ }
2837
+ // ── --as text: inline the file CONTENTS as a text block on the
2838
+ // composition canvas, instead of (or in addition to) storing as
2839
+ // an opaque R2 attachment. Closes the "I have a .md, I want it
2840
+ // ON my pasteboard" workflow without a separate canvas-add-text
2841
+ // round trip.
2842
+ //
2843
+ // Auto-trigger: small text/markdown files with --composition set
2844
+ // default to text-block placement. Override with `--as attachment`
2845
+ // if the user explicitly wants the opaque-file behaviour.
2846
+ //
2847
+ // Without --composition the flag is a no-op (we need a target
2848
+ // canvas; the workspace VFS path doesn't have a canvas concept).
2849
+ const isText = contentType.startsWith('text/') ||
2850
+ contentType === 'application/json' ||
2851
+ contentType === 'application/xml';
2852
+ // `explicitAs` is declared earlier (above the wantMobileInstall branch).
2853
+ const wantTextBlock = compositionId &&
2854
+ (explicitAs === 'text' ||
2855
+ (explicitAs !== 'attachment' && isText && content.length <= 256 * 1024));
2856
+ if (wantTextBlock) {
2857
+ const text = content.toString('utf-8');
2858
+ const lines = text.split(/\r?\n/).length;
2859
+ // Heuristic placement: leave a comfortable margin from the origin
2860
+ // so the block doesn't land under the chrome. Sized for ~80ch.
2861
+ const x = 80, y = 80;
2862
+ const width = 720;
2863
+ const height = Math.max(120, Math.min(2400, lines * 22 + 60));
2864
+ try {
2865
+ const res = await rpcOnce('canvas-add-text', {
2866
+ compositionId,
2867
+ text,
2868
+ x,
2869
+ y,
2870
+ width,
2871
+ height,
2872
+ // Pass through filename + mime so the block knows where it
2873
+ // came from — useful for "open externally" + future "convert
2874
+ // back to file" affordances. The server-side canvas-add-text
2875
+ // already merges extras into block.data.
2876
+ source: 'teleport',
2877
+ sourceFilename: fileName,
2878
+ sourceMime: contentType,
2879
+ }, getTimeout(values));
2880
+ if (res?.error) {
2881
+ console.error(` canvas-add-text failed: ${res.error}`);
2882
+ process.exit(1);
2883
+ }
2884
+ const blockId = res?.blockId ?? res?.result?.blockId ?? res?.id ?? '(unknown)';
2885
+ console.log(` ${fileName} → text block ${blockId} on composition ${compositionId} (${lines} lines, ${sizeKB}KB)`);
2886
+ if (explicitAs !== 'text') {
2887
+ console.log(` Hint: re-run with --as attachment to also store as an opaque file under the composition's R2 prefix.`);
2888
+ }
2889
+ return;
2890
+ }
2891
+ catch (err) {
2892
+ console.error(` Failed: ${err.message}`);
2893
+ process.exit(1);
2894
+ }
2895
+ }
2896
+ console.log(` Teleporting ${fileName} (${sizeKB}KB) → /${key}`);
2897
+ try {
2898
+ if (content.length <= INLINE_MAX_BYTES) {
2899
+ // ── Small file: inline base64 over the relay WS ──────────────────
2900
+ const b64 = content.toString('base64');
2901
+ const result = await rpcOnce('file-teleport-inline', {
2902
+ key,
2903
+ name: fileName,
2904
+ mimeType: contentType,
2905
+ size: content.length,
2906
+ data: b64,
2907
+ composition: !!compositionId,
2908
+ }, getTimeout(values));
2909
+ if (result?.error) {
2910
+ console.error(` Failed: ${result.error}`);
2911
+ process.exit(1);
2912
+ }
2913
+ // ⚠ `file-teleport-inline` MAY come back wrapped in an ack envelope, exactly like the
2914
+ // large-file path below already unwraps. Reading `result.id` raw is how the id goes
2915
+ // missing on one door and not the other.
2916
+ const inlineRec = result?.type === 'ack' && result?.result !== undefined ? result.result : result;
2917
+ console.log(` ${fileName} → /${key}${compositionId ? ' (composition)' : ''}`);
2918
+ printTeleportedFileId(inlineRec?.id, compositionId, fileName);
2919
+ // A composition asset is deliberately NOT projected: it belongs to the composition's own
2920
+ // fs surface and has no `files` row to hang a document off.
2921
+ if (!compositionId)
2922
+ await projectUploadedFileAsDocument(inlineRec?.id, fileName, values);
2923
+ return;
2924
+ }
2925
+ // ── Large file: presigned URL → direct PUT → notify ───────────────
2926
+ const intent = await rpcOnce('get-upload-url', {
2927
+ key,
2928
+ contentType,
2929
+ maxSize: content.length,
2930
+ }, getTimeout(values));
2931
+ if (!intent?.uploadUrl) {
2932
+ console.error(` Failed to get upload URL: ${intent?.error || 'no uploadUrl in response'}`);
2933
+ process.exit(1);
2934
+ }
2935
+ // Server's FileManager returns a relative path when `domain` isn't
2936
+ // configured — absolute-ize it here using the daemon's relay host.
2937
+ // The relay Worker handles PUT /f/upload/* and forwards to the DO.
2938
+ let uploadUrl = intent.uploadUrl;
2939
+ if (uploadUrl.startsWith('/')) {
2940
+ const { readFileSync, existsSync } = await import('fs');
2941
+ const { join } = await import('path');
2942
+ const { homedir } = await import('os');
2943
+ let relayHost = 'https://relay.clearly.sh';
2944
+ try {
2945
+ const cfgPath = join(homedir(), '.beehaven', 'config.json');
2946
+ if (existsSync(cfgPath)) {
2947
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
2948
+ if (cfg.endpoint) {
2949
+ relayHost = String(cfg.endpoint)
2950
+ .replace(/^wss:/, 'https:')
2951
+ .replace(/^ws:/, 'http:')
2952
+ .replace(/\/$/, '');
2953
+ }
2954
+ }
2955
+ }
2956
+ catch {
2957
+ /* fall back to default */
2958
+ }
2959
+ uploadUrl = relayHost + uploadUrl;
2960
+ }
2961
+ const putRes = await fetch(uploadUrl, {
2962
+ method: 'PUT',
2963
+ headers: { 'Content-Type': contentType },
2964
+ body: content,
2965
+ });
2966
+ if (!putRes.ok) {
2967
+ console.error(` Upload PUT failed: ${putRes.status} ${await putRes.text().catch(() => '')}`);
2968
+ process.exit(1);
2969
+ }
2970
+ const recorded = await rpcOnce('file-uploaded', {
2971
+ key,
2972
+ name: fileName,
2973
+ // ⚠ `mimeType`, NOT `mime` — this sent `mime` and the handler reads `mimeType`, so every
2974
+ // file over the inline threshold landed with a NULL `mime_type`: a generic icon in the
2975
+ // gallery and `application/octet-stream` on download, with nothing reporting a problem.
2976
+ // The handler now accepts both spellings; this sends the canonical one.
2977
+ mimeType: contentType,
2978
+ size: content.length,
2979
+ composition: !!compositionId,
2980
+ }, getTimeout(values));
2981
+ if (recorded?.error) {
2982
+ console.error(` Uploaded to R2 but failed to record file row: ${recorded.error}`);
2983
+ process.exit(1);
2984
+ }
2985
+ console.log(` ${fileName} → /${key} (direct R2 upload, ${sizeKB}KB${compositionId ? ', composition' : ''})`);
2986
+ const rec = recorded?.type === 'ack' && recorded?.result !== undefined ? recorded.result : recorded;
2987
+ printTeleportedFileId(rec?.id, compositionId, fileName);
2988
+ if (!compositionId) {
2989
+ await projectUploadedFileAsDocument(rec?.id, fileName, values);
2990
+ }
2991
+ }
2992
+ catch (err) {
2993
+ console.error(` Failed: ${err.message}`);
2994
+ process.exit(1);
2995
+ }
2996
+ }
2997
+ // ── beehaven context — refresh .clearly/CLAUDE.md ────────────────────────
2998
+ export async function cmdContext(values) {
2999
+ const { getLinkedFolders } = await import('../folder-sync.js');
3000
+ const { ClearlySync } = await import('../clearly-sync.js');
3001
+ const cwd = process.cwd();
3002
+ const linked = getLinkedFolders();
3003
+ const entry = linked[cwd];
3004
+ if (!entry) {
3005
+ console.error(' This folder is not linked to a Clearly project.');
3006
+ console.error(' Run "beehaven link" to connect it.');
3007
+ process.exit(1);
3008
+ }
3009
+ const sync = new ClearlySync({
3010
+ projectPath: cwd,
3011
+ projectId: entry.brandId,
3012
+ callAction: (action, input) => rpcOnce(action, input, getTimeout(values)),
3013
+ });
3014
+ try {
3015
+ await sync.pull();
3016
+ console.log(' Context written to .clearly/CLAUDE.md');
3017
+ console.log(' Tasks: .clearly/tasks.yaml');
3018
+ console.log(' Memories: .clearly/memories.yaml');
3019
+ }
3020
+ catch (err) {
3021
+ console.error(` Error: ${err.message}`);
3022
+ process.exit(1);
3023
+ }
3024
+ }
3025
+ // ── beehaven broadcast '{json}' — admin broadcast ────────────────────────
3026
+ export async function cmdBroadcast(rawMsg, values) {
3027
+ try {
3028
+ const msg = JSON.parse(rawMsg);
3029
+ const result = await rpcOnce('admin:broadcast', { message: msg }, getTimeout(values));
3030
+ console.log(JSON.stringify(result, null, 2));
3031
+ }
3032
+ catch (err) {
3033
+ console.error(` ${err.message}`);
3034
+ process.exit(1);
3035
+ }
3036
+ }
3037
+ // ── beehaven agent "<message>" — one-shot prompt to Omni ─────────────────
3038
+ /**
3039
+ * ⚠ THE ACTION IS `agent-prompt`, NOT `admin:agent-prompt`.
3040
+ *
3041
+ * This dispatched `admin:agent-prompt` for its whole life, and no such action is
3042
+ * mounted on the workspace — every invocation of the documented verb answered
3043
+ *
3044
+ * Unknown message type: admin:agent-prompt. Did you mean: admin:sql …
3045
+ *
3046
+ * so `beehaven agent "<msg>"`, advertised in `--help` as "One-shot prompt to the
3047
+ * cloud agent", has never once reached an agent. The working action is
3048
+ * `agent-prompt` — which is what the avatar hive's own `/omni` route has been
3049
+ * calling all along (`Avatar.ts` `replyWithOmni`), which is why Omni was
3050
+ * reachable from the desktop companion and not from the terminal.
3051
+ *
3052
+ * The payload matters as much as the name: `agentId: 'omni'` selects the
3053
+ * orchestrator, and `wait: true` is what makes this a one-shot rather than a
3054
+ * fire-and-forget that prints an ack and exits before the reply exists.
3055
+ */
3056
+ export async function cmdAgent(prompt, values) {
3057
+ try {
3058
+ const result = await rpcOnce('agent-prompt', {
3059
+ message: prompt,
3060
+ agentId: 'omni',
3061
+ wait: true,
3062
+ // ⚠ NOT `cwd`. The old call passed the shell's working directory, which
3063
+ // the action does not accept — a local path is meaningless to a cloud
3064
+ // orchestrator, and sending it only ever added a field to be ignored.
3065
+ // Continuity comes from `conversationId`, which the caller may thread
3066
+ // through to keep one thread across invocations.
3067
+ ...(typeof values?.conversation === 'string' && values.conversation
3068
+ ? { conversationId: values.conversation }
3069
+ : {}),
3070
+ },
3071
+ // Omni runs tools; the CLI default is far too short for a real turn.
3072
+ getTimeout(values) || 180_000);
3073
+ /**
3074
+ * Print the REPLY, not the envelope. The whole response is still available
3075
+ * with `--json` — but a verb whose entire purpose is "ask a question and read
3076
+ * the answer" should not make you pick the answer out of a JSON blob.
3077
+ */
3078
+ if (values?.json || !result?.reply) {
3079
+ console.log(JSON.stringify(result, null, 2));
3080
+ return;
3081
+ }
3082
+ console.log(result.reply);
3083
+ if (result.conversationId || result.compositionId) {
3084
+ console.error(`\n conversation: ${result.conversationId ?? result.compositionId}` +
3085
+ ` (pass --conversation <id> to continue it)`);
3086
+ }
3087
+ }
3088
+ catch (err) {
3089
+ console.error(` ${err.message}`);
3090
+ process.exit(1);
3091
+ }
3092
+ }
3093
+ // ── beehaven store-query <storeId> "<SQL>" ───────────────────────────────
3094
+ export async function cmdStoreQuery(storeId, sql, values) {
3095
+ try {
3096
+ const result = await rpcOnce('admin:store-query', { storeId, sql }, getTimeout(values));
3097
+ console.log(JSON.stringify(result, null, 2));
3098
+ }
3099
+ catch (err) {
3100
+ console.error(` ${err.message}`);
3101
+ process.exit(1);
3102
+ }
3103
+ }
3104
+ // ── BEEHAVEN.md generation ───────────────────────────────────────────────
3105
+ /**
3106
+ * Generate BEEHAVEN.md at the project root.
3107
+ * Pulls the workspace manifest from the cloud DO via daemon IPC,
3108
+ * formats it as a Claude-readable reference document.
3109
+ */
3110
+ export async function generateBeehavenMd(projectDir) {
3111
+ const { writeFileSync, existsSync, mkdirSync } = await import('fs');
3112
+ const { join } = await import('path');
3113
+ // Pull workspace info + actions from cloud DO
3114
+ const [wsInfo, actionsResult] = await Promise.all([
3115
+ rpcOnce('workspace-info', {}, 10_000),
3116
+ rpcOnce('list-actions', { format: 'full' }, 10_000),
3117
+ ]);
3118
+ const actions = actionsResult.actions || [];
3119
+ const tables = wsInfo.tables || {};
3120
+ // Group actions by category
3121
+ const grouped = new Map();
3122
+ for (const a of actions) {
3123
+ if (a.name.startsWith('bh:') || a.name.startsWith('_admin'))
3124
+ continue;
3125
+ const cat = a.category || 'other';
3126
+ if (!grouped.has(cat))
3127
+ grouped.set(cat, []);
3128
+ grouped.get(cat).push(a);
3129
+ }
3130
+ // Curated actions Claude would actually use in a dev project
3131
+ const CURATED_ACTIONS = new Set([
3132
+ // Context & Memory
3133
+ 'remember',
3134
+ 'recall',
3135
+ 'forget',
3136
+ 'project-context',
3137
+ // Notes & Search
3138
+ 'note-create',
3139
+ 'note-update',
3140
+ 'note-delete',
3141
+ 'search-notes',
3142
+ 'browse-notes',
3143
+ // Tasks
3144
+ 'note-list',
3145
+ // Files
3146
+ 'teleport',
3147
+ 'file-teleport-inline',
3148
+ 'get-upload-url',
3149
+ 'browse-vfs',
3150
+ // Agent
3151
+ 'chat',
3152
+ 'get-tools',
3153
+ // Communications
3154
+ 'send-sms',
3155
+ 'send-email',
3156
+ // System
3157
+ 'health-check',
3158
+ 'list-actions',
3159
+ 'describe-action',
3160
+ 'workspace-info',
3161
+ // Projects
3162
+ 'create-project',
3163
+ 'project-update',
3164
+ 'project-notes-list',
3165
+ 'project-notes-write',
3166
+ // Teams
3167
+ 'team-create',
3168
+ // Versioning
3169
+ 'undo',
3170
+ 'redo',
3171
+ 'snapshot-create',
3172
+ 'snapshot-list',
3173
+ ]);
3174
+ const curatedActions = actions.filter((a) => CURATED_ACTIONS.has(a.name));
3175
+ // Build markdown
3176
+ let md = `# BEEHAVEN.md
3177
+
3178
+ This file describes the Beehaven workspace connected to this project.
3179
+ Claude Code can use \`beehaven\` CLI commands to interact with the workspace.
3180
+
3181
+ ## Commands
3182
+
3183
+ \`\`\`bash
3184
+ beehaven remember <key> <value> # Store memory across sessions
3185
+ beehaven recall [query] # Retrieve memories
3186
+ beehaven search "<query>" # Search notes
3187
+ beehaven call note-create '{"title":"...","content":"...","fileType":"note"}'
3188
+ beehaven task "<title>" # Create a task (board item)
3189
+ beehaven teleport <file> # Upload file to R2 storage
3190
+ beehaven ask "<question>" # Chat with cloud agent
3191
+ beehaven call send-email '{"to":"...","subject":"...","body":"..."}'
3192
+ beehaven call send-sms '{"to":"+1...","body":"..."}'
3193
+ beehaven workspace # Overview: tables, actions, agent
3194
+ beehaven actions # Full list of all ${actions.length} actions
3195
+ beehaven call <action> '{"key":"value"}' # Call any action
3196
+ beehaven call --building team:<id> <action> # Target a specific building
3197
+ \`\`\`
3198
+
3199
+ ## Key Actions
3200
+
3201
+ | Action | Description |
3202
+ |--------|-------------|
3203
+ `;
3204
+ for (const a of curatedActions) {
3205
+ md += `| \`${a.name}\` | ${a.description || ''} |\n`;
3206
+ }
3207
+ md += `
3208
+ ## Tables
3209
+
3210
+ | Table | Rows | Key Columns |
3211
+ |-------|------|-------------|
3212
+ `;
3213
+ for (const [name, info] of Object.entries(tables)) {
3214
+ const t = info;
3215
+ md += `| ${name} | ${t.count} | ${t.columns.slice(0, 5).join(', ')}${t.columns.length > 5 ? '...' : ''} |\n`;
3216
+ }
3217
+ md += `
3218
+ ## Buildings
3219
+
3220
+ - **home** — Personal workspace (default target for all commands)
3221
+ - **team:\<id\>** — Shared team workspace: \`beehaven call --building team:abc note-create '{}'\`
3222
+ - **store:\<id\>** — Shopify store workspace: \`beehaven call --building store:xyz search-products '{}'\`
3223
+
3224
+ ## Creating Notes
3225
+
3226
+ \`\`\`bash
3227
+ # Create via CLI
3228
+ beehaven call note-create '{"title":"Sprint Plan","content":"## Goals\\n...","fileType":"note"}'
3229
+
3230
+ # Write .md files to .clearly/notes/ — they sync automatically
3231
+ echo "# Sprint Plan" > .clearly/notes/sprint-plan.md
3232
+
3233
+ # Upload files to R2 storage
3234
+ beehaven teleport report.pdf
3235
+ \`\`\`
3236
+
3237
+ ## All Actions (${actions.length} total)
3238
+
3239
+ Run \`beehaven actions\` to see the full categorized list, or \`beehaven actions --format tool-use --json\` for tool schemas.
3240
+
3241
+ `;
3242
+ // Compact full list grouped by category
3243
+ for (const [category, catActions] of grouped) {
3244
+ const label = category.charAt(0).toUpperCase() + category.slice(1);
3245
+ const names = catActions.map((a) => `\`${a.name}\``).join(', ');
3246
+ md += `**${label}:** ${names}\n\n`;
3247
+ }
3248
+ // Write BEEHAVEN.md
3249
+ writeFileSync(join(projectDir, 'BEEHAVEN.md'), md);
3250
+ console.log(' Generated BEEHAVEN.md');
3251
+ // Create .clearly/ directory structure
3252
+ const clearlyDir = join(projectDir, '.clearly');
3253
+ if (!existsSync(clearlyDir))
3254
+ mkdirSync(clearlyDir, { recursive: true });
3255
+ const notesDir = join(clearlyDir, 'notes');
3256
+ if (!existsSync(notesDir))
3257
+ mkdirSync(notesDir, { recursive: true });
3258
+ // Write .clearly/CLAUDE.md (subset of BEEHAVEN.md — context for Claude)
3259
+ const claudeMd = `# Clearly Workspace Context
3260
+
3261
+ This project is linked to a Clearly workspace via Beehaven.
3262
+
3263
+ ## Available Commands
3264
+ - \`beehaven actions\` — list workspace actions
3265
+ - \`beehaven call <action> '{}'\` — call any action
3266
+ - \`beehaven search "<query>"\` — search notes
3267
+ - \`beehaven remember <key> <value>\` — store memory
3268
+ - \`beehaven teleport <file>\` — upload file
3269
+ - \`beehaven task "<title>"\` — create task
3270
+
3271
+ ## Creating Notes
3272
+ Write .md files to this directory's notes/ subfolder. They sync automatically.
3273
+
3274
+ See BEEHAVEN.md in the project root for full documentation.
3275
+ `;
3276
+ writeFileSync(join(clearlyDir, 'CLAUDE.md'), claudeMd);
3277
+ console.log(' Generated .clearly/CLAUDE.md');
3278
+ }
3279
+ // ── actions.search — fuzzy match an intent string to action names ───────
3280
+ /**
3281
+ * Client-side fuzzy search over the action list, so `actions.search`
3282
+ * works on any DO that implements `list-actions` — no server handler
3283
+ * needed. Ranks by:
3284
+ * 1. exact match on name (weight 100)
3285
+ * 2. exact match on alias (weight 80)
3286
+ * 3. substring match in name (weight 40)
3287
+ * 4. substring match in description (weight 20)
3288
+ * 5. substring match in category (weight 10)
3289
+ *
3290
+ * Returns up to `limit` (default 10) top matches, each with {name,
3291
+ * score, matchedOn} so agents can see WHY the match hit.
3292
+ */
3293
+ export async function actionsSearch(client, params) {
3294
+ const q = String(params.query ?? params.q ?? '')
3295
+ .toLowerCase()
3296
+ .trim();
3297
+ if (!q)
3298
+ return { query: q, matches: [] };
3299
+ const limit = Math.max(1, Math.min(50, params.limit ?? 10));
3300
+ const raw = await client.call('list-actions', { format: 'full' });
3301
+ const list = Array.isArray(raw) ? raw : (raw?.actions ?? []);
3302
+ const queryTokens = q.split(/\s+/).filter((t) => t.length > 0);
3303
+ const scored = [];
3304
+ for (const a of list) {
3305
+ const name = String(a.name ?? '').toLowerCase();
3306
+ const aliases = Array.isArray(a.aliases)
3307
+ ? a.aliases.map((x) => String(x).toLowerCase())
3308
+ : [];
3309
+ const desc = String(a.description ?? '').toLowerCase();
3310
+ const category = String(a.category ?? '').toLowerCase();
3311
+ let score = 0;
3312
+ const matchedOn = [];
3313
+ if (name === q) {
3314
+ score += 100;
3315
+ matchedOn.push('name-exact');
3316
+ }
3317
+ else if (aliases.includes(q)) {
3318
+ score += 80;
3319
+ matchedOn.push('alias-exact');
3320
+ }
3321
+ for (const token of queryTokens) {
3322
+ if (!token)
3323
+ continue;
3324
+ if (name.includes(token)) {
3325
+ score += 40;
3326
+ matchedOn.push(`name:${token}`);
3327
+ }
3328
+ else if (aliases.some((al) => al.includes(token))) {
3329
+ score += 30;
3330
+ matchedOn.push(`alias:${token}`);
3331
+ }
3332
+ if (desc.includes(token)) {
3333
+ score += 20;
3334
+ matchedOn.push(`desc:${token}`);
3335
+ }
3336
+ if (category === token) {
3337
+ score += 10;
3338
+ matchedOn.push(`cat:${token}`);
3339
+ }
3340
+ }
3341
+ if (score > 0) {
3342
+ scored.push({
3343
+ name: a.name,
3344
+ score,
3345
+ matchedOn: [...new Set(matchedOn)],
3346
+ description: a.description,
3347
+ category: a.category,
3348
+ aliases: a.aliases,
3349
+ });
3350
+ }
3351
+ }
3352
+ scored.sort((a, b) => b.score - a.score);
3353
+ return { query: q, matches: scored.slice(0, limit) };
3354
+ }