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,251 @@
1
+ /**
2
+ * Pipeline orchestration (#69) — chain multiple Omni sessions where
3
+ * each stage's output feeds the next stage's input.
4
+ *
5
+ * The simplest treehouse pattern: same model + same SDK, different
6
+ * system-prompt-appends per stage. User asks for a brief; daemon
7
+ * runs Draft → Review → Revise; user sees one streamed result that's
8
+ * actually three turns under the hood.
9
+ *
10
+ * Stage events fire as `agent:pipeline-stage` so the FAB can render
11
+ * "Stage 2/3: Reviewer" indicators if it wants.
12
+ *
13
+ * Concurrency model — sequential by design. Pipelines that want
14
+ * parallel branches use the tree-of-thought pattern (#68) instead.
15
+ *
16
+ * Token + cost accounting — each stage is a full SDK turn. A 3-stage
17
+ * pipeline costs ~3x a normal turn. We don't artificially limit
18
+ * stages but the FAB should surface the cost before kicking off.
19
+ */
20
+ import { promptOmni } from './daemon-omni.js';
21
+ /** Common stage presets. Compose freely or use as a starter set. */
22
+ export const STAGE_PRESETS = {
23
+ drafter: {
24
+ id: 'drafter',
25
+ label: 'Drafter',
26
+ appendSystemPrompt: `You are the DRAFTER in a 3-stage pipeline. Write a first pass — clear, comprehensive, but explicitly UNPOLISHED. Don't worry about wording or organization yet. Capture every relevant idea. The reviewer will critique it; the reviser will polish. Length: long enough to cover the topic, short enough to be skimmable.`,
27
+ },
28
+ reviewer: {
29
+ id: 'reviewer',
30
+ label: 'Reviewer',
31
+ appendSystemPrompt: `You are the REVIEWER. The user's original prompt + the drafter's output is below. Critique the draft — what's missing, what's wrong, what's hand-wavy, what could be sharper. Be specific. List 5-10 concrete revisions. Don't rewrite the draft; just enumerate fixes.`,
32
+ transformInput: (priorOutput, originalPrompt) => `Original request: ${originalPrompt}\n\nDraft to review:\n\n${priorOutput}`,
33
+ },
34
+ reviser: {
35
+ id: 'reviser',
36
+ label: 'Reviser',
37
+ appendSystemPrompt: `You are the REVISER. Below: the user's original request, the drafter's first pass, and the reviewer's critique. Produce the FINAL output — incorporate the reviewer's fixes, smooth out the drafter's roughness. This is what the user sees. Match the form the user asked for (doc, list, plan, etc.).`,
38
+ transformInput: (priorOutput, originalPrompt) => `Original request: ${originalPrompt}\n\nReviewer's critique (apply these fixes):\n\n${priorOutput}\n\nProduce the final version.`,
39
+ },
40
+ };
41
+ /** Default 3-stage draft → review → revise pipeline. */
42
+ export const DEFAULT_PIPELINE = [
43
+ STAGE_PRESETS.drafter,
44
+ STAGE_PRESETS.reviewer,
45
+ STAGE_PRESETS.reviser,
46
+ ];
47
+ /** Run a pipeline. Returns the FINAL stage's output text. Each
48
+ * intermediate stage broadcasts its events under its own sessionId,
49
+ * so subscribers can see/render the chain.
50
+ *
51
+ * IMPORTANT: this runs sequentially. For parallel ("tree-of-thought")
52
+ * fan-out, use the future #68 orchestrator. */
53
+ export async function runPipeline(opts) {
54
+ let prevOutput = opts.originalPrompt;
55
+ const stageRecords = [];
56
+ for (const stage of opts.stages) {
57
+ const stagePrompt = stage.transformInput
58
+ ? stage.transformInput(prevOutput, opts.originalPrompt)
59
+ : prevOutput;
60
+ const stageSessionId = `${opts.sessionIdBase}-stage-${stage.id}`;
61
+ // promptOmni is fire-and-forget — to chain, we need the FULL
62
+ // assistant output text from this stage before starting the next.
63
+ // We collect it by listening on the subscriber's agent:done event
64
+ // for this specific sessionId.
65
+ const finalText = await runOneStageAndWaitForDone({
66
+ prompt: stagePrompt,
67
+ sessionId: stageSessionId,
68
+ userId: opts.userId,
69
+ model: opts.model,
70
+ appendSystemPrompt: stage.appendSystemPrompt,
71
+ compositionId: opts.compositionId,
72
+ compositionTitle: opts.compositionTitle,
73
+ });
74
+ stageRecords.push({ id: stage.id, sessionId: stageSessionId });
75
+ prevOutput = finalText;
76
+ }
77
+ return { finalText: prevOutput, stages: stageRecords };
78
+ }
79
+ /** Default 4-branch tree-of-thought. The same question gets four
80
+ * parallel takes; synthesis is up to the caller (often a #106
81
+ * consensus pass on top). */
82
+ export const DEFAULT_TREE_BRANCHES = [
83
+ {
84
+ id: 'planner',
85
+ label: 'Planner',
86
+ appendSystemPrompt: 'You are the PLANNER branch in a parallel deliberation. Answer the question as a 5-step concrete plan. Order by dependency. Skip narrative.',
87
+ },
88
+ {
89
+ id: 'researcher',
90
+ label: 'Researcher',
91
+ appendSystemPrompt: 'You are the RESEARCHER branch. Answer the question by gathering relevant facts/sources/precedents. Don\'t plan or implement — just inform.',
92
+ },
93
+ {
94
+ id: 'critic',
95
+ label: 'Critic',
96
+ appendSystemPrompt: 'You are the CRITIC branch. Answer the question by enumerating risks, gotchas, and counter-arguments. Be ruthless. Find the worst-case scenarios.',
97
+ },
98
+ {
99
+ id: 'editor',
100
+ label: 'Editor',
101
+ appendSystemPrompt: 'You are the EDITOR branch. Answer the question by clarifying what the user is REALLY asking, what context is missing, and what success would look like. Don\'t answer the literal question — interrogate it.',
102
+ },
103
+ ];
104
+ /** Fan out to N branches in parallel. Each branch is its own Omni
105
+ * session, runs concurrently with the others, results aggregate.
106
+ * Errors in one branch don't kill the others — partial results
107
+ * return with a {branch, error} entry for the failed one. */
108
+ export async function runTreeOfThought(opts) {
109
+ const branches = opts.branches.map((b) => ({
110
+ branch: b,
111
+ sessionId: `${opts.sessionIdBase}-branch-${b.id}`,
112
+ }));
113
+ // Promise.allSettled lets one branch fail without aborting the rest.
114
+ // Each branch's text is collected via the same wait-for-done helper
115
+ // used by the sequential pipeline.
116
+ const results = await Promise.allSettled(branches.map(async ({ branch, sessionId }) => ({
117
+ id: branch.id,
118
+ label: branch.label,
119
+ sessionId,
120
+ text: await runOneStageAndWaitForDone({
121
+ prompt: opts.originalPrompt,
122
+ sessionId,
123
+ userId: opts.userId,
124
+ model: opts.model,
125
+ appendSystemPrompt: branch.appendSystemPrompt,
126
+ compositionId: opts.compositionId,
127
+ compositionTitle: opts.compositionTitle,
128
+ }),
129
+ })));
130
+ return results.map((r, i) => {
131
+ const { branch, sessionId } = branches[i];
132
+ if (r.status === 'fulfilled')
133
+ return r.value;
134
+ return {
135
+ id: branch.id,
136
+ label: branch.label,
137
+ sessionId,
138
+ error: r.reason?.message ?? String(r.reason),
139
+ };
140
+ });
141
+ }
142
+ /**
143
+ * Consensus mode (#106) — tree-of-thought + synthesis. Runs the
144
+ * default 4-branch tree in parallel, then a 5th SYNTHESIZER session
145
+ * that takes all four outputs + the original question and produces a
146
+ * single unified answer with disagreements explicitly called out.
147
+ *
148
+ * Higher cost than a single turn (~5x), higher signal. Use when the
149
+ * user wants a thorough deliberation — "give me your most considered
150
+ * answer on this."
151
+ */
152
+ export async function runConsensus(opts) {
153
+ const branches = await runTreeOfThought(opts);
154
+ // Build the synthesizer prompt: original question + each branch's
155
+ // output (or error). Synthesizer is told to call out disagreements
156
+ // explicitly — that's the value vs a single-turn answer.
157
+ const branchSummaries = branches
158
+ .map((b) => `### ${b.label}\n${b.text ?? `(error: ${b.error})`}`)
159
+ .join('\n\n');
160
+ const synthesizerPrompt = `Original question: ${opts.originalPrompt}\n\nBelow are 4 parallel takes from focused subagents. Synthesize them into one final answer. Where the takes AGREE, state it concisely. Where they DISAGREE, call out the disagreement and explain which side you think is right + why. Don't gloss over conflict — surface it. End with a single recommendation.\n\n${branchSummaries}`;
161
+ const synthesizerSessionId = `${opts.sessionIdBase}-synthesizer`;
162
+ const synthesizerSystemPrompt = 'You are the SYNTHESIZER. Below: a question and four parallel subagent takes. Produce ONE final answer that surfaces agreement, names disagreement, and ends with a single recommendation.';
163
+ const synthesized = await runOneStageAndWaitForDone({
164
+ prompt: synthesizerPrompt,
165
+ sessionId: synthesizerSessionId,
166
+ userId: opts.userId,
167
+ model: opts.model,
168
+ appendSystemPrompt: synthesizerSystemPrompt,
169
+ compositionId: opts.compositionId,
170
+ compositionTitle: opts.compositionTitle,
171
+ });
172
+ return { branches, synthesized, synthesizerSessionId };
173
+ }
174
+ export async function runModelRace(opts) {
175
+ const started = Date.now();
176
+ const racers = opts.models.map((model) => ({
177
+ model,
178
+ sessionId: `${opts.sessionIdBase}-race-${model}`,
179
+ }));
180
+ // Promise.race for the winner, but we also need the OTHER promises
181
+ // to keep running so we can cache them. Use a different shape: each
182
+ // racer is a Promise that resolves with {model, text, durationMs};
183
+ // we track them all + report when ALL settle.
184
+ const wrapped = racers.map(({ model, sessionId }) => {
185
+ const branchStart = Date.now();
186
+ return runOneStageAndWaitForDone({
187
+ prompt: opts.originalPrompt,
188
+ sessionId,
189
+ userId: opts.userId,
190
+ model,
191
+ compositionId: opts.compositionId,
192
+ compositionTitle: opts.compositionTitle,
193
+ })
194
+ .then((text) => ({ model, sessionId, text, durationMs: Date.now() - branchStart }))
195
+ .catch((err) => ({ model, sessionId, error: err?.message ?? String(err), durationMs: Date.now() - branchStart }));
196
+ });
197
+ // Winner = first to resolve with a successful text.
198
+ const allSettled = await Promise.allSettled(wrapped);
199
+ const results = allSettled.map((s) => (s.status === 'fulfilled' ? s.value : { model: 'unknown', sessionId: '', error: 'wrap-failed' }));
200
+ const successes = results.filter((r) => 'text' in r && !!r.text);
201
+ if (successes.length === 0) {
202
+ throw new Error('All race branches failed');
203
+ }
204
+ // Fastest successful branch wins (by durationMs).
205
+ const winner = successes.reduce((min, r) => (r.durationMs < min.durationMs ? r : min), successes[0]);
206
+ const alternatives = results.filter((r) => r.sessionId !== winner.sessionId);
207
+ // Suppress unused-var
208
+ void started;
209
+ return { winner, alternatives };
210
+ }
211
+ /** Helper — fires promptOmni and resolves with the assistant text
212
+ * when agent:done fires for the matching sessionId. Used internally
213
+ * by runPipeline. */
214
+ async function runOneStageAndWaitForDone(input) {
215
+ const { subscribe } = await import('./daemon-omni.js');
216
+ return new Promise((resolve, reject) => {
217
+ let collected = '';
218
+ const unsubscribe = subscribe((event) => {
219
+ if (event.sessionId !== input.sessionId)
220
+ return;
221
+ if (event.type === 'agent:text-delta') {
222
+ collected += event.text;
223
+ }
224
+ else if (event.type === 'agent:done') {
225
+ unsubscribe();
226
+ // Prefer the done event's final text; fall back to delta
227
+ // accumulation when the SDK doesn't include result.text.
228
+ resolve(event.result?.text || collected);
229
+ }
230
+ else if (event.type === 'agent:error') {
231
+ unsubscribe();
232
+ reject(new Error(event.error));
233
+ }
234
+ });
235
+ promptOmni({
236
+ prompt: input.prompt,
237
+ sessionId: input.sessionId,
238
+ userId: input.userId,
239
+ model: input.model,
240
+ appendSystemPrompt: input.appendSystemPrompt,
241
+ compositionId: input.compositionId,
242
+ compositionTitle: input.compositionTitle,
243
+ // Pipeline stages are explicit fresh starts — no JSONL resume,
244
+ // each stage's prompt contains its full context.
245
+ continueSession: false,
246
+ }).catch((err) => {
247
+ unsubscribe();
248
+ reject(err);
249
+ });
250
+ });
251
+ }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Default tool-gate policies (#60). Registered automatically on daemon
3
+ * startup. Cover the obvious risks:
4
+ *
5
+ * • Long-running Bash commands (npm install, docker build, webpack,
6
+ * find without limits) — Claude tends to invoke these and then
7
+ * hang the agent loop for minutes. Refuse with a hint to use
8
+ * `runInBackground: true` or to split the work.
9
+ * • sudo Bash — refuse outright. The daemon runs as the user; sudo
10
+ * elevates beyond Claude's intended scope.
11
+ * • WebFetch obvious abuse — refuse known scraping targets when
12
+ * called without a User-Agent.
13
+ *
14
+ * These are conservative starts. Tighten / loosen as patterns emerge.
15
+ * Adding a new policy is one `registerToolGatePolicy({...})` call —
16
+ * no central switch to update.
17
+ *
18
+ * Disabling: set BEEHAVEN_OMNI_DISABLE_DEFAULT_POLICIES=1 to skip
19
+ * registration (useful for tests / debugging where you want raw SDK
20
+ * behavior).
21
+ */
22
+ import { registerToolGatePolicy } from './daemon-omni-tool-gate.js';
23
+ /** Bash commands that typically run more than 60 seconds. Not exhaustive;
24
+ * these are common offenders that block the agent loop. The check
25
+ * matches the start of the command (after `cd ... &&` is stripped). */
26
+ const SLOW_BASH_PATTERNS = [
27
+ { pattern: /^npm\s+(install|i|ci)(\s|$)/, reason: '`npm install` takes minutes — split into a setup step or run with `run_in_background: true`.' },
28
+ { pattern: /^yarn\s+(install|add)(\s|$)/, reason: '`yarn install/add` takes minutes — use `run_in_background: true` or split work.' },
29
+ { pattern: /^pnpm\s+(install|i|add)(\s|$)/, reason: '`pnpm install/add` takes minutes — use `run_in_background: true`.' },
30
+ { pattern: /^docker\s+build(\s|$)/, reason: '`docker build` can take many minutes — run in background.' },
31
+ { pattern: /^docker\s+compose\s+(up|build)/, reason: '`docker compose up/build` blocks the agent loop — run in background.' },
32
+ { pattern: /^webpack(\s|$)/, reason: '`webpack` build is slow — run in background or pipe to a log file.' },
33
+ { pattern: /^find\s+\/\s/, reason: '`find /` scans the whole filesystem — narrow the path or add `-maxdepth`.' },
34
+ { pattern: /^find\s+\$HOME\s/, reason: '`find $HOME` scans your entire home — narrow the path or add `-maxdepth`.' },
35
+ { pattern: /^pytest(\s|$)|^vitest(\s|$)|^jest(\s|$)/, reason: 'Test runs can be slow — use `run_in_background: true` or target specific test files.' },
36
+ { pattern: /^cargo\s+build(\s|$)/, reason: '`cargo build` is slow — run in background.' },
37
+ { pattern: /^go\s+test(\s|$)/, reason: '`go test` can be slow — narrow with `-run <pattern>` or background it.' },
38
+ ];
39
+ const SUDO_PATTERN = /^\s*sudo(\s|$)/;
40
+ /** Web domains we won't WebFetch — usually because they need auth or
41
+ * return wall-of-HTML pages Claude can't reason about productively. */
42
+ const WEBFETCH_BLOCKED_HOSTS = new Set([
43
+ // none yet; framework's wired so future product decisions land here
44
+ ]);
45
+ // ── Why free can Write and Bash ─────────────────────────────────────────
46
+ // These tools run through the LOCAL daemon, on the USER'S OWN MACHINE, with
47
+ // the user's OWN inference (BYOK). Write/Edit/Bash cost Clearly nothing —
48
+ // there is no pool, no COGS, no infrastructure being consumed. Gating them by
49
+ // tier was therefore charging for something we do not pay for, and it had a
50
+ // consequence worth stating plainly: 0 of 133 users carry a tier, so EVERY
51
+ // user resolved to `free`, and every user's agent was READ-ONLY. It could not
52
+ // write a file or run a command. An agent that cannot act is not a product
53
+ // anyone can evaluate, let alone buy.
54
+ //
55
+ // So capability is no longer the lever — VOLUME is (TIER_QUOTAS below), which
56
+ // is the lever the earlier entitlement work already concluded was right:
57
+ // deterministic units rather than capability gates. `Task` (subagent
58
+ // spawning) stays team-only because it FANS OUT — one call becomes many, and
59
+ // that is a genuine resource decision rather than a paywall on the basics.
60
+ const AGENT_CORE_TOOLS = [
61
+ 'Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch',
62
+ // Skill is the meta-tool for invoking specialized skills — keep it open.
63
+ 'Skill',
64
+ // TodoWrite is local-state only, no risk.
65
+ 'TodoWrite',
66
+ // Filesystem write + shell, on the user's own machine.
67
+ 'Write', 'Edit', 'NotebookEdit',
68
+ 'Bash', 'BashOutput', 'KillShell',
69
+ ];
70
+ /**
71
+ * The BEEHAVEN surface — first-party, every tier, by name.
72
+ *
73
+ * `TIER_ALLOWLIST` is an exact-name allowlist over Claude Code's BUILT-IN
74
+ * tools, written when the beehaven MCP tools existed only as a design and had
75
+ * never been mounted. The moment they were, every one of them fell through to
76
+ * the deny arm — on every tier, including `team` — and the agent was told to
77
+ * "upgrade to pro" to touch the user's own canvas. A prefix check rather than
78
+ * ~30 more entries, because the tool list is the hive catalog and grows.
79
+ *
80
+ * Why they are not a paid gate, using this file's own reasoning: these run
81
+ * through the LOCAL daemon against the user's OWN workspace, on their own
82
+ * inference. Gating `canvas_add_text` by plan charges for something we do not
83
+ * pay for. Volume is the lever (TIER_QUOTAS); capability is not.
84
+ *
85
+ * The anchored `mcp__clearly__` prefix matters — it names OUR server. A bare
86
+ * `mcp__` test would hand the same free pass to every MCP server the user
87
+ * happens to have configured, which is exactly the blanket the allowlist
88
+ * exists to avoid.
89
+ */
90
+ const FIRST_PARTY_TOOL_PREFIX = 'mcp__clearly__';
91
+ /**
92
+ * ⚠ `principal` IS EQUALLY FIRST-PARTY, and omitting it silently broke Omni.
93
+ *
94
+ * These are the tools that let the agent SPEAK — `reply_to_principal`,
95
+ * `ask_principal`, the board, design docs. When Omni's tools moved into the
96
+ * daemon they landed on a second in-process server (`mcp__principal__*`), and
97
+ * this gate only knew the `clearly` prefix, so the tier check denied every one
98
+ * of them: "isn't available on the free tier."
99
+ *
100
+ * The failure mode is the worst available. `reply_to_principal` is the ONLY
101
+ * channel to the user, so a denial there is an agent that does its work and then
102
+ * cannot say anything — the turn completes, the tool call is refused, and the
103
+ * person sees silence. Caught by calling it, not by reading it.
104
+ *
105
+ * Both prefixes name OUR servers. A bare `mcp__` test would hand the same free
106
+ * pass to every MCP server the user happens to have configured, which is exactly
107
+ * the blanket the allowlist exists to avoid.
108
+ */
109
+ const PRINCIPAL_TOOL_PREFIX = 'mcp__principal__';
110
+ function isFirstPartyTool(toolName) {
111
+ return toolName.startsWith(FIRST_PARTY_TOOL_PREFIX)
112
+ || toolName.startsWith(PRINCIPAL_TOOL_PREFIX);
113
+ }
114
+ const TIER_ALLOWLIST = {
115
+ free: new Set([...AGENT_CORE_TOOLS]),
116
+ pro: new Set([...AGENT_CORE_TOOLS]),
117
+ team: new Set([
118
+ ...AGENT_CORE_TOOLS,
119
+ // Subagent spawning — one call becomes many, so this is a real
120
+ // resource decision rather than a paywall on the basics.
121
+ 'Task',
122
+ ]),
123
+ };
124
+ /**
125
+ * Per-tier DAILY caps — the actual paid lever now that capability is not.
126
+ *
127
+ * A tool not listed for a tier is uncapped. Free is generous enough to
128
+ * evaluate the product properly and finish a real piece of work; it is not
129
+ * generous enough to run an agent all day, which is the paid behaviour.
130
+ */
131
+ const TIER_QUOTAS = {
132
+ free: { Bash: 100, Edit: 100, Write: 50, WebSearch: 50, WebFetch: 50 },
133
+ pro: { Bash: 500, Edit: 500, Write: 200, WebSearch: 200, WebFetch: 200 },
134
+ team: { Bash: 2000, Edit: 2000, Write: 1000, WebSearch: 500, WebFetch: 500 },
135
+ };
136
+ /**
137
+ * Map a Clearly SUBSCRIPTION PLAN onto the three capability buckets the
138
+ * allowlist above is written in terms of.
139
+ *
140
+ * Two different vocabularies meet here: billing sells
141
+ * free/creator/pro/studio/enterprise (lib/pricing/tiers.ts), while the
142
+ * tool gate only cares about "read-only", "can touch this machine", and
143
+ * "can spawn subagents". Keep the translation in ONE place so a new
144
+ * plan id can't silently land in the `default` branch of some `switch`
145
+ * somewhere and hand out shell access.
146
+ *
147
+ * Unknown plan ⇒ 'free'. We enforce what we can prove.
148
+ */
149
+ export function planToPolicyTier(plan) {
150
+ switch (String(plan ?? '').trim().toLowerCase()) {
151
+ case 'free':
152
+ return 'free';
153
+ case 'starter':
154
+ case 'creator':
155
+ case 'pro':
156
+ return 'pro';
157
+ case 'studio':
158
+ case 'enterprise':
159
+ case 'canvas_team':
160
+ case 'team':
161
+ return 'team';
162
+ default:
163
+ return 'free';
164
+ }
165
+ }
166
+ /**
167
+ * Resolve the caller's tier.
168
+ *
169
+ * DEFAULT IS DELIBERATELY 'free'. This used to be a stub that returned
170
+ * `'pro'` unconditionally — and `setTierResolver` was exported but never
171
+ * called by anything, so that stub WAS the production behaviour. The
172
+ * `tier-restrict` policy ran on every tool call, resolved 'pro' every
173
+ * time, and waved through Write/Edit/Bash/NotebookEdit/KillShell for
174
+ * every user including free ones. The gate looked enforced and enforced
175
+ * nothing.
176
+ *
177
+ * `daemon.ts` installs the real resolver at startup (relay-reported
178
+ * subscription plan, with the last known plan cached for offline use).
179
+ * If that wiring is ever removed again, the failure mode is now
180
+ * restrictive rather than permissive — the safe direction for a gate.
181
+ *
182
+ * Override via BEEHAVEN_OMNI_FORCE_TIER=free|pro|team for testing.
183
+ */
184
+ let _tierResolver = () => {
185
+ const forced = process.env.BEEHAVEN_OMNI_FORCE_TIER;
186
+ if (forced === 'free' || forced === 'pro' || forced === 'team')
187
+ return forced;
188
+ return 'free';
189
+ };
190
+ /** Last tier a resolver returned successfully — the fallback when a
191
+ * later lookup throws, so a transient failure can't downgrade (or
192
+ * upgrade) a user mid-session. */
193
+ let _lastKnownTier = null;
194
+ export function setTierResolver(fn) {
195
+ _tierResolver = fn;
196
+ }
197
+ /* The flat TOOL_QUOTAS map that used to live here is gone — its own comment
198
+ * asked for "per-tier overrides (free gets lower caps)", and TIER_QUOTAS above
199
+ * is that. One source of caps, keyed by tier. */
200
+ /** In-memory call counts per (userId, toolName). 24h sliding window. */
201
+ const toolCallCounts = new Map();
202
+ let registered = false;
203
+ export function ensureDefaultPoliciesRegistered() {
204
+ if (registered)
205
+ return;
206
+ if (process.env.BEEHAVEN_OMNI_DISABLE_DEFAULT_POLICIES === '1')
207
+ return;
208
+ registered = true;
209
+ // Bash: cost/latency gate.
210
+ registerToolGatePolicy({
211
+ id: 'bash-cost-gate',
212
+ priority: 10,
213
+ appliesTo: (toolName) => toolName === 'Bash',
214
+ decide(_toolName, input) {
215
+ const command = String(input?.command ?? '');
216
+ if (!command.trim())
217
+ return null;
218
+ // sudo: outright deny. The daemon process runs as the user; sudo
219
+ // elevates beyond Claude's normal scope.
220
+ if (SUDO_PATTERN.test(command)) {
221
+ return {
222
+ deny: true,
223
+ message: 'sudo commands are disallowed. The daemon runs as your user; if you really need elevated privileges, run the command yourself in a terminal.',
224
+ };
225
+ }
226
+ // Long-runner allowlist with hints. We don't deny if the caller
227
+ // already opted into background mode — there the long duration
228
+ // is expected and handled.
229
+ const runInBackground = input?.run_in_background === true;
230
+ if (!runInBackground) {
231
+ // Walk SLOW_BASH_PATTERNS; deny first match.
232
+ // Strip leading `cd /some/path && ` so the match works on the
233
+ // actual command being executed.
234
+ const stripped = command.replace(/^\s*cd\s+\S+\s*&&\s*/, '');
235
+ for (const { pattern, reason } of SLOW_BASH_PATTERNS) {
236
+ if (pattern.test(stripped)) {
237
+ return { deny: true, message: reason };
238
+ }
239
+ }
240
+ }
241
+ return null;
242
+ },
243
+ });
244
+ // Per-tool quotas (#91). Sliding 24h window of tool-call counts.
245
+ // Per (userId, toolName). When the daily cap is hit, deny with
246
+ // a clear message about when the window resets. Counters are
247
+ // in-memory only — daemon restart resets them, which is fine for
248
+ // local-machine policy (the cap is "Claude won't WebSearch 500x
249
+ // accidentally in one chat," not "audit-grade rate-limiting").
250
+ registerToolGatePolicy({
251
+ id: 'per-tool-quota',
252
+ priority: 7,
253
+ async decide(toolName, _input, ctx) {
254
+ // Quota is now PER TIER — it is the paid lever, since every tier can
255
+ // reach every core tool. Resolve the tier the same way tier-restrict
256
+ // does; a resolver failure falls back to the last known tier rather
257
+ // than to the most generous one.
258
+ let tier;
259
+ try {
260
+ tier = await _tierResolver({ sessionId: ctx.sessionId, userId: ctx.userId });
261
+ _lastKnownTier = tier;
262
+ }
263
+ catch {
264
+ tier = _lastKnownTier ?? 'free';
265
+ }
266
+ const limit = (TIER_QUOTAS[tier] ?? TIER_QUOTAS.free)[toolName];
267
+ if (limit == null)
268
+ return null;
269
+ const key = `${ctx.userId}::${toolName}`;
270
+ const now = Date.now();
271
+ let bucket = toolCallCounts.get(key);
272
+ if (!bucket || now - bucket.windowStart > 24 * 3_600_000) {
273
+ bucket = { count: 0, windowStart: now };
274
+ toolCallCounts.set(key, bucket);
275
+ }
276
+ bucket.count += 1;
277
+ if (bucket.count > limit) {
278
+ const resetIn = Math.max(0, 24 * 3_600_000 - (now - bucket.windowStart));
279
+ const resetHrs = Math.ceil(resetIn / 3_600_000);
280
+ return {
281
+ deny: true,
282
+ message: `Daily ${toolName} quota reached (${limit}/day on the ${tier} plan). Resets in ~${resetHrs}h.`,
283
+ };
284
+ }
285
+ return null;
286
+ },
287
+ });
288
+ // Per-tier tool palette gate (#61). Runs first (priority 5) so an
289
+ // out-of-tier tool gets blocked before any cost/policy checks even
290
+ // run. Sibling tools (canUseTool framework) can still mutate input
291
+ // for tools that ARE in tier — order matters but is independent of
292
+ // tier from this point on.
293
+ registerToolGatePolicy({
294
+ id: 'tier-restrict',
295
+ priority: 5,
296
+ async decide(toolName, _input, ctx) {
297
+ let tier;
298
+ try {
299
+ tier = await _tierResolver({ sessionId: ctx.sessionId, userId: ctx.userId });
300
+ }
301
+ catch {
302
+ // Resolver failed. This used to fail-open to 'pro' — so any error
303
+ // in tier lookup granted shell + filesystem write. Fall back to
304
+ // the LAST KNOWN plan instead (cached on the last successful
305
+ // relay handshake), and only to 'free' when we've never known
306
+ // one. A paid user working offline keeps their tools; an unknown
307
+ // user does not silently acquire them.
308
+ tier = _lastKnownTier ?? 'free';
309
+ }
310
+ // Remember the last successfully-resolved tier for the branch above.
311
+ if (tier)
312
+ _lastKnownTier = tier;
313
+ const allowlist = TIER_ALLOWLIST[tier] ?? TIER_ALLOWLIST.pro;
314
+ if (allowlist.has(toolName) || isFirstPartyTool(toolName))
315
+ return null;
316
+ // Out of tier. Hint the upgrade path.
317
+ const nextTier = tier === 'free' ? 'pro' : 'team';
318
+ return {
319
+ deny: true,
320
+ message: `Tool '${toolName}' isn't available on the ${tier} tier. Upgrade to ${nextTier} to unlock it.`,
321
+ };
322
+ },
323
+ });
324
+ // WebFetch: block hosts on the deny list (currently empty — placeholder
325
+ // for product decisions like "no scraping facebook.com without UA").
326
+ if (WEBFETCH_BLOCKED_HOSTS.size > 0) {
327
+ registerToolGatePolicy({
328
+ id: 'webfetch-host-gate',
329
+ priority: 10,
330
+ appliesTo: (toolName) => toolName === 'WebFetch',
331
+ decide(_toolName, input) {
332
+ const url = String(input?.url ?? '');
333
+ if (!url)
334
+ return null;
335
+ try {
336
+ const host = new URL(url).hostname;
337
+ if (WEBFETCH_BLOCKED_HOSTS.has(host)) {
338
+ return { deny: true, message: `${host} is on the daemon's WebFetch deny list. Use your browser or ask the user to paste the content.` };
339
+ }
340
+ }
341
+ catch { /* malformed URL — let the SDK reject downstream */ }
342
+ return null;
343
+ },
344
+ });
345
+ }
346
+ }