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