beehaven 0.8.21 → 0.8.24

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.
@@ -1,26 +1,7 @@
1
1
  /**
2
- * swarm / hive — the distributed "hive mesh" from the terminal.
3
- *
4
- * A **beehive** is a node running the daemon + hives (this Mac, a headless
5
- * server, or a Cloudflare container). A **queen hive** orchestrates; **worker
6
- * hives** execute. This machine registers itself automatically when the daemon
7
- * connects (see daemon.ts `registerBeehive`) — these verbs let you SEE the
8
- * swarm, designate roles, and route work.
9
- *
10
- * beehaven swarm list # every beehive: role, kind, hives, online
11
- * beehaven swarm queen # who's orchestrating
12
- * beehaven swarm role <queen|worker> # make THIS machine the queen (or a worker)
13
- * beehaven swarm whoami # this machine's beehive identity
14
- * beehaven swarm dispatch <cap> <action> [json] # route work to the best worker
15
- * beehaven swarm forget <deviceId> # drop a stale offline node
16
- *
17
- * All cloud state lives in the always-on Home DO (rpc/swarm-rpcs.ts); these
18
- * verbs reach it through the daemon over IPC via `rpcOnce`. (One namespace on
19
- * purpose — `hive`/`hives` already name the local-apps concept.)
2
+ * swarm — the Queen and Omni identity commands. The unfinished node registry and
3
+ * dispatch protocol were retired; these two bees remain independent of it.
20
4
  */
21
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
- import { homedir, hostname, platform } from 'node:os';
23
- import { join } from 'node:path';
24
5
  import { rpcOnce } from '../ipc-client.js';
25
6
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
26
7
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
@@ -28,50 +9,28 @@ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
28
9
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
29
10
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
30
11
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
31
- const CONFIG_DIR = join(homedir(), '.beehaven');
32
- const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
33
- /** This machine's stable beehive id — MUST match relay.ts `deviceId`. */
34
- function thisDeviceId() {
35
- return `${hostname()}-${platform()}`;
36
- }
37
- function loadConfig() {
38
- try {
39
- return existsSync(CONFIG_FILE) ? JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) : {};
40
- }
41
- catch {
42
- return {};
43
- }
44
- }
45
- function saveConfig(update) {
46
- mkdirSync(CONFIG_DIR, { recursive: true });
47
- writeFileSync(CONFIG_FILE, JSON.stringify({ ...loadConfig(), ...update }, null, 2));
48
- }
49
12
  /** Unwrap the daemon's { result } envelope (rpcOnce sometimes returns it raw). */
50
13
  function unwrap(r) {
51
14
  return r?.result ?? r ?? {};
52
15
  }
53
16
  // ══════════════════════════════ swarm ══════════════════════════════
