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