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,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon-side Codex — OpenAI Codex SDK integration, sibling of daemon-omni.
|
|
3
|
+
*
|
|
4
|
+
* The "treehouse" architecture: one daemon process hosts multiple AI
|
|
5
|
+
* agent SDKs in parallel, each as its own module with the same shape:
|
|
6
|
+
* • daemon-omni.ts → @anthropic-ai/claude-agent-sdk (Claude)
|
|
7
|
+
* • daemon-codex.ts → @openai/codex-sdk (GPT/Codex)
|
|
8
|
+
* • daemon-gemini-cli → @google/gemini-cli-core (planned)
|
|
9
|
+
*
|
|
10
|
+
* Each emits the SAME event shapes (agent:text-delta, agent:tool-call,
|
|
11
|
+
* agent:tool-result, agent:done) so the FAB renderer doesn't need a
|
|
12
|
+
* per-provider code path.
|
|
13
|
+
*
|
|
14
|
+
* IPC verbs the daemon exposes:
|
|
15
|
+
* codex:prompt { prompt, sessionId, model?, workingDir? }
|
|
16
|
+
* codex:end { sessionId }
|
|
17
|
+
* codex:sessions {}
|
|
18
|
+
*
|
|
19
|
+
* Codex SDK shape (v0.130):
|
|
20
|
+
* const codex = new Codex({ apiKey? });
|
|
21
|
+
* const thread = codex.startThread({ model?, workingDirectory? });
|
|
22
|
+
* const stream = thread.runStreamed(prompt);
|
|
23
|
+
* for await (const event of stream.events) { ... }
|
|
24
|
+
*
|
|
25
|
+
* Auth:
|
|
26
|
+
* Reads OPENAI_API_KEY from process.env. If absent, the SDK refuses
|
|
27
|
+
* to start. Future: per-user BYOK via daemon settings.
|
|
28
|
+
*/
|
|
29
|
+
const sessions = new Map();
|
|
30
|
+
const subscribers = new Set();
|
|
31
|
+
// Lazy SDK instance — keeps the Codex client warm across sessions in
|
|
32
|
+
// the same daemon process. Constructed on first session start.
|
|
33
|
+
let _codex = null;
|
|
34
|
+
// ── Safety knobs (parity with daemon-omni — audit task #64) ──────────
|
|
35
|
+
/** Default max prompts queued per session before promptCodex rejects. */
|
|
36
|
+
const DEFAULT_MAX_QUEUED_PROMPTS = 20;
|
|
37
|
+
/** Idle-session reaper: a Codex session held in memory after the user
|
|
38
|
+
* stops using it leaks a Thread object (which the SDK may keep TCP
|
|
39
|
+
* connections / file descriptors warm for). Reaper sweeps every minute
|
|
40
|
+
* and ends sessions idle longer than the timeout. The Codex SDK's
|
|
41
|
+
* thread store survives — next promptCodex resumes via resumeThreadId. */
|
|
42
|
+
const IDLE_TIMEOUT_MS = (() => {
|
|
43
|
+
const env = process.env.BEEHAVEN_CODEX_IDLE_TIMEOUT_MS;
|
|
44
|
+
if (env != null) {
|
|
45
|
+
const n = Number(env);
|
|
46
|
+
if (Number.isFinite(n) && n >= 0)
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
return 30 * 60_000; // 30 min default — matches Omni
|
|
50
|
+
})();
|
|
51
|
+
const CLEANUP_INTERVAL_MS = 60_000;
|
|
52
|
+
let cleanupTimer = null;
|
|
53
|
+
function reapIdleSessions() {
|
|
54
|
+
if (IDLE_TIMEOUT_MS === 0)
|
|
55
|
+
return 0;
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
let reaped = 0;
|
|
58
|
+
for (const [id, sess] of sessions) {
|
|
59
|
+
if (sess.turnInFlight)
|
|
60
|
+
continue; // never reap mid-turn
|
|
61
|
+
if (sess.queuedPrompts.length > 0)
|
|
62
|
+
continue; // pending work
|
|
63
|
+
if (now - sess.lastActivityAt > IDLE_TIMEOUT_MS) {
|
|
64
|
+
endSession(id);
|
|
65
|
+
reaped++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return reaped;
|
|
69
|
+
}
|
|
70
|
+
function ensureCleanupTimer() {
|
|
71
|
+
if (cleanupTimer)
|
|
72
|
+
return;
|
|
73
|
+
if (IDLE_TIMEOUT_MS === 0)
|
|
74
|
+
return;
|
|
75
|
+
cleanupTimer = setInterval(() => {
|
|
76
|
+
const reaped = reapIdleSessions();
|
|
77
|
+
if (reaped > 0) {
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.log(`[codex] reaped ${reaped} idle session(s); ${sessions.size} remain`);
|
|
80
|
+
}
|
|
81
|
+
}, CLEANUP_INTERVAL_MS);
|
|
82
|
+
if (typeof cleanupTimer?.unref === 'function')
|
|
83
|
+
cleanupTimer.unref();
|
|
84
|
+
}
|
|
85
|
+
// ── Subscriber API ───────────────────────────────────────────────────
|
|
86
|
+
export function subscribe(fn) {
|
|
87
|
+
subscribers.add(fn);
|
|
88
|
+
return () => subscribers.delete(fn);
|
|
89
|
+
}
|
|
90
|
+
function emit(event) {
|
|
91
|
+
for (const fn of subscribers) {
|
|
92
|
+
try {
|
|
93
|
+
fn(event);
|
|
94
|
+
}
|
|
95
|
+
catch { /* subscriber errors don't sink the loop */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function getOrCreateCodex() {
|
|
99
|
+
if (_codex)
|
|
100
|
+
return _codex;
|
|
101
|
+
const mod = await import('@openai/codex-sdk');
|
|
102
|
+
_codex = new mod.Codex();
|
|
103
|
+
return _codex;
|
|
104
|
+
}
|
|
105
|
+
async function getOrCreateSession(opts) {
|
|
106
|
+
const existing = sessions.get(opts.sessionId);
|
|
107
|
+
if (existing)
|
|
108
|
+
return existing;
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const session = {
|
|
111
|
+
sessionId: opts.sessionId,
|
|
112
|
+
userId: opts.userId,
|
|
113
|
+
thread: null,
|
|
114
|
+
threadId: opts.resumeThreadId ?? null,
|
|
115
|
+
ended: false,
|
|
116
|
+
turnIndex: 0,
|
|
117
|
+
startedAt: now,
|
|
118
|
+
currentText: '',
|
|
119
|
+
turnInFlight: false,
|
|
120
|
+
queuedPrompts: [],
|
|
121
|
+
lastActivityAt: now,
|
|
122
|
+
maxQueuedPrompts: opts.maxQueuedPrompts ?? DEFAULT_MAX_QUEUED_PROMPTS,
|
|
123
|
+
};
|
|
124
|
+
sessions.set(opts.sessionId, session);
|
|
125
|
+
ensureCleanupTimer();
|
|
126
|
+
const codex = await getOrCreateCodex();
|
|
127
|
+
// Resolve working directory (#103): if compositionId is set, use the
|
|
128
|
+
// composition's scratch dir — same as Claude's FAB session. Codex
|
|
129
|
+
// and Claude operating in the same directory is the basis of the
|
|
130
|
+
// handoff pattern (Claude writes plan.md, Codex reads + implements).
|
|
131
|
+
// Explicit workingDir overrides; otherwise compositionId wins;
|
|
132
|
+
// otherwise SDK default.
|
|
133
|
+
let workingDirectory;
|
|
134
|
+
if (opts.workingDir) {
|
|
135
|
+
workingDirectory = opts.workingDir;
|
|
136
|
+
}
|
|
137
|
+
else if (opts.compositionId) {
|
|
138
|
+
const { homedir } = await import('os');
|
|
139
|
+
const { join } = await import('path');
|
|
140
|
+
const { existsSync, mkdirSync } = await import('fs');
|
|
141
|
+
workingDirectory = join(homedir(), '.clearly', 'compositions', opts.compositionId);
|
|
142
|
+
try {
|
|
143
|
+
if (!existsSync(workingDirectory))
|
|
144
|
+
mkdirSync(workingDirectory, { recursive: true });
|
|
145
|
+
}
|
|
146
|
+
catch { /* fall through — Codex will fail with a clearer error */ }
|
|
147
|
+
}
|
|
148
|
+
// `skipGitRepoCheck`: Codex defaults to requiring a Git repo for the
|
|
149
|
+
// working directory (refuses to start otherwise). Composition dirs
|
|
150
|
+
// aren't git repos by default, so we disable the check.
|
|
151
|
+
session.thread = opts.resumeThreadId
|
|
152
|
+
? codex.resumeThread(opts.resumeThreadId)
|
|
153
|
+
: codex.startThread({
|
|
154
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
155
|
+
...(workingDirectory ? { workingDirectory } : {}),
|
|
156
|
+
skipGitRepoCheck: true,
|
|
157
|
+
});
|
|
158
|
+
return session;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Run a single Codex turn — the SDK's runStreamed returns an iterable
|
|
162
|
+
* of typed events; we re-shape each into our CodexEvent union and emit.
|
|
163
|
+
*
|
|
164
|
+
* Codex's event taxonomy (per SDK 0.130):
|
|
165
|
+
* • item.completed → text chunks (kind: 'agent_message')
|
|
166
|
+
* • item.completed → tool calls/results (kind: 'command_execution')
|
|
167
|
+
* • turn.completed → end of turn marker
|
|
168
|
+
* • thread.started → session id update
|
|
169
|
+
* • error → fail the turn
|
|
170
|
+
*
|
|
171
|
+
* We translate these to the same {agent:text-delta, agent:tool-call,
|
|
172
|
+
* agent:tool-result, agent:done} the FAB already speaks for Claude.
|
|
173
|
+
*/
|
|
174
|
+
async function runTurn(session, prompt) {
|
|
175
|
+
if (session.ended)
|
|
176
|
+
return;
|
|
177
|
+
if (!session.thread) {
|
|
178
|
+
throw new Error('Codex session not initialized');
|
|
179
|
+
}
|
|
180
|
+
session.turnInFlight = true;
|
|
181
|
+
session.currentText = '';
|
|
182
|
+
const turnStart = Date.now();
|
|
183
|
+
try {
|
|
184
|
+
const stream = await session.thread.runStreamed(prompt);
|
|
185
|
+
for await (const event of stream.events) {
|
|
186
|
+
if (session.ended)
|
|
187
|
+
break;
|
|
188
|
+
handleCodexEvent(session, event);
|
|
189
|
+
}
|
|
190
|
+
session.turnIndex += 1;
|
|
191
|
+
emit({
|
|
192
|
+
type: 'agent:turn-complete',
|
|
193
|
+
sessionId: session.sessionId,
|
|
194
|
+
turnIndex: session.turnIndex,
|
|
195
|
+
});
|
|
196
|
+
emit({
|
|
197
|
+
type: 'agent:done',
|
|
198
|
+
sessionId: session.sessionId,
|
|
199
|
+
result: {
|
|
200
|
+
text: session.currentText,
|
|
201
|
+
turns: session.turnIndex,
|
|
202
|
+
durationMs: Date.now() - turnStart,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
emit({
|
|
208
|
+
type: 'agent:error',
|
|
209
|
+
sessionId: session.sessionId,
|
|
210
|
+
error: err?.message ?? String(err),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
session.turnInFlight = false;
|
|
215
|
+
session.lastActivityAt = Date.now();
|
|
216
|
+
// Drain queued prompts FIFO (parity with daemon-omni — audit #64).
|
|
217
|
+
const next = session.queuedPrompts.shift();
|
|
218
|
+
if (next && !session.ended) {
|
|
219
|
+
void runTurn(session, next).catch((e) => {
|
|
220
|
+
emit({
|
|
221
|
+
type: 'agent:error',
|
|
222
|
+
sessionId: session.sessionId,
|
|
223
|
+
error: e?.message ?? String(e),
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function handleCodexEvent(session, event) {
|
|
230
|
+
// Item events — Codex bundles assistant messages, tool calls, and
|
|
231
|
+
// tool results all as "items." We dispatch on kind.
|
|
232
|
+
if (event?.type === 'item.completed' || event?.type === 'item.updated') {
|
|
233
|
+
const item = event.item;
|
|
234
|
+
if (!item)
|
|
235
|
+
return;
|
|
236
|
+
if (item.item_type === 'agent_message' && typeof item.text === 'string') {
|
|
237
|
+
// Codex sends the full assistant message text in one item rather
|
|
238
|
+
// than delta-chunks. We emit it as a single agent:text-delta with
|
|
239
|
+
// the whole payload — the FAB's streaming bubble handler treats
|
|
240
|
+
// "current = '' + this chunk" as full text, which is the right
|
|
241
|
+
// behavior since the bubble had no prior content.
|
|
242
|
+
const delta = item.text.slice(session.currentText.length);
|
|
243
|
+
if (delta) {
|
|
244
|
+
session.currentText = item.text;
|
|
245
|
+
emit({
|
|
246
|
+
type: 'agent:text-delta',
|
|
247
|
+
sessionId: session.sessionId,
|
|
248
|
+
text: delta,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (item.item_type === 'command_execution') {
|
|
254
|
+
// Two-phase: when Codex starts a command, we emit tool-call.
|
|
255
|
+
// When it finishes (status === 'completed' | 'failed'), we emit
|
|
256
|
+
// tool-result. Both carry the same call id so the FAB can match.
|
|
257
|
+
const callId = item.id ?? `codex-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
258
|
+
const status = item.status;
|
|
259
|
+
if (status === 'in_progress' || event.type === 'item.updated') {
|
|
260
|
+
emit({
|
|
261
|
+
type: 'agent:tool-call',
|
|
262
|
+
sessionId: session.sessionId,
|
|
263
|
+
callId,
|
|
264
|
+
tool: 'Bash', // Codex's command_execution is shell-based
|
|
265
|
+
input: { command: item.command ?? '', description: '' },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
else if (status === 'completed' || status === 'failed') {
|
|
269
|
+
const out = item.aggregated_output ?? '';
|
|
270
|
+
emit({
|
|
271
|
+
type: 'agent:tool-result',
|
|
272
|
+
sessionId: session.sessionId,
|
|
273
|
+
callId,
|
|
274
|
+
tool: 'Bash',
|
|
275
|
+
ok: status === 'completed',
|
|
276
|
+
preview: typeof out === 'string' ? out.slice(0, 200) : '',
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (event?.type === 'thread.started' && event.thread_id) {
|
|
284
|
+
session.threadId = event.thread_id;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (event?.type === 'error') {
|
|
288
|
+
emit({
|
|
289
|
+
type: 'agent:error',
|
|
290
|
+
sessionId: session.sessionId,
|
|
291
|
+
error: event.message ?? 'Codex error',
|
|
292
|
+
});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Push a user message into a Codex session. Resolves when the SDK
|
|
297
|
+
* starts processing (events stream via subscribers). One concurrent
|
|
298
|
+
* turn per session; subsequent prompts queue FIFO (parity with
|
|
299
|
+
* daemon-omni). Throws QUEUE_FULL if the queue is at capacity. */
|
|
300
|
+
export async function promptCodex(input) {
|
|
301
|
+
const session = await getOrCreateSession({
|
|
302
|
+
sessionId: input.sessionId,
|
|
303
|
+
userId: input.userId,
|
|
304
|
+
model: input.model,
|
|
305
|
+
workingDir: input.workingDir,
|
|
306
|
+
compositionId: input.compositionId,
|
|
307
|
+
resumeThreadId: input.resumeThreadId,
|
|
308
|
+
maxQueuedPrompts: input.maxQueuedPrompts,
|
|
309
|
+
});
|
|
310
|
+
if (session.ended) {
|
|
311
|
+
throw new Error(`Codex session ${input.sessionId} has ended; start a new one.`);
|
|
312
|
+
}
|
|
313
|
+
if (session.turnInFlight) {
|
|
314
|
+
// Queue cap (audit task #64 — parity with daemon-omni).
|
|
315
|
+
if (session.queuedPrompts.length >= session.maxQueuedPrompts) {
|
|
316
|
+
throw new Error(`QUEUE_FULL: Codex session ${input.sessionId} has ${session.queuedPrompts.length} prompts queued (max ${session.maxQueuedPrompts}). ` +
|
|
317
|
+
`Wait for the current turn to finish or end this session.`);
|
|
318
|
+
}
|
|
319
|
+
session.queuedPrompts.push(input.prompt);
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Fire-and-forget the turn — the subscriber stream surfaces progress.
|
|
323
|
+
void runTurn(session, input.prompt).catch((err) => {
|
|
324
|
+
emit({
|
|
325
|
+
type: 'agent:error',
|
|
326
|
+
sessionId: session.sessionId,
|
|
327
|
+
error: err?.message ?? String(err),
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return { sessionId: input.sessionId, threadId: session.threadId };
|
|
332
|
+
}
|
|
333
|
+
export function endSession(sessionId) {
|
|
334
|
+
const session = sessions.get(sessionId);
|
|
335
|
+
if (!session)
|
|
336
|
+
return;
|
|
337
|
+
session.ended = true;
|
|
338
|
+
sessions.delete(sessionId);
|
|
339
|
+
}
|
|
340
|
+
export function listSessions() {
|
|
341
|
+
const now = Date.now();
|
|
342
|
+
return Array.from(sessions.values()).map((s) => ({
|
|
343
|
+
sessionId: s.sessionId,
|
|
344
|
+
userId: s.userId,
|
|
345
|
+
turns: s.turnIndex,
|
|
346
|
+
ageMs: now - s.startedAt,
|
|
347
|
+
threadId: s.threadId,
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon entry point.
|
|
3
|
+
*
|
|
4
|
+
* Invoked by the Clearly Dev Mac app tray (or any launchd/systemd unit) via:
|
|
5
|
+
*
|
|
6
|
+
* node /path/to/dist/daemon.js
|
|
7
|
+
*
|
|
8
|
+
* This is the ONE AND ONLY way to launch the daemon process. The user-facing
|
|
9
|
+
* `beehaven` CLI does NOT expose a `start` subcommand by design — daemon
|
|
10
|
+
* lifecycle is owned by the tray, and the tray spawns this entry file
|
|
11
|
+
* directly instead of going through a CLI subcommand. That decoupling means
|
|
12
|
+
* the CLI's command surface can change without risking the tray.
|
|
13
|
+
*
|
|
14
|
+
* Environment contract from the tray:
|
|
15
|
+
* BEEHAVEN_NO_REPL=1 — skip interactive login; rely on provisioned token
|
|
16
|
+
* HOME=<user home> — so keychain + config paths resolve correctly
|
|
17
|
+
* PATH=<curated path> — so nvm node + system tools are reachable
|
|
18
|
+
*
|
|
19
|
+
* Exit behaviour:
|
|
20
|
+
* - Normal lifetime: process stays up until SIGTERM/SIGINT from the tray.
|
|
21
|
+
* - Fatal start error: logged to stderr (captured by the tray's log files)
|
|
22
|
+
* and exits 1. The tray supervises and won't auto-respawn on fatal
|
|
23
|
+
* startup errors (those usually need user intervention — expired token,
|
|
24
|
+
* missing config, etc).
|
|
25
|
+
*/
|
|
26
|
+
import { startDaemon } from './daemon.js';
|
|
27
|
+
// ── Parent-watcher (tray-coupled enforcement) ──
|
|
28
|
+
// The daemon is documented as tray-coupled: it must not outlive the
|
|
29
|
+
// tray that spawned it. The tray's own `before-quit` handler tries
|
|
30
|
+
// to send SIGTERM, but that path is bypassed when:
|
|
31
|
+
// - User Cmd+Q's the app (event.preventDefault() → window hides, no shutdown)
|
|
32
|
+
// - Tray process crashes / force-quits / OOM-kills
|
|
33
|
+
// - Electron itself segfaults
|
|
34
|
+
//
|
|
35
|
+
// All of those leave the daemon orphaned, still holding the relay WS
|
|
36
|
+
// open, still answering canvas-* RPCs, still costing DO active-time
|
|
37
|
+
// for the user's session that they thought they ended.
|
|
38
|
+
//
|
|
39
|
+
// macOS reparents orphans to launchd (PID 1). We capture the original
|
|
40
|
+
// parent PID at boot and poll every 3s; the moment it changes (parent
|
|
41
|
+
// is gone), we exit. 3s is short enough to feel responsive to the
|
|
42
|
+
// user but long enough that a tray restart isn't flagged as a death.
|
|
43
|
+
//
|
|
44
|
+
// Why poll instead of process.on('disconnect'): disconnect only fires
|
|
45
|
+
// for child_process.fork() IPC channels. The Mac app uses plain spawn
|
|
46
|
+
// + stdio:'pipe', so there's no IPC channel to detect the parent's
|
|
47
|
+
// exit through. Polling ppid is the portable Node API.
|
|
48
|
+
const ORIGINAL_PARENT_PID = process.ppid;
|
|
49
|
+
const PARENT_CHECK_INTERVAL_MS = 3_000;
|
|
50
|
+
// HEADLESS mode (e.g. a Cloudflare sandbox container): there is no Mac-app tray
|
|
51
|
+
// parent to be coupled to, so the parent-death suicide must be disabled — else the
|
|
52
|
+
// daemon exits ~3s after boot (ppid=1) and the agent's box has no daemon. Opt in
|
|
53
|
+
// via BEEHAVEN_HEADLESS=1 (set by the sandbox image + control-server).
|
|
54
|
+
const BEEHAVEN_HEADLESS = process.env.BEEHAVEN_HEADLESS === '1' || process.env.BEEHAVEN_HEADLESS === 'true';
|
|
55
|
+
if (!BEEHAVEN_HEADLESS && ORIGINAL_PARENT_PID && ORIGINAL_PARENT_PID !== 1) {
|
|
56
|
+
setInterval(() => {
|
|
57
|
+
const currentPpid = process.ppid;
|
|
58
|
+
if (currentPpid !== ORIGINAL_PARENT_PID) {
|
|
59
|
+
console.error(`[beehaven daemon] parent died (was=${ORIGINAL_PARENT_PID}, now=${currentPpid}) — self-terminating to enforce tray-coupled lifecycle`);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
}, PARENT_CHECK_INTERVAL_MS).unref();
|
|
63
|
+
// .unref() so this timer doesn't block the event loop from draining
|
|
64
|
+
// when shutdown is in progress.
|
|
65
|
+
}
|
|
66
|
+
async function run() {
|
|
67
|
+
if (process.argv[2] === '--restart-after-update') {
|
|
68
|
+
// A daemon started by `beehaven start --headless` has no launchd/systemd or tray to restart
|
|
69
|
+
// it after exit 75. The updated entry waits for the old owner to release the IPC socket.
|
|
70
|
+
if (process.env.BEEHAVEN_CLI_OWNED_DAEMON !== '1')
|
|
71
|
+
throw new Error('update restart requires a CLI-owned daemon');
|
|
72
|
+
const previousPid = Number(process.argv[3]);
|
|
73
|
+
if (!Number.isSafeInteger(previousPid) || previousPid <= 1)
|
|
74
|
+
throw new Error('invalid previous daemon PID');
|
|
75
|
+
const deadline = Date.now() + 15_000;
|
|
76
|
+
while (Date.now() < deadline) {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(previousPid, 0);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error?.code === 'ESRCH')
|
|
82
|
+
break;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
process.kill(previousPid, 0);
|
|
89
|
+
throw new Error('previous daemon did not exit');
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error?.code !== 'ESRCH')
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await startDaemon({});
|
|
97
|
+
}
|
|
98
|
+
run().catch((err) => {
|
|
99
|
+
console.error('[beehaven daemon] fatal:', err?.stack ?? err?.message ?? err);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon-fetch URL allowlist.
|
|
3
|
+
*
|
|
4
|
+
* The daemon-fetch IPC proxy lets local clients make HTTP requests with
|
|
5
|
+
* the daemon's Firebase ID token attached as `Authorization: Bearer …`.
|
|
6
|
+
* Without a URL allowlist, any local process that gets past IPC auth can
|
|
7
|
+
* use the daemon as a confused deputy — hit arbitrary endpoints with the
|
|
8
|
+
* user's cloud credentials.
|
|
9
|
+
*
|
|
10
|
+
* Policy: allow-by-host. We maintain a static allowlist of hosts the
|
|
11
|
+
* daemon is expected to talk to. Everything else is rejected.
|
|
12
|
+
*
|
|
13
|
+
* Callers needing to add a new host should edit this file rather than
|
|
14
|
+
* disabling the allowlist — forces the decision through code review.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Hosts we're willing to forward authenticated requests to.
|
|
18
|
+
*
|
|
19
|
+
* Each entry is a lowercased hostname or wildcard pattern (leading `*.`
|
|
20
|
+
* matches any subdomain). Exact host matches win over wildcards, but we
|
|
21
|
+
* evaluate them uniformly.
|
|
22
|
+
*/
|
|
23
|
+
const ALLOWED_HOSTS = [
|
|
24
|
+
// Clearly production + staging relays
|
|
25
|
+
'relay.clearly.sh',
|
|
26
|
+
'staging-relay.clearly.sh',
|
|
27
|
+
'clearly.sh',
|
|
28
|
+
'*.clearly.sh',
|
|
29
|
+
'*.clearly-sh.pages.dev',
|
|
30
|
+
// Clearly Cloudflare Workers (named workspaces + routes).
|
|
31
|
+
// SECURITY: do NOT add a `*.workers.dev` wildcard here — ANY party can
|
|
32
|
+
// deploy a worker under *.workers.dev. Enumerate only Clearly's workers.
|
|
33
|
+
'bee-relay.throbbing-unit-1b3f.workers.dev',
|
|
34
|
+
'bee-relay-staging.throbbing-unit-1b3f.workers.dev',
|
|
35
|
+
// Firebase auth + project endpoints
|
|
36
|
+
'identitytoolkit.googleapis.com',
|
|
37
|
+
'securetoken.googleapis.com',
|
|
38
|
+
'firestore.googleapis.com',
|
|
39
|
+
'*.firebaseio.com',
|
|
40
|
+
'*.firebaseapp.com',
|
|
41
|
+
// Vertex AI / Google Cloud (for AI, storage, cloud functions)
|
|
42
|
+
'aiplatform.googleapis.com',
|
|
43
|
+
'*-aiplatform.googleapis.com',
|
|
44
|
+
'storage.googleapis.com',
|
|
45
|
+
// REMOVED: '*-cloudfunctions.net'. It compiled to
|
|
46
|
+
// `^[a-z0-9-]+-cloudfunctions\.net$`, which matches a SECOND-LEVEL
|
|
47
|
+
// domain — `evil-cloudfunctions.net` is registrable by anyone, and it
|
|
48
|
+
// was allowed. Google's real Cloud Functions hosts are subdomains of
|
|
49
|
+
// cloudfunctions.net, already covered by the next line, so the entry
|
|
50
|
+
// was redundant as well as dangerous. It sat three lines under the
|
|
51
|
+
// comment warning about exactly this with *.workers.dev.
|
|
52
|
+
'*.cloudfunctions.net',
|
|
53
|
+
'*.run.app',
|
|
54
|
+
// Shopify (app proxy callbacks for stores the user has installed)
|
|
55
|
+
'*.myshopify.com',
|
|
56
|
+
];
|
|
57
|
+
/**
|
|
58
|
+
* The subset of allowed hosts that may receive the user's Firebase ID
|
|
59
|
+
* token as `Authorization: Bearer …`.
|
|
60
|
+
*
|
|
61
|
+
* Being fetchable and being trusted with a credential are DIFFERENT
|
|
62
|
+
* questions, and conflating them is what made this a confused-deputy
|
|
63
|
+
* hole. `daemon.ts` attached the bearer to every allowed host, and four
|
|
64
|
+
* allowlist entries are registrable by third parties:
|
|
65
|
+
*
|
|
66
|
+
* *.myshopify.com anyone can open a Shopify store
|
|
67
|
+
* *.firebaseapp.com anyone can create a Firebase project
|
|
68
|
+
* *.run.app anyone can deploy a Cloud Run service
|
|
69
|
+
* *.cloudfunctions.net any other Google Cloud customer
|
|
70
|
+
*
|
|
71
|
+
* Each of those would have received a live Firebase ID token for the
|
|
72
|
+
* user — the same class of leak the `*.workers.dev` comment above warns
|
|
73
|
+
* about, just spelled differently. They stay FETCHABLE (the daemon has
|
|
74
|
+
* legitimate reasons to talk to a merchant storefront) but they no
|
|
75
|
+
* longer get the credential.
|
|
76
|
+
*
|
|
77
|
+
* Entries here are Clearly-controlled or Google-first-party
|
|
78
|
+
* (`*.googleapis.com` is Google's registrable domain — no third party
|
|
79
|
+
* can obtain a host under it). Add to this list only with the same
|
|
80
|
+
* question answered: *can anyone but us obtain a hostname that matches?*
|
|
81
|
+
*/
|
|
82
|
+
const CREDENTIALED_HOSTS = [
|
|
83
|
+
'relay.clearly.sh',
|
|
84
|
+
'staging-relay.clearly.sh',
|
|
85
|
+
'clearly.sh',
|
|
86
|
+
'*.clearly.sh',
|
|
87
|
+
'*.clearly-sh.pages.dev',
|
|
88
|
+
'bee-relay.throbbing-unit-1b3f.workers.dev',
|
|
89
|
+
'bee-relay-staging.throbbing-unit-1b3f.workers.dev',
|
|
90
|
+
// Google first-party API endpoints — all under googleapis.com.
|
|
91
|
+
'identitytoolkit.googleapis.com',
|
|
92
|
+
'securetoken.googleapis.com',
|
|
93
|
+
'firestore.googleapis.com',
|
|
94
|
+
'aiplatform.googleapis.com',
|
|
95
|
+
'*-aiplatform.googleapis.com',
|
|
96
|
+
'storage.googleapis.com',
|
|
97
|
+
];
|
|
98
|
+
/**
|
|
99
|
+
* Localhost + loopback are allowed — development convenience. Daemon runs
|
|
100
|
+
* on the user's machine so forwarding to 127.0.0.1:*:*` is strictly less
|
|
101
|
+
* powerful than what native code can already do.
|
|
102
|
+
*
|
|
103
|
+
* URL.hostname strips brackets from IPv6 literals for IP address forms but
|
|
104
|
+
* keeps them when there's a scope-id etc., so we include both shapes.
|
|
105
|
+
*/
|
|
106
|
+
const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']);
|
|
107
|
+
export function isDaemonFetchUrlAllowed(rawUrl) {
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = new URL(rawUrl);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { ok: false, reason: 'Invalid URL' };
|
|
114
|
+
}
|
|
115
|
+
// Only http(s). Block file://, data:, javascript:, etc. outright.
|
|
116
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
117
|
+
return { ok: false, reason: `Protocol ${parsed.protocol} not allowed` };
|
|
118
|
+
}
|
|
119
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
120
|
+
// http is only allowed to localhost — anything else must be https to
|
|
121
|
+
// avoid leaking the Firebase bearer token over plaintext.
|
|
122
|
+
if (parsed.protocol === 'http:' && !LOCALHOST_HOSTS.has(hostname)) {
|
|
123
|
+
return { ok: false, reason: 'http:// only permitted for localhost' };
|
|
124
|
+
}
|
|
125
|
+
if (LOCALHOST_HOSTS.has(hostname))
|
|
126
|
+
return { ok: true };
|
|
127
|
+
for (const pattern of ALLOWED_HOSTS) {
|
|
128
|
+
if (matchHost(hostname, pattern))
|
|
129
|
+
return { ok: true };
|
|
130
|
+
}
|
|
131
|
+
return { ok: false, reason: `Host ${hostname} not in daemon-fetch allowlist` };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* May this URL receive the user's Firebase ID token?
|
|
135
|
+
*
|
|
136
|
+
* Callers MUST gate the `Authorization` header on this, not on
|
|
137
|
+
* `isDaemonFetchUrlAllowed`. Fails closed: anything not explicitly
|
|
138
|
+
* listed in CREDENTIALED_HOSTS gets the request without a credential.
|
|
139
|
+
*/
|
|
140
|
+
export function mayReceiveCredentials(rawUrl) {
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = new URL(rawUrl);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
149
|
+
// localhost is the one http:// host the allowlist permits, and a dev
|
|
150
|
+
// server on the user's own machine is already inside the trust
|
|
151
|
+
// boundary — the token never leaves the loopback interface.
|
|
152
|
+
if (LOCALHOST_HOSTS.has(hostname))
|
|
153
|
+
return true;
|
|
154
|
+
// Everything else: never hand a bearer token to a plaintext endpoint.
|
|
155
|
+
if (parsed.protocol !== 'https:')
|
|
156
|
+
return false;
|
|
157
|
+
return CREDENTIALED_HOSTS.some((pattern) => matchHost(hostname, pattern));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Match a hostname against a pattern. `*` in the pattern matches a single
|
|
161
|
+
* non-empty hostname label (alphanumerics + hyphens). So:
|
|
162
|
+
* `*.clearly.sh` matches `foo.clearly.sh`, `a.b.clearly.sh`
|
|
163
|
+
* `*-aiplatform.googleapis.com` matches `us-central1-aiplatform.googleapis.com`
|
|
164
|
+
* `*.workers.dev` matches `my-worker.workers.dev`
|
|
165
|
+
*
|
|
166
|
+
* Leading `*.` is special-cased to also match the bare domain so that
|
|
167
|
+
* `*.clearly.sh` accepts `clearly.sh` itself without a separate entry.
|
|
168
|
+
*/
|
|
169
|
+
function matchHost(hostname, pattern) {
|
|
170
|
+
const p = pattern.toLowerCase();
|
|
171
|
+
if (p === hostname)
|
|
172
|
+
return true;
|
|
173
|
+
// Build a regex: `*` matches one non-empty label (alphanumerics + hyphens),
|
|
174
|
+
// literal dots escaped. Anchor both ends so partial matches can't sneak
|
|
175
|
+
// through.
|
|
176
|
+
//
|
|
177
|
+
// Design choice: `*.workers.dev` does NOT match bare `workers.dev` — if
|
|
178
|
+
// you want the root domain accepted, list it explicitly. This prevents
|
|
179
|
+
// a pattern meant for user subdomains from accidentally whitelisting
|
|
180
|
+
// the platform's landing page.
|
|
181
|
+
const regex = '^' +
|
|
182
|
+
p
|
|
183
|
+
.split('')
|
|
184
|
+
.map((ch) => {
|
|
185
|
+
if (ch === '*')
|
|
186
|
+
return '[a-z0-9-]+';
|
|
187
|
+
if (/[.+?^${}()|[\]\\]/.test(ch))
|
|
188
|
+
return '\\' + ch;
|
|
189
|
+
return ch;
|
|
190
|
+
})
|
|
191
|
+
.join('') +
|
|
192
|
+
'$';
|
|
193
|
+
return new RegExp(regex).test(hostname);
|
|
194
|
+
}
|