54
17
  export async function cmdSwarm(positionals, values) {
55
- const sub = positionals[1] ?? 'list';
56
- const rest = positionals.slice(2);
18
+ const sub = positionals[1] ?? 'bees';
57
19
  switch (sub) {
58
20
  case 'list':
59
21
  case 'ls':
60
- return swarmList();
61
22
  case 'queen':
62
- return swarmQueen();
63
23
  case 'role':
64
- return swarmRole(rest);
65
24
  case 'whoami':
66
25
  case 'status':
67
26
  case 'me':
68
- return swarmWhoami();
69
27
  case 'dispatch':
70
28
  case 'run':
71
- return swarmDispatch(rest, values);
72
29
  case 'forget':
73
30
  case 'rm':
74
- return swarmForget(rest);
31
+ console.log(' The node swarm registry and dispatch protocol were retired.');
32
+ console.log(dim(' Use beehaven swarm bees | sample-honey | forge-omni for Queen and Omni.'));
33
+ return;
75
34
  // The bees — 👑 Queen (samples all the honey) + 🐝 Omni (the owner's twin).
76
35
  case 'bees':
77
36
  return swarmBees();
@@ -83,121 +42,8 @@ export async function cmdSwarm(positionals, values) {
83
42
  return swarmForgeOmni(values);
84
43
  default:
85
44
  console.log(` Unknown swarm subcommand '${sub}'.`);
86
- console.log(dim(' Try: beehaven swarm list | queen | role | whoami | dispatch | forget | bees | sample-honey | forge-omni'));
87
- }
88
- }
89
- async function swarmList() {
90
- let res;
91
- try {
92
- res = unwrap(await rpcOnce('swarm-list', {}, 8_000));
93
- }
94
- catch {
95
- console.log(red(' Cannot reach the swarm.') + dim(' Is the daemon running? beehaven status'));
96
- return;
97
- }
98
- const beehives = res?.beehives ?? [];
99
- if (!beehives.length) {
100
- console.log('');
101
- console.log(' 🐝 No beehives registered yet.');
102
- console.log(dim(' This machine registers automatically when the daemon connects.'));
103
- console.log(dim(' Bring more online: `beehaven start --headless` on a server, or a cloud container.'));
104
- console.log('');
105
- return;
106
- }
107
- const me = thisDeviceId();
108
- console.log('');
109
- console.log(` 🐝 ${bold('Swarm')} ${dim(`— ${res.online}/${res.count} online`)}`);
110
- console.log(dim(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
111
- for (const b of beehives) {
112
- const dot = b.online ? green('●') : dim('○');
113
- const crown = b.role === 'queen' ? '👑' : '🐝';
114
- const mine = b.deviceId === me ? cyan(' ← this machine') : '';
115
- const caps = (b.capabilities ?? []).slice(0, 8).join(', ') || dim('no hives');
116
- console.log(` ${dot} ${crown} ${cyan(String(b.hostname).padEnd(22))} ${dim(String(b.kind).padEnd(15))} ${(b.role === 'queen' ? yellow('queen') : 'worker').padEnd(7)}${mine}`);
117
- console.log(` ${dim(b.deviceId)}`);
118
- console.log(` ${dim('hives:')} ${caps}${b.load ? dim(` load:${b.load}`) : ''}`);
119
- }
120
- console.log('');
121
- console.log(dim(` queen: ${res.queen ? green(res.queen) : 'none — promote one with `beehaven swarm role queen`'}`));
122
- console.log('');
123
- }
124
- async function swarmQueen() {
125
- let res;
126
- try {
127
- res = unwrap(await rpcOnce('swarm-queen', {}, 8_000));
128
- }
129
- catch {
130
- console.log(red(' Cannot reach the swarm.'));
131
- return;
132
- }
133
- const q = res?.queen;
134
- console.log('');
135
- if (!q) {
136
- console.log(' 👑 No queen designated.');
137
- console.log(dim(' Make this machine the queen: beehaven swarm role queen'));
138
- }
139
- else {
140
- console.log(` 👑 ${bold('Queen')} — ${cyan(q.hostname)} ${q.online ? green('(online)') : dim('(offline)')}`);
141
- console.log(` ${dim(q.deviceId)} · ${dim(q.kind)}`);
142
- console.log(` ${dim('hives:')} ${(q.capabilities ?? []).join(', ') || dim('none')}`);
143
- }
144
- console.log('');
145
- }
146
- async function swarmDispatch(rest, values) {
147
- const capability = rest[0];
148
- const action = rest[1];
149
- if (!capability || !action) {
150
- console.log(' Usage: beehaven swarm dispatch <capability> <action> [json]');
151
- console.log(dim(' capability = the hive to run (e.g. motion-studio, voice)'));
152
- console.log(dim(' e.g. beehaven swarm dispatch voice synthesize \'{"text":"hi"}\''));
153
- return;
154
- }
155
- let input = {};
156
- if (rest[2]) {
157
- try {
158
- input = JSON.parse(rest[2]);
159
- }
160
- catch {
161
- console.log(red(' Invalid JSON payload.'));
162
- return;
163
- }
45
+ console.log(dim(' Try: beehaven swarm bees | sample-honey | forge-omni'));
164
46
  }
165
- const targetDeviceId = typeof values?.target === 'string' ? values.target : undefined;
166
- console.log(dim(` → dispatching ${capability}:${action}${targetDeviceId ? ` @${targetDeviceId}` : ' to the best worker'}…`));
167
- let res;
168
- try {
169
- res = unwrap(await rpcOnce('swarm-dispatch', { capability, action, input, targetDeviceId }, 300_000));
170
- }
171
- catch (e) {
172
- console.log(red(` Dispatch failed: ${e?.message ?? e}`));
173
- return;
174
- }
175
- if (res?.error || res?.ok === false) {
176
- console.log(red(` ✗ ${res.error ?? 'dispatch failed'}`) + (res.code ? dim(` [${res.code}]`) : ''));
177
- return;
178
- }
179
- console.log(green(` ✓ ${capability}:${action}`) + dim(` on ${res.targetDeviceId} · ${res.durationMs}ms`));
180
- console.log(JSON.stringify(res.result, null, 2));
181
- }
182
- async function swarmForget(rest) {
183
- const deviceId = rest[0];
184
- if (!deviceId) {
185
- console.log(' Usage: beehaven swarm forget <deviceId>');
186
- return;
187
- }
188
- let res;
189
- try {
190
- res = unwrap(await rpcOnce('swarm-forget', { deviceId }, 8_000));
191
- }
192
- catch (e) {
193
- console.log(red(` ${e?.message ?? e}`));
194
- return;
195
- }
196
- if (res?.error) {
197
- console.log(red(` ✗ ${res.error}`));
198
- return;
199
- }
200
- console.log(green(` ✓ forgot beehive ${deviceId}`));
201
47
  }
202
48
  // ══════════════════════════════ the bees ══════════════════════════════
203
49
  // 👑 Queen samples ALL the honey (the full corpus, gets fat); 🐝 Omni is the
@@ -283,59 +129,3 @@ async function swarmForgeOmni(values) {
283
129
  console.log(dim(` Log in as your twin: ${res.loginCommand || 'beehaven agent login omni-bee'}`));
284
130
  console.log('');
285
131
  }
286
- // ─────────────────────── this machine's role/identity ───────────────────────
287
- async function swarmRole(rest) {
288
- const role = (rest[0] ?? '').toLowerCase();
289
- if (role !== 'queen' && role !== 'worker') {
290
- const current = loadConfig().role ?? (process.env.BEEHAVEN_HEADLESS === '1' ? 'worker' : 'queen');
291
- console.log(' Usage: beehaven swarm role <queen|worker>');
292
- console.log(dim(` This machine is currently: ${current === 'queen' ? yellow('👑 queen') : 'worker'}`));
293
- return;
294
- }
295
- // 1) Persist locally so it survives restarts + the daemon re-registers with it.
296
- saveConfig({ role });
297
- // 2) Update the cloud row immediately so the swarm reflects it now (the daemon
298
- // catches up its own role on its next register, ~2 min, or `beehaven restart`).
299
- const deviceId = thisDeviceId();
300
- let synced = false;
301
- try {
302
- const r = unwrap(await rpcOnce('hive-set-role', { deviceId, role }, 8_000));
303
- synced = !!r?.ok;
304
- }
305
- catch {
306
- /* daemon offline — local config still saved; applies on next connect */
307
- }
308
- console.log('');
309
- console.log(` ${green('✓')} This machine is now the ${role === 'queen' ? yellow('👑 queen hive') : 'worker hive'}.`);
310
- console.log(dim(synced
311
- ? ' Synced to the swarm. It orchestrates from here.'
312
- : ' Saved locally — applies when the daemon next connects (beehaven restart to apply now).'));
313
- console.log('');
314
- }
315
- async function swarmWhoami() {
316
- const cfg = loadConfig();
317
- const deviceId = thisDeviceId();
318
- const role = cfg.role ?? (process.env.BEEHAVEN_HEADLESS === '1' ? 'worker' : 'queen');
319
- const kind = process.env.BEEHAVEN_KIND ?? (process.env.BEEHAVEN_HEADLESS === '1' ? 'headless' : 'local-mac');
320
- console.log('');
321
- console.log(` 🐝 ${bold('This beehive')}`);
322
- console.log(` deviceId: ${cyan(deviceId)}`);
323
- console.log(` role: ${role === 'queen' ? yellow('👑 queen') : 'worker'} ${dim('(beehaven swarm role <queen|worker>)')}`);
324
- console.log(` kind: ${kind}`);
325
- // Best-effort live view from the swarm registry.
326
- try {
327
- const res = unwrap(await rpcOnce('swarm-list', {}, 5_000));
328
- const me = (res?.beehives ?? []).find((b) => b.deviceId === deviceId);
329
- if (me) {
330
- console.log(` online: ${me.online ? green('yes') : dim('no')}`);
331
- console.log(` hives: ${(me.capabilities ?? []).join(', ') || dim('none')}`);
332
- }
333
- else {
334
- console.log(dim(' not yet registered in the swarm — the daemon registers on connect.'));
335
- }
336
- }
337
- catch {
338
- /* daemon offline */
339
- }
340
- console.log('');
341
- }
@@ -860,6 +860,22 @@ export async function cmdCall(action, rawInput, values) {
860
860
  input = { ...input, action: canonical, name: canonical };
861
861
  }
862
862
  }
863
+ // ⚠ `type` IS THE WIRE'S ACTION NAME (PPO-190): a top-level `type` would be overwritten by the
864
+ // daemon's `{ ...input, type: action }` and lost with `ok: true`. Move it to the verb's own field
865
+ // or refuse with the right spelling — never drop it in silence. See reserved-type.ts.
866
+ {
867
+ const { reconcileReservedType } = await import('../reserved-type.js');
868
+ const r = reconcileReservedType(action, input);
869
+ if (r.kind === 'refused') {
870
+ console.error(` ${r.error}`);
871
+ process.exit(1);
872
+ }
873
+ if (r.kind === 'moved') {
874
+ input = r.input;
875
+ if (!values.json)
876
+ console.error(` · ${r.note}`);
877
+ }
878
+ }
863
879
  // Confirm spend BEFORE resolving a building or touching the daemon — a cancelled call must
864
880
  // cost nothing and behave identically whether the input came from argv, a file, or stdin.
865
881
  // It runs after the parse because the gate is input-aware: `engine:'handroll'` and any call
package/dist/daemon.js CHANGED
@@ -2501,9 +2501,27 @@ export async function startDaemon(opts) {
2501
2501
  });
2502
2502
  });
