beehaven 0.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Claude Daemon Types
3
+ *
4
+ * Types for the 24/7 Claude daemon service, token management,
5
+ * and WebSocket gateway communication.
6
+ */
7
+ export const DEFAULT_DAEMON_CONFIG = {
8
+ autoStart: true,
9
+ wsPort: 18790,
10
+ maxIdleTime: 0, // Never auto-sleep
11
+ persistContext: true,
12
+ maxSessionHistory: 100,
13
+ defaultModel: undefined, // Use Claude's default
14
+ };
15
+ // ============================================
16
+ // IPC CHANNELS
17
+ // ============================================
18
+ /** IPC channel names for daemon communication */
19
+ export const DAEMON_IPC_CHANNELS = {
20
+ // Token management
21
+ SETUP_TOKEN: 'daemon:setup-token',
22
+ VALIDATE_TOKEN: 'daemon:validate-token',
23
+ TOKEN_STATUS: 'daemon:token-status',
24
+ CLEAR_TOKEN: 'daemon:clear-token',
25
+ // Daemon lifecycle
26
+ START: 'daemon:start',
27
+ STOP: 'daemon:stop',
28
+ RESTART: 'daemon:restart',
29
+ STATUS: 'daemon:status',
30
+ // WebSocket info
31
+ WS_URL: 'daemon:ws-url',
32
+ LOCAL_AUTH: 'daemon:local-auth',
33
+ // Events (main -> renderer)
34
+ STATUS_CHANGED: 'daemon:status-changed',
35
+ TOKEN_EXPIRING: 'daemon:token-expiring',
36
+ ERROR: 'daemon:error',
37
+ };
38
+ /**
39
+ * Default values for SMS commands
40
+ */
41
+ export const DEFAULT_SMS_COMMAND = {
42
+ status: 'pending',
43
+ retryCount: 0,
44
+ };
45
+ /**
46
+ * Parse SMS command intent using simple heuristics
47
+ * (Used as fallback if Gemini parsing fails)
48
+ */
49
+ export function parseIntentHeuristic(text) {
50
+ const lowerText = text.toLowerCase();
51
+ // Video creation patterns
52
+ if (lowerText.includes('tiktok') ||
53
+ lowerText.includes('video') ||
54
+ lowerText.includes('reel') ||
55
+ lowerText.includes('short')) {
56
+ const platformMatch = lowerText.match(/(tiktok|youtube|instagram|reel)/);
57
+ return {
58
+ type: 'video-create',
59
+ confidence: 0.7,
60
+ entities: {
61
+ platform: platformMatch?.[1] || 'tiktok',
62
+ topic: text.replace(/make|create|edit|me|a|about/gi, '').trim(),
63
+ },
64
+ rawText: text,
65
+ };
66
+ }
67
+ // Code generation patterns
68
+ if (lowerText.includes('create') ||
69
+ lowerText.includes('build') ||
70
+ lowerText.includes('website') ||
71
+ lowerText.includes('app') ||
72
+ lowerText.includes('code')) {
73
+ return {
74
+ type: 'code-generate',
75
+ confidence: 0.6,
76
+ entities: {
77
+ topic: text,
78
+ },
79
+ rawText: text,
80
+ };
81
+ }
82
+ // Research patterns
83
+ if (lowerText.includes('research') ||
84
+ lowerText.includes('find') ||
85
+ lowerText.includes('search') ||
86
+ lowerText.includes('look up')) {
87
+ return {
88
+ type: 'research',
89
+ confidence: 0.6,
90
+ entities: {
91
+ topic: text,
92
+ },
93
+ rawText: text,
94
+ };
95
+ }
96
+ // Query patterns
97
+ if (lowerText.startsWith('what') || lowerText.startsWith('how') || lowerText.startsWith('why')) {
98
+ return {
99
+ type: 'query',
100
+ confidence: 0.5,
101
+ entities: {},
102
+ rawText: text,
103
+ };
104
+ }
105
+ // Default to conversation
106
+ return {
107
+ type: 'conversation',
108
+ confidence: 0.3,
109
+ entities: {},
110
+ rawText: text,
111
+ };
112
+ }
@@ -0,0 +1,2 @@
1
+ export * from './types.js';
2
+ export * from './daemon-types.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,133 @@
1
+ /**
2
+ * status.json — shared schema between the daemon (writer) and the
3
+ * Clearly Dev Mac app tray (reader).
4
+ *
5
+ * The daemon writes `~/.beehaven/status.json` every 2 seconds with a
6
+ * snapshot of relay connectivity, pending RPCs, reconnect attempts,
7
+ * and version info. The tray polls this file on every menu rebuild to
8
+ * render live status (relay OK, daemon running PID N, etc).
9
+ *
10
+ * Without a shared schema, the two sides could drift — the daemon
11
+ * starts emitting a new field, the tray silently ignores it; the
12
+ * daemon renames a field, the tray reads `undefined` and shows wrong
13
+ * status. This file is the contract.
14
+ *
15
+ * Design choices:
16
+ * - No Zod / io-ts / runtime-type library — CLI keeps a near-zero
17
+ * runtime dependency footprint. A hand-written validator is ~80
18
+ * lines, as correct as any library for this shape.
19
+ * - Explicit schemaVersion so the tray can reject future snapshots
20
+ * that use an incompatible version — better than silently showing
21
+ * wrong data.
22
+ * - Bidirectional: the daemon calls `validateStatusSnapshot()` before
23
+ * writing (catches local drift); the tray calls
24
+ * `readAndValidateStatusFile()` after reading (catches remote drift).
25
+ */
26
+ /** Current schema version. Bump when a field changes semantics. */
27
+ export const STATUS_SCHEMA_VERSION = 1;
28
+ /**
29
+ * Validate an unknown value against the StatusSnapshot schema.
30
+ *
31
+ * Returns `{ ok: true, value }` with a typed snapshot on success, or
32
+ * `{ ok: false, errors: string[] }` with one message per failing field.
33
+ * Never throws. Rejects extra fields silently (tolerates daemons that
34
+ * emit extra fields newer than the tray knows about).
35
+ */
36
+ export function validateStatusSnapshot(v) {
37
+ const errors = [];
38
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
39
+ return { ok: false, errors: ['not an object'] };
40
+ }
41
+ const o = v;
42
+ if (o.schemaVersion !== STATUS_SCHEMA_VERSION) {
43
+ errors.push(`schemaVersion: expected ${STATUS_SCHEMA_VERSION}, got ${JSON.stringify(o.schemaVersion)}`);
44
+ }
45
+ if (typeof o.pid !== 'number' || !Number.isFinite(o.pid) || o.pid <= 0) {
46
+ errors.push(`pid: expected positive number, got ${JSON.stringify(o.pid)}`);
47
+ }
48
+ if (typeof o.updatedAt !== 'number' || !Number.isFinite(o.updatedAt) || o.updatedAt <= 0) {
49
+ errors.push(`updatedAt: expected positive number, got ${JSON.stringify(o.updatedAt)}`);
50
+ }
51
+ if (typeof o.relayConnected !== 'boolean') {
52
+ errors.push(`relayConnected: expected boolean, got ${typeof o.relayConnected}`);
53
+ }
54
+ if (typeof o.relayConfigured !== 'boolean') {
55
+ errors.push(`relayConfigured: expected boolean, got ${typeof o.relayConfigured}`);
56
+ }
57
+ if (o.relayEndpoint !== null && typeof o.relayEndpoint !== 'string') {
58
+ errors.push(`relayEndpoint: expected string or null, got ${typeof o.relayEndpoint}`);
59
+ }
60
+ if (typeof o.pendingRequests !== 'number' ||
61
+ !Number.isFinite(o.pendingRequests) ||
62
+ o.pendingRequests < 0) {
63
+ errors.push(`pendingRequests: expected non-negative number, got ${JSON.stringify(o.pendingRequests)}`);
64
+ }
65
+ if (typeof o.reconnectAttempts !== 'number' ||
66
+ !Number.isFinite(o.reconnectAttempts) ||
67
+ o.reconnectAttempts < 0) {
68
+ errors.push(`reconnectAttempts: expected non-negative number, got ${JSON.stringify(o.reconnectAttempts)}`);
69
+ }
70
+ if (typeof o.version !== 'string' || o.version.length === 0) {
71
+ errors.push(`version: expected non-empty string, got ${JSON.stringify(o.version)}`);
72
+ }
73
+ if (errors.length > 0)
74
+ return { ok: false, errors };
75
+ return { ok: true, value: o };
76
+ }
77
+ /**
78
+ * Read + validate the status file. Returns the parsed snapshot on
79
+ * success, or a structured error discriminator. Handles:
80
+ * - missing file → `{ ok: false, reason: 'not-found' }`
81
+ * - unreadable / permissions → `{ ok: false, reason: 'read-failed', detail }`
82
+ * - invalid JSON → `{ ok: false, reason: 'malformed', detail }`
83
+ * - schema-version mismatch → `{ ok: false, reason: 'version-mismatch', expected, got }`
84
+ * - shape validation failure → `{ ok: false, reason: 'schema', errors }`
85
+ * - stale (older than maxAgeMs) → `{ ok: false, reason: 'stale', ageMs }`
86
+ *
87
+ * Reader-side helper. Use from the Mac app tray instead of raw
88
+ * `JSON.parse(readFileSync(path))` to get typed errors.
89
+ */
90
+ export async function readAndValidateStatusFile(path, opts = {}) {
91
+ const { readFile } = await import('node:fs/promises');
92
+ let raw;
93
+ try {
94
+ raw = await readFile(path, 'utf8');
95
+ }
96
+ catch (err) {
97
+ if (err?.code === 'ENOENT')
98
+ return { ok: false, reason: 'not-found' };
99
+ return { ok: false, reason: 'read-failed', detail: err?.message ?? String(err) };
100
+ }
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(raw);
104
+ }
105
+ catch (err) {
106
+ return { ok: false, reason: 'malformed', detail: err?.message ?? 'JSON parse failed' };
107
+ }
108
+ // Fast version check — if the version doesn't match, don't bother
109
+ // walking fields. The tray can surface "daemon is newer/older, restart"
110
+ // without a confusing schema-error cascade.
111
+ if (parsed?.schemaVersion !== STATUS_SCHEMA_VERSION) {
112
+ return {
113
+ ok: false,
114
+ reason: 'version-mismatch',
115
+ expected: STATUS_SCHEMA_VERSION,
116
+ got: parsed?.schemaVersion,
117
+ };
118
+ }
119
+ const result = validateStatusSnapshot(parsed);
120
+ if (!result.ok)
121
+ return {
122
+ ok: false,
123
+ reason: 'schema',
124
+ errors: result.errors,
125
+ };
126
+ if (opts.maxAgeMs !== undefined) {
127
+ const age = Date.now() - result.value.updatedAt;
128
+ if (age > opts.maxAgeMs) {
129
+ return { ok: false, reason: 'stale', ageMs: age };
130
+ }
131
+ }
132
+ return { ok: true, value: result.value };
133
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * shell-session — the remote shell's working directory, owned by the CLIENT, one per LOGIN.
3
+ *
4
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────────────────────
5
+ * The workspace shell kept its cwd server-side in a `WeakMap` keyed on
6
+ * `input.sessionId || this._activeSessionId || 'default'`. The CLI never sent a `sessionId`, and
7
+ * `_activeSessionId` exists only on the Shopify store DO — so on a workspace DO the key was the
8
+ * literal string `'default'` for every caller alive. MEASURED on staging 2026-09-05:
9
+ *
10
+ * jinri-claude bash 'cd Q1 && pwd' -> cwd '~/Q1'
11
+ * northwind-scribe bash 'pwd' -> cwd '~/Q1' <- another agent's directory
12
+ *
13
+ * A relative path therefore resolved against whatever a sibling agent last `cd`'d to, and both
14
+ * sides reported success. (CBH-39.)
15
+ *
16
+ * ── WHY THE CLIENT OWNS IT RATHER THAN THE SERVER ──────────────────────────────────────────
17
+ * Sending a `sessionId` would have been a one-line fix and leaves three properties broken: the map
18
+ * is per-DO-instance RAM, so an eviction silently resets the directory mid-task; nothing can
19
+ * INSPECT it without executing a command; and the map grows per session with no eviction policy.
20
+ * Holding the cwd in a file and sending it makes the transport stateless, survives both DO
21
+ * eviction and daemon restarts, and lets `pwd` answer with no round trip — the same three
22
+ * arguments `cli-state.ts` already makes for keeping the target cursor in a file.
23
+ *
24
+ * ── ⚠⚠ THE KEY IS THE LOGIN, NOT THE IDENTITY — AND THAT DIFFERS FROM `target.json` ─────────
25
+ * `cli-state.effectiveScope()` resolves the process scope THROUGH `agentForScope()` to the bound
26
+ * agent NAME, deliberately: *"A LOGGED-IN AGENT'S CURSOR FOLLOWS THE AGENT, NOT THE PROCESS …
27
+ * a durable agent would start at home each time and lose the workspace it had chosen."* That is
28
+ * right for a cursor and wrong for a cwd, and the two are not the same question:
29
+ *
30
+ * WHICH WORKSPACE am I in -> a standing choice -> follows the identity (target.json)
31
+ * WHERE am I inside it -> this shell, right now -> follows the LOGIN (this file)
32
+ *
33
+ * Two Claude sessions in two terminals, both logged in as `jinri-claude`, are two shells. They
34
+ * share a workspace and must not share a directory — which is exactly how real terminals behave,
35
+ * and why resetting to `~` on a new session is correct here rather than a regression.
36
+ *
37
+ * So this uses the RAW `deriveAgentScope()` (`claude-<sessionId>`, `codex-…`, `pid-<ppid>`) and
38
+ * must NOT be "fixed" to use `effectiveScope()`. A guard pins that.
39
+ *
40
+ * ⚠ AN EXPLICIT `BEEHAVEN_AGENT=<name>` IN TWO TERMINALS STILL SHARES ONE FILE, because that is
41
+ * the one handle a person can set identically on purpose. Naming the same scope twice is a
42
+ * statement that they are the same shell; the automatic handles are all per-session.
43
+ *
44
+ * ⚠ A HUMAN (no scope) KEEPS THE UNSCOPED FILE, mirroring `target.json` exactly — nothing about an
45
+ * existing interactive shell moves.
46
+ */
47
+ import { deriveAgentScope } from './agent-scope.js';
48
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
49
+ import { join } from 'node:path';
50
+ import { homedir } from 'node:os';
51
+ const STATE_DIR = join(homedir(), '.beehaven');
52
+ /**
53
+ * The per-login key.
54
+ *
55
+ * ⚠ SANITISED, because it lands in a filesystem path — `BEEHAVEN_AGENT=../../..` would otherwise
56
+ * escape STATE_DIR. Same rule as `cli-state.targetFileFor`; a name that sanitises to nothing falls
57
+ * back to the shared file rather than writing `cwd-.json`.
58
+ */
59
+ export function shellSessionKey(env = process.env) {
60
+ return (deriveAgentScope(env) || '').trim().replace(/[^A-Za-z0-9_-]/g, '');
61
+ }
62
+ function cwdFileFor(key = shellSessionKey()) {
63
+ return join(STATE_DIR, key ? `cwd-${key}.json` : 'cwd.json');
64
+ }
65
+ /** Exported for the scoping guard — the path rule takes an env var into a path, so it is asserted. */
66
+ export const __cwdFileFor = cwdFileFor;
67
+ /**
68
+ * ⚠⚠ ONE FUNCTION DECIDES THE TARGET KEY, BECAUSE TWO DOORS REACH THE SHELL AND THEY SPELL IT
69
+ * DIFFERENTLY.
70
+ *
71
+ * `beehaven ls ~` arrives through `execNotesShell` with `buildingType === 'home'`. `beehaven call
72
+ * bash …` arrives through `cmdCall`, where the address comes from the target cursor — and `cd home`
73
+ * PERSISTS the canonical `workspace`, not `home` (`CANONICAL_HOME_ADDRESS`). Left to compute their
74
+ * own keys the two doors would file one shell's cwd under `home` and the other's under `workspace`,
75
+ * so `cd` through one verb would be invisible to the next — the same shell, two directories,
76
+ * nothing reporting a problem.
77
+ *
78
+ * Every home spelling collapses to `home`; anything else is used as given.
79
+ */
80
+ export function shellTargetKey(address) {
81
+ const a = String(address ?? '').trim();
82
+ if (!a || a === 'home' || a === 'me' || a === 'workspace')
83
+ return 'home';
84
+ // `--building type:id` and the address grammar's `type/id` name the same DO — normalise the
85
+ // separator, or the same workspace gets two entries depending on which flag was used.
86
+ return a.replace(':', '/');
87
+ }
88
+ function readMap(key = shellSessionKey()) {
89
+ try {
90
+ const f = cwdFileFor(key);
91
+ if (!existsSync(f))
92
+ return {};
93
+ const parsed = JSON.parse(readFileSync(f, 'utf-8'));
94
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
95
+ }
96
+ catch {
97
+ // ⚠ NEVER THROWS. This runs on every shell call; a corrupt file must cost the cwd, not the command.
98
+ return {};
99
+ }
100
+ }
101
+ /** The stored cwd for a target, or null when this login has never moved there. */
102
+ export function readShellCwd(target, key = shellSessionKey()) {
103
+ const v = readMap(key)[target];
104
+ return typeof v === 'string' && v ? v : null;
105
+ }
106
+ /**
107
+ * Persist the cwd the server reported.
108
+ *
109
+ * ⚠ WRITTEN FROM THE RESPONSE, NEVER PREDICTED FROM THE COMMAND. Guessing that `cd X` lands in `X`
110
+ * means re-implementing the shell's own resolution in the client, where it would drift; the handler
111
+ * already returns the directory it actually ran in, so echoing that back is the only value that
112
+ * cannot disagree with the server.
113
+ *
114
+ * ⚠ `~` IS NOT STORED. It is the default, so writing it just grows the file with entries that mean
115
+ * "nothing" — and an absent entry and an entry saying `~` must not be two ways to say one thing.
116
+ */
117
+ export function writeShellCwd(target, cwd, key = shellSessionKey()) {
118
+ try {
119
+ const clean = String(cwd ?? '').trim();
120
+ const map = readMap(key);
121
+ if (!clean || clean === '~' || clean === '/home') {
122
+ if (!(target in map))
123
+ return;
124
+ delete map[target];
125
+ }
126
+ else {
127
+ if (map[target] === clean)
128
+ return; // no write when nothing moved
129
+ map[target] = clean;
130
+ }
131
+ mkdirSync(STATE_DIR, { recursive: true });
132
+ const f = cwdFileFor(key);
133
+ const tmp = `${f}.tmp`;
134
+ writeFileSync(tmp, JSON.stringify(map, null, 2), { mode: 0o600 });
135
+ renameSync(tmp, f); // atomic — two shells may write at once
136
+ }
137
+ catch {
138
+ /* a cwd we could not persist is a cwd that resets; never fail the command over it */
139
+ }
140
+ }
141
+ /**
142
+ * The actions whose payload carries a shell working directory.
143
+ *
144
+ * ⚠ AN ALLOWLIST, NOT A GUESS. Attaching `cwd` to every action would put an unexpected key in
145
+ * hundreds of payloads; `z.object()` strips unknown keys, so most would ignore it silently and any
146
+ * that happened to declare a `cwd` would be handed one that means something else entirely.
147
+ */
148
+ const SHELL_ACTIONS = new Set(['bash', 'notes-bash']);
149
+ export function isShellAction(action) {
150
+ return SHELL_ACTIONS.has(String(action || '').trim());
151
+ }
152
+ /**
153
+ * Attach this login's cwd to a shell payload.
154
+ *
155
+ * ⚠ AN EXPLICIT `cwd` IN THE CALLER'S OWN INPUT WINS AND IS LEFT ALONE — `beehaven call bash
156
+ * '{"command":"ls","cwd":"/local/src"}'` is how the LOCAL machine is addressed, and overwriting it
157
+ * would silently redirect a command from the user's laptop to the workspace.
158
+ */
159
+ export function attachShellCwd(action, input, target) {
160
+ if (!isShellAction(action))
161
+ return input;
162
+ if (typeof input?.cwd === 'string' && input.cwd.trim())
163
+ return input;
164
+ /**
165
+ * ⚠⚠ ALWAYS ASSERT A cwd — SENDING NOTHING STILL INHERITS ANOTHER LOGIN'S DIRECTORY.
166
+ *
167
+ * The first version sent the field only when a directory had been stored, which reads as the
168
+ * obvious economy and leaves the bug half-open. MEASURED against the deployed fix: login A did
169
+ * `cd Q1`; login B, having stored nothing, sent no `cwd`, the server fell back to the shared
170
+ * session map, and B ran in `~/Q1` — the exact leak, surviving the fix for it.
171
+ *
172
+ * Absence has to keep meaning "a caller that does not manage its own cwd" (the web client, MCP,
173
+ * an older CLI), because that is what the session map is still there to serve. So a client that
174
+ * DOES manage its own must say `~` out loud rather than staying quiet: at root by decision, not
175
+ * by default.
176
+ */
177
+ return { ...input, cwd: readShellCwd(target) ?? '~' };
178
+ }
179
+ /**
180
+ * Store the cwd a shell response reported.
181
+ *
182
+ * ⚠ `cwdReset` MEANS THE STORED PATH IS DEAD — the folder was deleted or renamed since. The server
183
+ * has already fallen back to the root and said so; clearing the entry is what stops this login
184
+ * sending the dead path on every subsequent call forever.
185
+ */
186
+ export function absorbShellCwd(action, result, target) {
187
+ if (!isShellAction(action))
188
+ return;
189
+ const r = result;
190
+ if (!r || typeof r !== 'object')
191
+ return;
192
+ if (typeof r.cwdReset === 'string' && r.cwdReset) {
193
+ writeShellCwd(target, '~');
194
+ return;
195
+ }
196
+ if (typeof r.cwd === 'string' && r.cwd)
197
+ writeShellCwd(target, r.cwd);
198
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * speak-lock.ts — one voice at a time, machine-wide.
3
+ *
4
+ * ⚠⚠ WHY THIS EXISTS: TWO `speak`s AT ONCE SOUND LIKE A BROKEN MODEL, NOT LIKE TWO
5
+ * MESSAGES. Reported as the voice "spazzing out and stuttering", with the telling
6
+ * detail that *the start was fine* — which is exactly the shape of overlap: the first
7
+ * utterance plays alone and is clean, then the second begins on top of it and the two
8
+ * become an unintelligible smear.
9
+ *
10
+ * Reproduced directly: three backgrounded `speak`s fired three seconds apart showed
11
+ * **two concurrent `afplay` processes**. Nothing in the audio was wrong — the model,
12
+ * the temperature and the joins all measured clean. The defect was that nobody owned
13
+ * the speaker.
14
+ *
15
+ * ⚠ IT SERIALISES, IT DOES NOT REFUSE. Dropping the second utterance would lose
16
+ * something the caller wanted said; playing it later is almost always right for
17
+ * status narration. A caller who genuinely wants to talk over the top can opt out.
18
+ *
19
+ * ⚠ THE LOCK IS ADVISORY AND SELF-HEALING. It holds a pid; a lock whose owner is gone
20
+ * is taken immediately, so a killed `speak` cannot mute the machine until reboot —
21
+ * which would be a far worse failure than the overlap it prevents.
22
+ */
23
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ const LOCK_DIR = join(homedir(), '.beehaven');
27
+ const LOCK_FILE = join(LOCK_DIR, 'speak.lock');
28
+ /** Longest we will wait for another utterance before speaking anyway. */
29
+ const MAX_WAIT_MS = 5 * 60_000;
30
+ const POLL_MS = 250;
31
+ function ownerAlive() {
32
+ try {
33
+ if (!existsSync(LOCK_FILE))
34
+ return null;
35
+ const pid = Number(readFileSync(LOCK_FILE, 'utf8').trim());
36
+ if (!Number.isFinite(pid) || pid <= 0)
37
+ return null;
38
+ // Signal 0 tests existence without touching the process.
39
+ try {
40
+ process.kill(pid, 0);
41
+ return pid;
42
+ }
43
+ catch {
44
+ return null; // owner is gone — the lock is stale
45
+ }
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
52
+ /**
53
+ * Wait for the speaker, then hold it. Returns a release function that is safe to
54
+ * call more than once.
55
+ */
56
+ export async function acquireSpeakLock(opts = {}) {
57
+ if (opts.wait === false)
58
+ return () => { };
59
+ try {
60
+ mkdirSync(LOCK_DIR, { recursive: true });
61
+ }
62
+ catch { /* the write below will report anything real */ }
63
+ const deadline = Date.now() + MAX_WAIT_MS;
64
+ while (ownerAlive() !== null && Date.now() < deadline) {
65
+ await sleep(POLL_MS);
66
+ }
67
+ try {
68
+ writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o600 });
69
+ }
70
+ catch {
71
+ // ⚠ AN UNWRITABLE LOCK MUST NOT SILENCE THE UTTERANCE. Overlapping audio is a
72
+ // worse outcome than no audio only if you value tidiness over being heard.
73
+ return () => { };
74
+ }
75
+ let released = false;
76
+ const release = () => {
77
+ if (released)
78
+ return;
79
+ released = true;
80
+ try {
81
+ // Only remove it if it is still OURS — a lock we waited past the deadline for
82
+ // may since have been taken by someone else, and deleting theirs would let a
83
+ // third utterance in on top.
84
+ if (existsSync(LOCK_FILE) && readFileSync(LOCK_FILE, 'utf8').trim() === String(process.pid)) {
85
+ unlinkSync(LOCK_FILE);
86
+ }
87
+ }
88
+ catch { /* nothing useful to do */ }
89
+ };
90
+ // ⚠ RELEASE ON THE WAYS OUT THAT ARE NOT A NORMAL RETURN. A ^C mid-utterance would
91
+ // otherwise leave a lock owned by a dead pid — self-healing, but only on the next
92
+ // call, which is one confusing wait too many.
93
+ process.once('exit', release);
94
+ process.once('SIGINT', () => { release(); process.exit(130); });
95
+ process.once('SIGTERM', () => { release(); process.exit(143); });
96
+ return release;
97
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Lightweight CLI spinner and color utilities — zero dependencies.
3
+ */
4
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+ export class Spinner {
6
+ timer = null;
7
+ frameIdx = 0;
8
+ text;
9
+ constructor(text) {
10
+ this.text = text;
11
+ }
12
+ start() {
13
+ this.timer = setInterval(() => {
14
+ const frame = frames[this.frameIdx % frames.length];
15
+ process.stderr.write(`\r${color.cyan(frame)} ${this.text}`);
16
+ this.frameIdx++;
17
+ }, 80);
18
+ return this;
19
+ }
20
+ update(text) {
21
+ this.text = text;
22
+ }
23
+ succeed(text) {
24
+ this.stop();
25
+ console.log(`${color.green('✓')} ${text ?? this.text}`);
26
+ }
27
+ fail(text) {
28
+ this.stop();
29
+ console.error(`${color.red('✗')} ${text ?? this.text}`);
30
+ }
31
+ stop() {
32
+ if (this.timer) {
33
+ clearInterval(this.timer);
34
+ this.timer = null;
35
+ }
36
+ process.stderr.write('\r\x1b[K'); // Clear line
37
+ }
38
+ }
39
+ export const color = {
40
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
41
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
42
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
43
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
44
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
45
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
46
+ gray: (s) => `\x1b[90m${s}\x1b[0m`,
47
+ };
48
+ export function table(headers, rows) {
49
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
50
+ const sep = widths.map((w) => '─'.repeat(w + 2)).join('┼');
51
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
52
+ const formatRow = (row) => row.map((c, i) => ` ${pad(c, widths[i])} `).join('│');
53
+ return [formatRow(headers), sep, ...rows.map(formatRow)].join('\n');
54
+ }
55
+ export function spinner(text) {
56
+ return new Spinner(text);
57
+ }