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,91 @@
1
+ /**
2
+ * Composition → local repo bindings (#47b).
3
+ *
4
+ * A Clearly composition (a canvas "board") can be bound to a real local
5
+ * git repo on THIS machine, so a daemon-spawned coding agent works in the
6
+ * actual codebase — and shares a Claude Code project 1:1 with that repo.
7
+ *
8
+ * board → /Users/me/projects/motion_studio → ~/.claude/projects/<encoded-cwd>
9
+ *
10
+ * The binding is inherently LOCAL + per-machine — absolute repo paths
11
+ * differ from machine to machine, exactly like Claude Code's own project
12
+ * registry. The composition id is the stable CLOUD identity; this map is
13
+ * the local answer to "on THIS machine, where does that board live?".
14
+ *
15
+ * ~/.clearly/composition-repos.json
16
+ * { "<compositionId>": "/Users/me/projects/motion_studio" }
17
+ *
18
+ * Bound → the daemon uses the repo as the session cwd verbatim and does
19
+ * NOT seed/refresh a CLAUDE.md there (the repo owns its own
20
+ * context). The agent's conversation lands in the repo's own
21
+ * Claude Code project dir → 1:1 with `claude` run from that repo.
22
+ * Unbound→ the existing `~/.clearly/compositions/<id>` scratch-dir
23
+ * behavior is unchanged (no regression).
24
+ *
25
+ * Kept in its own module (no Agent-SDK imports) so both the daemon
26
+ * (`daemon-omni.ts`) and the CLI (`beehaven composition …`) can read/write
27
+ * it without the CLI dragging in `@anthropic-ai/claude-agent-sdk`.
28
+ *
29
+ * The map is read fresh on every cwd resolution, so a new binding takes
30
+ * effect on the NEXT omni session for that composition — no daemon
31
+ * restart required to (un)bind.
32
+ */
33
+ import { homedir } from 'node:os';
34
+ import { join, dirname } from 'node:path';
35
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
36
+ export function compositionRepoMapPath() {
37
+ return join(homedir(), '.clearly', 'composition-repos.json');
38
+ }
39
+ /** Read the whole map. Returns {} on missing/corrupt file — bindings are
40
+ * best-effort enhancements, never a hard dependency of a session. */
41
+ export function readCompositionRepoMap() {
42
+ try {
43
+ const parsed = JSON.parse(readFileSync(compositionRepoMapPath(), 'utf8'));
44
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
45
+ // Keep only string→string entries; drop anything malformed.
46
+ const out = {};
47
+ for (const [k, v] of Object.entries(parsed)) {
48
+ if (typeof v === 'string' && v.trim())
49
+ out[k] = v;
50
+ }
51
+ return out;
52
+ }
53
+ return {};
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ }
59
+ /**
60
+ * The absolute repo path a composition is bound to, or null.
61
+ *
62
+ * Returns a path only when the binding exists AND the directory is
63
+ * present on disk. A stale binding (repo moved or deleted) resolves to
64
+ * null so the caller falls back to the scratch dir rather than erroring
65
+ * on a missing cwd.
66
+ */
67
+ export function resolveBoundRepo(compositionId) {
68
+ const bound = readCompositionRepoMap()[compositionId];
69
+ if (typeof bound === 'string' && bound.trim() && existsSync(bound))
70
+ return bound;
71
+ return null;
72
+ }
73
+ /**
74
+ * Bind (repoPath set) or unbind (repoPath null) a composition. Last write
75
+ * wins — fine for a single-user local file. Creates `~/.clearly/` if
76
+ * needed. Returns the resulting map.
77
+ */
78
+ export function setCompositionRepo(compositionId, repoPath) {
79
+ const map = readCompositionRepoMap();
80
+ if (repoPath)
81
+ map[compositionId] = repoPath;
82
+ else
83
+ delete map[compositionId];
84
+ const p = compositionRepoMapPath();
85
+ mkdirSync(dirname(p), { recursive: true });
86
+ writeFileSync(p, `${JSON.stringify(map, null, 2)}\n`, 'utf8');
87
+ return map;
88
+ }
89
+ export function listCompositionRepos() {
90
+ return readCompositionRepoMap();
91
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * daemon-avatar — let the daemon's OWN agent harness (Monet) drive a
3
+ * local avatar as a first-class agent principal.
4
+ *
5
+ * A terminal Claude Code session signs in via `beehaven agent login` and
6
+ * carries its token on each call. The daemon's in-process harness has no
7
+ * CLI to sign in through, so it mints its OWN AgentIdentity here
8
+ * ('monet', kind 'daemon-agent') and calls the local avatar building with
9
+ * that identity's CallerContext — exactly like the app-rpc path does for
10
+ * CLI agents. The result: Monet drives its own avatar instance
11
+ * (getBuilding('avatar', 'monet')), distinct from every terminal agent's
12
+ * avatar.
13
+ *
14
+ * Wire-up: pass `driveAvatar: (action, args) => driveAvatarAsMonet(
15
+ * daemonAppManager, action, args)` into `createClearlyMcpServer(...)` so
16
+ * the harness gets `avatar_say` / `avatar_speak` / `avatar_get_state` /
17
+ * `avatar_emote` / `avatar_react` tools.
18
+ */
19
+ import { mintAgentToken, verifyAgentToken } from './agent-identity-daemon.js';
20
+ import { loadAgent, saveAgent } from './agent-state.js';
21
+ /** Default voice per built-in maid avatar — used when auto-minting an
22
+ * avatar's agent identity. The user can override per-avatar in the maid
23
+ * chat voice settings (which re-mints with the chosen voice). */
24
+ const AVATAR_DEFAULT_VOICE = {
25
+ mimi: { provider: 'openai', voiceId: 'marin' },
26
+ lilith: { provider: 'elevenlabs' },
27
+ omni: { provider: 'openai', voiceId: 'nova' },
28
+ monet: { provider: 'openai', voiceId: 'marin' },
29
+ };
30
+ export const MONET_AGENT_ID = 'monet';
31
+ export const MONET_AVATAR_ID = 'monet';
32
+ let _monetCaller = null;
33
+ let _monetToken = null;
34
+ /**
35
+ * Mint (once, then cache) Monet's daemon-agent identity and return both
36
+ * the token and the CallerContext the avatar building trusts.
37
+ */
38
+ export async function ensureMonetIdentity(userId = 'local') {
39
+ if (_monetToken && _monetCaller)
40
+ return { token: _monetToken, caller: _monetCaller };
41
+ const token = await mintAgentToken({
42
+ agentId: MONET_AGENT_ID,
43
+ kind: 'daemon-agent',
44
+ displayName: 'Monet',
45
+ avatarId: MONET_AVATAR_ID,
46
+ userId,
47
+ permissions: ['avatar:say', 'avatar:speak', 'avatar:emote'],
48
+ });
49
+ const claims = await verifyAgentToken(token);
50
+ if (!claims)
51
+ throw new Error('monet identity failed self-verification');
52
+ _monetCaller = {
53
+ role: 'agent',
54
+ agentId: claims.agentId,
55
+ avatarId: claims.avatarId,
56
+ userId: claims.userId,
57
+ displayName: claims.displayName,
58
+ permissions: claims.permissions,
59
+ };
60
+ _monetToken = token;
61
+ return { token: _monetToken, caller: _monetCaller };
62
+ }
63
+ /**
64
+ * Drive the local avatar as Monet. `appManager` is the daemon's
65
+ * LocalAppManager. Resolves the avatar instance for Monet's avatarId and
66
+ * calls the action with Monet's CallerContext as the 3rd arg.
67
+ */
68
+ export async function driveAvatarAsMonet(appManager, action, input = {}, userId = 'local') {
69
+ if (!appManager || typeof appManager.getBuilding !== 'function') {
70
+ throw new Error('local app manager unavailable — cannot drive avatar');
71
+ }
72
+ const { caller } = await ensureMonetIdentity(userId);
73
+ const handle = appManager.getBuilding('avatar', caller.avatarId);
74
+ const workspace = handle?.instance;
75
+ if (!workspace || typeof workspace.call !== 'function') {
76
+ throw new Error('AGENT_UNAVAILABLE_SIGNED_OUT: the avatar hive is not loaded (daemon signed out?).');
77
+ }
78
+ return workspace.call(action, input, caller);
79
+ }
80
+ /** Test seam — clear the cached identity. */
81
+ export function _resetMonetIdentity() {
82
+ _monetCaller = null;
83
+ _monetToken = null;
84
+ }
85
+ /**
86
+ * Ensure a STORED agent identity exists for `avatarId`, so spawning daemon
87
+ * claude with `BEEHAVEN_AGENT=<avatarId>` resolves to a valid token whose
88
+ * `meta.voice` drives `beehaven speak`. Auto-mints + persists on first use
89
+ * (per-avatar, with a sensible default voice), refreshes when near expiry,
90
+ * and preserves any voice the user has already set. Returns the agent name
91
+ * to place in `BEEHAVEN_AGENT`.
92
+ *
93
+ * This is the "auto-mint, edit in settings" provisioning: the daemon creates
94
+ * the identity transparently; the maid voice settings later re-mint it with
95
+ * the user's chosen voice via `agent rebind --voice` / `agent login --voice`.
96
+ */
97
+ export async function ensureAvatarAgent(avatarId, userId = 'local') {
98
+ const name = avatarId;
99
+ const existing = loadAgent(name);
100
+ // Reuse while comfortably unexpired (5-min skew margin).
101
+ if (existing && existing.expiresAt > Date.now() + 5 * 60_000)
102
+ return name;
103
+ const voice = existing?.voice ?? AVATAR_DEFAULT_VOICE[avatarId] ?? { provider: 'openai' };
104
+ const displayName = existing?.displayName ?? avatarId.charAt(0).toUpperCase() + avatarId.slice(1);
105
+ const token = await mintAgentToken({
106
+ agentId: avatarId,
107
+ kind: 'claude-code',
108
+ displayName,
109
+ avatarId,
110
+ userId,
111
+ permissions: ['avatar:speak', 'avatar:say', 'avatar:emote'],
112
+ meta: { voice },
113
+ });
114
+ const claims = await verifyAgentToken(token);
115
+ if (!claims)
116
+ throw new Error(`ensureAvatarAgent: self-verify failed for "${avatarId}"`);
117
+ const record = {
118
+ agentId: avatarId,
119
+ kind: 'claude-code',
120
+ displayName,
121
+ avatarId,
122
+ userId,
123
+ voice,
124
+ token,
125
+ issuedAt: claims.iat,
126
+ expiresAt: claims.exp,
127
+ };
128
+ saveAgent(name, record);
129
+ return name;
130
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * claude-code hive auto-install (#24) — ensure the claude-code hive
3
+ * is loaded into the daemon on every launch in dev mode.
4
+ *
5
+ * Why: the dev Mac app ships the `claude-code` hive in its bundled
6
+ * `Resources/beehaven/hives/claude-code/` directory, but until the
7
+ * user manually runs `beehaven install claude-code`, the daemon
8
+ * doesn't load it. That means a fresh `git clone` → `npm run
9
+ * install:dev` cycle leaves Claude unable to call canvas actions
10
+ * because the bridge isn't wired.
11
+ *
12
+ * Auto-install: on daemon ready, check if claude-code is registered;
13
+ * if not, install it from the bundled path. Idempotent — subsequent
14
+ * launches no-op.
15
+ *
16
+ * Wiring (Mac app rebuild required):
17
+ * In packages/cli/src/daemon.ts, after the IPC server is listening:
18
+ *
19
+ * import { ensureClaudeCodeInstalled } from './daemon-claude-code-auto-install.js';
20
+ * await ensureClaudeCodeInstalled(localAppManager, bundledHivesDir);
21
+ *
22
+ * `bundledHivesDir` resolves to:
23
+ * - dev: process.resourcesPath + '/beehaven/hives'
24
+ * - cli: ../../../hives (monorepo path)
25
+ */
26
+ import { resolve as resolvePath } from 'node:path';
27
+ import { existsSync } from 'node:fs';
28
+ /**
29
+ * Idempotent — installs the claude-code hive if missing, no-ops
30
+ * otherwise. Safe to call on every daemon startup.
31
+ */
32
+ export async function ensureClaudeCodeInstalled(ctx) {
33
+ const log = ctx.log ?? (() => { });
34
+ if (ctx.localAppManager.isInstalled('claude-code')) {
35
+ log('info', 'auto-install', 'claude-code already installed — skipping');
36
+ return { alreadyInstalled: true, installed: false };
37
+ }
38
+ const sourcePath = resolvePath(ctx.bundledHivesDir, 'claude-code');
39
+ if (!existsSync(sourcePath)) {
40
+ log('warn', 'auto-install', `claude-code source missing at ${sourcePath} — bundled-hives directory may be incomplete`);
41
+ return { alreadyInstalled: false, installed: false, reason: 'source-missing' };
42
+ }
43
+ try {
44
+ await ctx.localAppManager.install(sourcePath);
45
+ log('info', 'auto-install', `claude-code installed from ${sourcePath}`);
46
+ return { alreadyInstalled: false, installed: true };
47
+ }
48
+ catch (err) {
49
+ log('warn', 'auto-install', `claude-code install failed: ${err?.message ?? err}`);
50
+ return { alreadyInstalled: false, installed: false, reason: err?.message ?? 'install-failed' };
51
+ }
52
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * daemon-client — Connect to the local Beehaven daemon over IPC.
3
+ *
4
+ * Lets in-process tooling (the `beehaven` CLI itself, scripts, tests) call
5
+ * worker routes via the daemon's `daemon-fetch` proxy without ever holding
6
+ * the user's Firebase token directly. The token lives in the daemon process
7
+ * and is attached as `Authorization: Bearer …` for each forwarded request.
8
+ *
9
+ * Usage:
10
+ * import { daemonFetch, daemonRpc, isDaemonRunning } from './daemon-client.js';
11
+ *
12
+ * if (await isDaemonRunning()) {
13
+ * const res = await daemonFetch('GET', 'https://relay.clearly.sh/admin/users');
14
+ * console.log(res.body);
15
+ * }
16
+ */
17
+ import net from 'net';
18
+ import { existsSync } from 'fs';
19
+ import { resolveClientAuthToken } from './ipc-auth.js';
20
+ import { activeAgentWrap } from './agent-attribution.js';
21
+ const SOCKET_PATH = '/tmp/beehaven-ipc.sock';
22
+ const TIMEOUT_MS = 60_000;
23
+ const AUTH_TIMEOUT_MS = 3_000;
24
+ /** One attempt. Resolves, never rejects. */
25
+ function probeOnce(deadlineMs) {
26
+ return new Promise((resolve) => {
27
+ let connected = false;
28
+ const sock = net.createConnection(SOCKET_PATH);
29
+ const timer = setTimeout(() => {
30
+ sock.destroy();
31
+ // ⚠ CONNECTED-BUT-SILENT IS NOT "DOWN". The kernel accepted us, so a listener exists.
32
+ resolve({ state: connected ? 'silent' : 'down', attempts: 1 });
33
+ }, deadlineMs);
34
+ sock.once('connect', () => { connected = true; });
35
+ sock.once('data', () => {
36
+ clearTimeout(timer);
37
+ sock.end();
38
+ resolve({ state: 'up', attempts: 1 });
39
+ });
40
+ sock.once('error', (err) => {
41
+ clearTimeout(timer);
42
+ resolve({ state: 'down', attempts: 1, code: err.code });
43
+ });
44
+ });
45
+ }
46
+ /** ⚠ Deliberately short — see the measured p90 above. Three of these is still under a second. */
47
+ const PROBE_DEADLINE_MS = 1000;
48
+ const PROBE_ATTEMPTS = 3;
49
+ const PROBE_BACKOFF_MS = 150;
50
+ /**
51
+ * Probe the daemon, retrying a TRANSIENT failure before concluding anything.
52
+ *
53
+ * ⚠⚠ THE RETRY IS FREE ON THE HAPPY PATH AND ON THE COMMON SAD PATH. A first attempt that answers
54
+ * costs one connect (p50 0.1 ms); an absent socket FILE returns immediately without retrying,
55
+ * because nothing about a missing socket can change in 300 ms and this runs before many commands.
56
+ * Retries fire only when the socket file EXISTS and the attempt failed — i.e. a daemon was here
57
+ * moments ago, which is precisely the world the ticket describes.
58
+ *
59
+ * ⚠ `ECONNREFUSED` ON A UNIX SOCKET IS AMBIGUOUS and is therefore retried, not trusted: it means
60
+ * either a stale socket file left by a dead daemon (permanent) or a full accept backlog on a live
61
+ * one (transient). One sample cannot tell them apart; three, spread over 300 ms, usually can.
62
+ *
63
+ * ⚠ THE STATE IS NOT DOWNGRADED BY A LATER ATTEMPT. If any attempt saw a listener, the answer is
64
+ * `silent` — a daemon that accepted once and refused once is alive and struggling, and calling
65
+ * that "down" is the original defect.
66
+ */
67
+ export async function probeDaemon() {
68
+ if (!existsSync(SOCKET_PATH))
69
+ return { state: 'down', attempts: 0 };
70
+ let sawListener = false;
71
+ let last = { state: 'down', attempts: 0 };
72
+ for (let i = 1; i <= PROBE_ATTEMPTS; i++) {
73
+ const r = await probeOnce(PROBE_DEADLINE_MS);
74
+ last = { ...r, attempts: i };
75
+ if (r.state === 'up')
76
+ return last;
77
+ if (r.state === 'silent')
78
+ sawListener = true;
79
+ if (i < PROBE_ATTEMPTS)
80
+ await new Promise((r2) => setTimeout(r2, PROBE_BACKOFF_MS));
81
+ }
82
+ return sawListener ? { ...last, state: 'silent' } : last;
83
+ }
84
+ /**
85
+ * Is the daemon usable?
86
+ *
87
+ * ⚠ KEPT AS THE BOOLEAN FACE OF `probeDaemon`, never a second implementation — the same shape
88
+ * `exampleMutates`/`classifyExample` uses in `commands/explain.ts`. ~15 call sites want a yes/no
89
+ * and must not each re-derive one; the callers that need to tell "wedged" from "absent" (`status`,
90
+ * `start`) ask for the probe.
91
+ */
92
+ export async function isDaemonRunning() {
93
+ return (await probeDaemon()).state === 'up';
94
+ }
95
+ /**
96
+ * Complete the IPC auth handshake on a freshly-connected socket.
97
+ *
98
+ * Reads the status frame, sends `{ type: 'auth', token }`, waits for
99
+ * `auth-result`. Resolves when ok=true. If the daemon reports
100
+ * `requireAuth: false` (backward-compat with a daemon built before auth
101
+ * was added), we skip the handshake silently.
102
+ */
103
+ function handshake(socket) {
104
+ return new Promise((resolve, reject) => {
105
+ let buffer = '';
106
+ let statusSeen = false;
107
+ const timer = setTimeout(() => {
108
+ socket.removeListener('data', onData);
109
+ reject(new Error('IPC auth handshake timeout'));
110
+ }, AUTH_TIMEOUT_MS);
111
+ const onData = (chunk) => {
112
+ buffer += chunk.toString();
113
+ const lines = buffer.split('\n');
114
+ buffer = lines.pop() || '';
115
+ for (const line of lines) {
116
+ if (!line.trim())
117
+ continue;
118
+ let msg;
119
+ try {
120
+ msg = JSON.parse(line);
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ if (msg.type === 'status' && !statusSeen) {
126
+ statusSeen = true;
127
+ if (msg.requireAuth === false) {
128
+ clearTimeout(timer);
129
+ socket.removeListener('data', onData);
130
+ resolve();
131
+ return;
132
+ }
133
+ const token = resolveClientAuthToken();
134
+ if (!token) {
135
+ clearTimeout(timer);
136
+ socket.removeListener('data', onData);
137
+ reject(new Error(
138
+ /**
139
+ * ⚠⚠ THE OLD ADVICE NAMED ONLY THE MAC APP, AND THE COMMON CAUSE IS A
140
+ * CLI-OWNED DAEMON — so the single instruction given was the one that
141
+ * could not possibly help.
142
+ *
143
+ * `beehaven stop` clears both token files. If the process survives it
144
+ * (or a follow-up `start` refuses because the socket is still held),
145
+ * you are left with a LIVE daemon and NO tokens: the socket connects,
146
+ * the handshake fails, and every command reports this. Relaunching an
147
+ * app that does not own that daemon changes nothing, which is exactly
148
+ * how this looked unfixable for an hour.
149
+ *
150
+ * Restarting the daemon re-mints both scopes — verified: the files
151
+ * reappear within seconds of a clean `stop` then `start`.
152
+ */
153
+ /**
154
+ * ⚠⚠ THE OLD TEXT BLAMED THE DAEMON AND ADVISED THE RESTART THAT CAUSES THIS.
155
+ *
156
+ * `stop` scrubs both tokens on shutdown, and `start --headless` used to return
157
+ * BEFORE the replacement was minted — so the common way to reach this message was
158
+ * a `stop && start && call` sequence landing in that window. Telling the reader to
159
+ * restart deleted the fresh token and put them back in it. Observed repeatedly.
160
+ *
161
+ * ⚠ THE SOCKET IS THE DISCRIMINATOR. A live socket with no token means the daemon
162
+ * is still coming up — wait. No socket means nothing is running — start one. Those
163
+ * are opposite actions and the old message gave the wrong one for the common case.
164
+ *
165
+ * ⚠ `start --headless` NOW WAITS for the socket (see `daemon-cmd.ts`), so a script
166
+ * that uses it should no longer reach this at all.
167
+ */
168
+ (existsSync(SOCKET_PATH)
169
+ ? 'The daemon is still starting — its socket is up but it has not minted an IPC token yet. ' +
170
+ 'Wait a second and retry. (Do NOT restart: `stop` scrubs the token, which re-enters this window.)'
171
+ : 'No IPC auth token and no daemon socket — nothing is running. ' +
172
+ 'Start one: `beehaven start --headless` (prefix HIVE_DEV_MODE=1 for admin/shell verbs), ' +
173
+ 'or open the Clearly Dev Mac app if it owns the daemon.')));
174
+ return;
175
+ }
176
+ socket.write(JSON.stringify({ type: 'auth', token }) + '\n');
177
+ continue;
178
+ }
179
+ if (msg.type === 'auth-result') {
180
+ clearTimeout(timer);
181
+ socket.removeListener('data', onData);
182
+ if (msg.ok)
183
+ resolve();
184
+ else
185
+ reject(new Error(msg.error || 'IPC auth rejected by daemon'));
186
+ return;
187
+ }
188
+ }
189
+ };
190
+ socket.on('data', onData);
191
+ });
192
+ }
193
+ /** Open a socket, complete auth handshake, run a single request, close. */
194
+ async function withSocket(fn) {
195
+ const socket = net.createConnection(SOCKET_PATH);
196
+ await new Promise((resolve, reject) => {
197
+ socket.once('connect', () => resolve());
198
+ socket.once('error', reject);
199
+ });
200
+ await handshake(socket);
201
+ try {
202
+ return await fn(socket);
203
+ }
204
+ finally {
205
+ socket.end();
206
+ }
207
+ }
208
+ /** Generic IPC RPC: send a typed message, await matching `rpc-result`/`rpc-error`. */
209
+ function sendIpc(socket, msg) {
210
+ return new Promise((resolve, reject) => {
211
+ const rid = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
212
+ let buffer = '';
213
+ const timer = setTimeout(() => {
214
+ socket.removeListener('data', onData);
215
+ reject(new Error(`Daemon RPC timeout after ${TIMEOUT_MS}ms`));
216
+ }, TIMEOUT_MS);
217
+ const onData = (chunk) => {
218
+ buffer += chunk.toString();
219
+ const lines = buffer.split('\n');
220
+ buffer = lines.pop() || '';
221
+ for (const line of lines) {
222
+ if (!line.trim())
223
+ continue;
224
+ let parsed;
225
+ try {
226
+ parsed = JSON.parse(line);
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ if (parsed.type === 'rpc-result' && parsed.rid === rid) {
232
+ clearTimeout(timer);
233
+ socket.removeListener('data', onData);
234
+ resolve(parsed.result);
235
+ return;
236
+ }
237
+ if (parsed.type === 'rpc-error' && parsed.rid === rid) {
238
+ clearTimeout(timer);
239
+ socket.removeListener('data', onData);
240
+ reject(new Error(parsed.error || 'Daemon RPC failed'));
241
+ return;
242
+ }
243
+ }
244
+ };
245
+ socket.on('data', onData);
246
+ socket.write(JSON.stringify({ ...msg, rid }) + '\n');
247
+ });
248
+ }
249
+ /**
250
+ * Make an HTTP request through the daemon. The daemon attaches its
251
+ * Firebase ID token as `Authorization: Bearer …`. The token never leaves
252
+ * the daemon process.
253
+ */
254
+ export async function daemonFetch(method, url, body, headers) {
255
+ return withSocket(async (sock) => {
256
+ return await sendIpc(sock, {
257
+ type: 'daemon-fetch',
258
+ method,
259
+ url,
260
+ body: typeof body === 'object' ? JSON.stringify(body) : body,
261
+ headers,
262
+ });
263
+ });
264
+ }
265
+ /**
266
+ * Forward an action RPC through the daemon → relay → DO chain.
267
+ *
268
+ * ⚠⚠ THE SECOND GENERAL TRANSPORT, AND IT WAS THE LAST UN-ATTRIBUTED DOOR. `IpcClient` is the
269
+ * other; this one builds the `type:'rpc'` frame itself, so it reaches the workspace without ever
270
+ * touching `IpcClient.call` and therefore without the agent wrap. Its callers are not marginal —
271
+ * `beehaven logs`, `shop`, `costs`, the `--building` gateway path in `cli.ts` and the shell's
272
+ * fallback all come through here, so the whole set was writing no `agent_activity` row.
273
+ *
274
+ * ⚠ Found by RUNNING the verbs, not by reading the diff: `beehaven logs` produced no row after
275
+ * the `IpcClient` fix, which is what exposed a transport nobody had counted.
276
+ *
277
+ * ⚠ THE SPECIFIC HELPERS BELOW ARE FINE UNWRAPPED — `daemonConnect`, `daemonAppList` and their
278
+ * siblings all name `building:*` / `daemon:*` actions the daemon answers itself, which
279
+ * `DAEMON_LOCAL_ACTIONS` exempts anyway. It is this GENERAL form, which forwards whatever it is
280
+ * handed, that had to learn about identity.
281
+ */
282
+ export async function daemonRpc(action, input = {}) {
283
+ const wrapped = activeAgentWrap(action, input ?? {}, {});
284
+ return withSocket(async (sock) => {
285
+ return await sendIpc(sock, { type: 'rpc', action: wrapped.action, input: wrapped.input });
286
+ });
287
+ }
288
+ /** Get the daemon's current Firebase ID token. Use `daemonFetch` if possible. */
289
+ export async function daemonGetToken() {
290
+ return withSocket(async (sock) => {
291
+ const result = await sendIpc(sock, { type: 'get-token' });
292
+ return result.token;
293
+ });
294
+ }
295
+ /**
296
+ * Connect to a building — tells the daemon to open a persistent session
297
+ * via FocusManager on the Home DO. All subsequent `daemonRpc` calls
298
+ * without an explicit target route to this building.
299
+ *
300
+ * The Home DO holds a WebSocket to the target building. Messages tagged
301
+ * with `_building` forward through it. Streaming, live queries, and
302
+ * presence all work over the persistent connection.
303
+ */
304
+ export async function daemonConnect(buildingType, buildingId) {
305
+ return withSocket(async (sock) => {
306
+ return await sendIpc(sock, {
307
+ type: 'rpc',
308
+ action: 'building:connect',
309
+ input: { buildingType, buildingId },
310
+ });
311
+ });
312
+ }
313
+ /** Disconnect from the currently connected building. */
314
+ export async function daemonDisconnect() {
315
+ return withSocket(async (sock) => {
316
+ return await sendIpc(sock, {
317
+ type: 'rpc',
318
+ action: 'building:disconnect',
319
+ input: {},
320
+ });
321
+ });
322
+ }
323
+ /** Get the currently connected building (if any). */
324
+ export async function daemonGetConnection() {
325
+ return withSocket(async (sock) => {
326
+ const result = await sendIpc(sock, {
327
+ type: 'rpc',
328
+ action: 'building:connection',
329
+ input: {},
330
+ });
331
+ return result?.connected
332
+ ? { buildingType: result.buildingType, buildingId: result.buildingId }
333
+ : null;
334
+ });
335
+ }
336
+ /** Get full session status — connected building, active sessions, local app state. */
337
+ export async function daemonGetSessionStatus() {
338
+ return withSocket(async (sock) => {
339
+ return await sendIpc(sock, {
340
+ type: 'rpc',
341
+ action: 'daemon:session-status',
342
+ input: {},
343
+ });
344
+ });
345
+ }
346
+ /** List installed apps. */
347
+ export async function daemonAppList() {
348
+ return withSocket(async (sock) => {
349
+ return await sendIpc(sock, { type: 'rpc', action: 'daemon:app-list', input: {} });
350
+ });
351
+ }
352
+ /** Install an app from a local path or URL. */
353
+ export async function daemonAppInstall(source) {
354
+ return withSocket(async (sock) => {
355
+ return await sendIpc(sock, { type: 'rpc', action: 'daemon:app-install', input: { source } });
356
+ });
357
+ }
358
+ /** Uninstall an app by name. */
359
+ export async function daemonAppUninstall(name) {
360
+ return withSocket(async (sock) => {
361
+ return await sendIpc(sock, { type: 'rpc', action: 'daemon:app-uninstall', input: { name } });
362
+ });
363
+ }