2503
2503
  // Local client raw sends → forward to DO via relay
2504
- ipc.on('send', (msg) => {
2505
- if (relay.isConnected()) {
2506
- relay.wsSend(msg);
2504
+ const ipcLiveQueries = new Map();
2505
+ ipc.on('send', (msg, clientId) => {
2506
+ if (!relay.isConnected() || !relay.wsSend(msg))
2507
+ return;
2508
+ const queryId = typeof msg?.queryId === 'string' ? msg.queryId : '';
2509
+ if (!queryId || !clientId)
2510
+ return;
2511
+ if (msg.type === 'sql:subscribe') {
2512
+ if (!ipcLiveQueries.has(clientId)) {
2513
+ ipcLiveQueries.set(clientId, new Set());
2514
+ ipc.onClientDisconnect(clientId, () => {
2515
+ for (const ownedId of ipcLiveQueries.get(clientId) ?? []) {
2516
+ relay.wsSend({ type: 'sql:unsubscribe', queryId: ownedId });
2517
+ }
2518
+ ipcLiveQueries.delete(clientId);
2519
+ });
2520
+ }
2521
+ ipcLiveQueries.get(clientId).add(queryId);
2522
+ }
2523
+ else if (msg.type === 'sql:unsubscribe') {
2524
+ ipcLiveQueries.get(clientId)?.delete(queryId);
2507
2525
  }
2508
2526
  });
2509
2527
  // Local client HTTP proxy: make a fetch with the daemon's Firebase ID
@@ -3059,57 +3077,6 @@ export async function startDaemon(opts) {
3059
3077
  if (daemonAppManager) {
3060
3078
  relay.sendLocalAppStatus(daemonAppManager.status());
3061
3079
  }
3062
- // Capabilities may have changed (install/uninstall) — re-advertise this
3063
- // node's beehive record so the swarm's dispatch matching stays accurate.
3064
- registerBeehive();
3065
- }
3066
- // ── Distributed hive mesh — advertise this daemon as a beehive ──
3067
- //
3068
- // The node declares its ROLE (queen orchestrates / worker executes), its
3069
- // KIND (this Mac / a headless server / a cloud container), and its
3070
- // CAPABILITIES (the hives it can run — the dispatch match keys). The user's
3071
- // Mac defaults to QUEEN (the natural orchestrator — where the human is); a
3072
- // headless/container daemon defaults to WORKER. `beehaven swarm role` (config
3073
- // `role`) or BEEHAVEN_ROLE overrides. See rpc/swarm-rpcs.ts for the queen
3074
- // + dispatch side.
3075
- function beehiveRole() {
3076
- const env = (process.env.BEEHAVEN_ROLE || '').toLowerCase();
3077
- if (env === 'queen' || env === 'worker')
3078
- return env;
3079
- const cfgRole = String(loadConfig()?.role || '').toLowerCase();
3080
- if (cfgRole === 'queen' || cfgRole === 'worker')
3081
- return cfgRole;
3082
- return process.env.BEEHAVEN_HEADLESS === '1' ? 'worker' : 'queen';
3083
- }
3084
- function beehiveKind() {
3085
- const env = (process.env.BEEHAVEN_KIND || '').toLowerCase();
3086
- if (env === 'local-mac' || env === 'headless' || env === 'cloud-container')
3087
- return env;
3088
- return process.env.BEEHAVEN_HEADLESS === '1' ? 'headless' : 'local-mac';
3089
- }
3090
- function beehiveCapabilities() {
3091
- try {
3092
- const s = daemonAppManager?.status();
3093
- const caps = new Set(Array.isArray(s?.buildingTypes) ? s.buildingTypes : []);
3094
- // Surface app names too so `swarm list` reads friendly (motion-studio,
3095
- // voice, recorder …) even before a building instance has spun up.
3096
- for (const a of s?.apps ?? [])
3097
- if (a?.name)
3098
- caps.add(String(a.name));
3099
- return [...caps];
3100
- }
3101
- catch {
3102
- return [];
3103
- }
3104
- }
3105
- function registerBeehive() {
3106
- if (!relay.isConnected())
3107
- return;
3108
- relay.sendSwarmRegister({
3109
- role: beehiveRole(),
3110
- kind: beehiveKind(),
3111
- capabilities: beehiveCapabilities(),
3112
- });
3113
3080
  }
3114
3081
  /** Sync a single uninstall to the Home DO. */
3115
3082
  function syncUninstallToCloud(name) {
@@ -4220,18 +4187,6 @@ export async function startDaemon(opts) {
4220
4187
  setTimeout(() => {
4221
4188
  syncAvatarFromCloud().catch(() => { });
4222
4189
  }, 250);
4223
- // Register as a beehive the moment the WS opens so the node appears in
4224
- // the swarm immediately; syncInstalledAppsToCloud + the periodic tick
4225
- // below re-register with the full capability list once hives finish
4226
- // loading. Deferred slightly so the capability scan sees loaded hives.
4227
- setTimeout(() => {
4228
- try {
4229
- registerBeehive();
4230
- }
4231
- catch {
4232
- /* best-effort */
4233
- }
4234
- }, 500);
4235
4190
  // #111 — if startup rehydrated a Claude credential from keychain,
4236
4191
  // tell the worker now that the WS is open. Worker handler at
4237
4192
  // `case 'canvas-credential-rehydrated':` refreshes daemon_confirmed_at
@@ -4430,16 +4385,7 @@ export async function startDaemon(opts) {
4430
4385
  setInterval(() => checkAndAutoUpdate(), UPDATE_CHECK_INTERVAL);
4431
4386
  // Reset the LOCAL metrics window every 5 minutes (no network).
4432
4387
  setInterval(() => metrics.resetWindow(), 5 * 60_000);
4433
- /**
4434
- * ⚠⚠ NO TIMED FRAMES TO THE CLOUD (2026-09-29). Two intervals lived here: a `telemetry` frame
4435
- * and a `swarm-register` re-advertise, each every 2 minutes. Neither had a consumer — the
4436
- * workspace has no handler for either type and nothing inserts into `beehives` — but each one
4437
- * woke the user's Hive, reopened its portal (the portal idle-closes after 60 s), woke the
4438
- * workspace and re-hydrated all ~111 tables. Measured on staging: that kept one Hive resident
4439
- * ~50% of every hour per connected daemon (~11K GB-s/day) and made the workspace re-read ~15K
4440
- * rows per reopen. An idle daemon must cost nothing; it speaks when something happens.
4441
- * Registration still runs on connect and whenever installed apps change (above).
4442
- */
4388
+ // No timed telemetry or swarm-register frames: an idle daemon must cost nothing.
4443
4389
  // Start REPL
4444
4390
  if (opts?.repl !== false && !process.env.BEEHAVEN_NO_REPL)
4445
4391
  repl.start();
@@ -471,7 +471,7 @@ export class IpcServer extends EventEmitter {
471
471
  break;
472
472
  case 'send':
473
473
  // Forward raw message to relay → DO
474
- this.emit('send', msg.msg);
474
+ this.emit('send', msg.msg, client.id);
475
475
  break;
476
476
  case 'app-rpc':
477
477
  // Forward RPC to a local app building (not the cloud relay).
package/dist/relay.js CHANGED
@@ -522,21 +522,17 @@ export class Relay {
522
522
  this.scheduleReconnect();
523
523
  return;
524
524
  }
525
- // The daemon holds ONE socket — to the user's HIVE (`/my/hive/ws`, the per-user
526
- // multiplexer). The hive opens a server-side portal to the caller's CANONICAL workspace
527
- // (no `workspaceId` → the hive resolves it from `building_assignments`) and pipes it
528
- // transparently, so every workspace RPC (canvas-invoke, actions, table sync) round-trips
529
- // exactly as before — plus the daemon shares the same socket the web app uses. (Plain
530
- // synchronous URL — no pre-connect HTTP hop — so nothing can stall the dial.)
525
+ // Workspace traffic terminates on the workspace DO itself. A Hive portal can close after
526
+ // 60 s while leaving the daemon socket open; then cloud pushes and local-tool requests
527
+ // disappear until the daemon sends another frame. The direct socket hibernates safely.
531
528
  const params = new URLSearchParams({
532
529
  token,
533
530
  client: process.env.BEEHAVEN_CLIENT || 'daemon',
534
531
  clientType: process.env.BEEHAVEN_CLIENT_TYPE || 'daemon',
535
- wsType: 'workspace',
536
532
  });
537
533
  if (this.deviceId)
538
534
  params.set('deviceId', String(this.deviceId).slice(0, 64));
539
- const url = `${this.wsBase()}/my/hive/ws?${params.toString()}`;
535
+ const url = `${this.wsBase()}/my/workspace/ws?${params.toString()}`;
540
536
  const ws = new WebSocket(url);
541
537
  // Wait for open or first error/close before resolving
542
538
  await new Promise((resolve) => {
@@ -1228,34 +1224,6 @@ export class Relay {
1228
1224
  sendLocalAppStatus(status) {
1229
1225
  this.wsSend({ type: 'local-app-status', ...status });
1230
1226
  }
1231
- /**
1232
- * Advertise this daemon as a BEEHIVE in the distributed hive mesh.
1233
- *
1234
- * The Home DO's `case 'swarm-register'` upserts a durable node row (keyed by
1235
- * this stable deviceId) + maps deviceId → this live session, which is how the
1236
- * swarm knows the node is online and how the queen targets a dispatch at it.
1237
- * Re-sent on connect, whenever capabilities change, and on a periodic cadence
1238
- * (see daemon.ts `registerBeehive`) so it also serves as the beehive's
1239
- * keepalive and self-heals after a DO hibernation dropped the live map.
1240
- *
1241
- * role 'queen' (orchestrator) | 'worker' (executor)
1242
- * kind 'local-mac' | 'headless' | 'cloud-container'
1243
- * capabilities the hive/building names this node can run (dispatch match keys)
1244
- */
1245
- sendSwarmRegister(opts) {
1246
- if (!this.connected)
1247
- return;
1248
- this.wsSend({
1249
- type: 'swarm-register',
1250
- deviceId: this.deviceId,
1251
- role: opts.role,
1252
- kind: opts.kind,
1253
- capabilities: opts.capabilities,
1254
- hostname: hostname(),
1255
- os: platform(),
1256
- root: homedir(),
1257
- });
1258
- }
1259
1227
  /** Start periodic token self-refresh (every 50 min, for headless/long-running daemons) */
1260
1228
  tokenRefreshTimer = null;
1261
1229
  startTokenRefresh() {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * reserved-type — `type` is the WIRE'S action name, so a top-level `type` in a call's input is lost.
3
+ *
4
+ * ⚠⚠ SILENT DATA LOSS (PPO-190). The daemon sends `{ ...input, type: action }`. An agent that calls
5
+ * `ticket-create '{"title":"…","type":"bug"}'` gets `ok: true` and a ticket that is NOT a bug: its
6
+ * `type` was overwritten by `"ticket-create"` before the workspace ever saw it. AGENTS.md has
7
+ * carried "use `issueType`, not `type`" as a warning, which is a rule every new agent has to be
8
+ * told and every one of them breaks once.
9
+ *
10
+ * The verbs already accept a non-colliding spelling (`issueType`, `linkType`, `proposalType`,
11
+ * `kind`). This moves `type` onto that spelling when the verb has one, and refuses with the right
12
+ * spelling when it does not — either way, nothing is dropped in silence.
13
+ */
14
+ /** Verb → the field the workspace reads instead of `type`. */
15
+ export const TYPE_ALIASES = {
16
+ // Each entry is a verb whose handler reads this field (verified against apps/cloudflare/src).
17
+ 'ticket-create': 'issueType',
18
+ 'ticket-update': 'issueType',
19
+ 'ticket-link-create': 'linkType',
20
+ 'proposal-create': 'proposalType',
21
+ 'composition-create': 'kind',
22
+ 'record-thought': 'kind',
23
+ 'thought-record': 'kind',
24
+ };
25
+ /**
26
+ * Reconcile a top-level `type` with the wire's use of the name. Pure.
27
+ *
28
+ * ⚠ A `type` EQUAL TO THE ACTION is left alone — that is a caller spelling the action twice, which
29
+ * the wire already agrees with. Nested `type`s (canvas nodes, `updates.type`) never collide and are
30
+ * never inspected.
31
+ */
32
+ export function reconcileReservedType(action, input) {
33
+ if (!input || typeof input !== 'object' || !Object.prototype.hasOwnProperty.call(input, 'type'))
34
+ return { kind: 'none' };
35
+ const value = input.type;
36
+ if (value === undefined || value === action)
37
+ return { kind: 'none' };
38
+ const field = TYPE_ALIASES[action];
39
+ if (field) {
40
+ const { type: _dropped, ...rest } = input;
41
+ if (rest[field] !== undefined && rest[field] !== value) {
42
+ return {
43
+ kind: 'refused',
44
+ error: `both "type" (${JSON.stringify(value)}) and "${field}" (${JSON.stringify(rest[field])}) were given — `
45
+ + `"type" is the action name on the wire and would be dropped; pass only "${field}".`,
46
+ };
47
+ }
48
+ return {
49
+ kind: 'moved',
50
+ field,
51
+ input: { ...rest, [field]: value },
52
+ note: `"type" is reserved for the action name on the wire — sent as "${field}": ${JSON.stringify(value)}`,
53
+ };
54
+ }
55
+ return {
56
+ kind: 'refused',
57
+ error: `"type" is reserved for the action name on the wire, so ${action} would never see ${JSON.stringify(value)}. `
58
+ + `Use the verb's own field (see: beehaven describe ${action}).`,
59
+ };
60
+ }
@@ -140,6 +140,7 @@ export const VERB_SURFACE = {
140
140
  describe: { cls: 'BEYOND', why: 'alias', aliasOf: 'explain' },
141
141
  rpc: { cls: 'BEYOND', why: 'the raw gateway envelope, below `call`' },
142
142
  ticket: { cls: 'BEYOND', why: 'resolves a board before creating, and refuses a numeric priority the API inverts' },
143
+ inbox: { cls: 'PROVISIONAL', why: 'reads and manages workspace message threads; keep until door traffic is measured' },
143
144
  comment: { cls: 'BEYOND', why: 'addresses ANY surface by key/title and turns per-surface anchor flags into one call; prints who the @s reached' },
144
145
  comments: { cls: 'BEYOND', why: 'served by the comment module', aliasOf: 'comment' },
145
146
  mention: { cls: 'BEYOND', why: 'resolves an 8-char id prefix before acking, and reads your mention queue as whichever identity you are' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beehaven",
3
- "version": "0.8.21",
3
+ "version": "0.8.24",
4
4
  "description": "Beehaven CLI — command your Clearly workspace, agents, and canvas from the terminal",
5
5
  "private": false,
6
6
  "type": "module",
@@ -35,6 +35,7 @@ const SOURCES = {
35
35
  'agent-doodle-space':'apps/mcp-server/plugin/skills/agent-doodle-space/SKILL.md',
36
36
  'design-craft': 'apps/mcp-server/plugin/skills/design-craft/SKILL.md',
37
37
  'layout-systems': 'apps/mcp-server/plugin/skills/layout-systems/SKILL.md',
38
+ 'brand-identity': 'apps/mcp-server/plugin/skills/brand-identity/SKILL.md',
38
39
  'pair-on-canvas': 'apps/mcp-server/plugin/skills/pair-on-canvas/SKILL.md',
39
40
  'codebase-map': 'apps/mcp-server/plugin/skills/codebase-map/SKILL.md',
40
41
  'clearly-workspace': 'apps/mcp-server/plugin/skills/clearly-workspace/SKILL.md',
package/skills/README.md CHANGED
@@ -1,24 +1,26 @@
1
- # CLI-paired Claude Code skills
1
+ # Skills shipped with the CLI
2
2
 
3
- Claude Code skills that document built-in `beehaven` CLI verbs.
4
- Distinct from hive-paired skills (which live with each hive in
5
- `clearly-hives/<hive>/skills/`).
3
+ The `beehaven` tarball carries the agent skills a CLI-only user needs — the same set the Clearly
4
+ Mac app installs — so `curl | sh` teaches an agent Clearly without the app.
6
5
 
7
- A skill belongs here when it documents functionality that ships
8
- with the CLI itself, not with any specific hive. Currently:
6
+ ## Where each skill comes from
9
7
 
10
- - **`claude-shopper/`** — documents `beehaven shop find/at/search/get/checkout`
11
- (the shop@0 protocol surface). Not paired with any hive — the shop
12
- catalog is part of the CLI's Yellow Pages integration.
8
+ | Skill | Source of truth (edit HERE, never the copy) |
9
+ |---|---|
10
+ | `beehaven-cli` | `clearly-hives/beehaven-cli/skills/SKILL.md` |
11
+ | `clearly-agent`, `clearly-canvas`, `clearly-workspace`, `design-craft`, `layout-systems`, `brand-identity`, `pair-on-canvas`, `codebase-map`, `agent-home`, `agent-doodle-space` | `apps/mcp-server/plugin/skills/<name>/` in the monorepo |
12
+ | `claude-shopper` | this folder — hand-written, documents `beehaven shop` (the shop@0 protocol) |
13
13
 
14
- These aren't installed by `beehaven install` (that's for hive-paired
15
- skills via the `agentDocs.claudeCode` manifest field). If a user wants
16
- the claude-shopper skill, they install it manually:
14
+ `scripts/sync-skills.mjs` copies every monorepo source into `skills/<name>/` (`npm run sync:skills`,
15
+ and automatically at `prepack`) and writes `skills/index.json`. Its `SOURCES` table mirrors the
16
+ Mac app's `electron-builder*.yml` extraResources and `SHIPPED_SKILLS`
17
+ (`apps/electron/electron/agent-skills-installer.ts`); `apps/electron/scripts/verify-mcp-parity.mjs`
18
+ fails when the two sets disagree.
17
19
 
18
- ```bash
19
- cp -R packages/cli/skills/claude-shopper ~/.claude/skills/beehaven-shopper
20
- ```
20
+ ## Rules
21
21
 
22
- Or eventually via a `beehaven skills install-bundled <name>` verb (not
23
- yet built). See `beehaven_sdk/docs/LOCAL_HIVES.md` §11.5 for the
24
- hive-paired skill flow this complements.
22
+ - Edit the source, then run `npm run sync:skills`. A change made to a copy here is overwritten.
23
+ - Copies are generated, so they are listed in `.gitignore` (a second committed copy is a second copy
24
+ to drift). ⚠ `agent-home`, `agent-doodle-space`, `clearly-agent` and `clearly-workspace` are still
25
+ tracked from before that rule; treat them as generated all the same.
26
+ - Only `README.md` and `claude-shopper/` are hand-written here.