beehaven 0.8.37 → 0.8.39

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.
@@ -1,349 +0,0 @@
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
- }
@@ -1,191 +0,0 @@
1
- /**
2
- * Sandbox-then-commit Write policy (#62) — gate destructive Write tool
3
- * calls behind a temp-directory sandbox so a buggy agent can't
4
- * irrecoverably trash the user's filesystem.
5
- *
6
- * The pattern:
7
- * 1. Agent calls `Write` with file_path X + new content.
8
- * 2. canUseTool wrapper intercepts:
9
- * - mkdtemp under `~/.beehaven/sandbox/<sessionId>/`
10
- * - mirror the file (or its containing dir) into the sandbox
11
- * - write to the sandboxed copy
12
- * - return ok to the agent (it sees the write as "successful")
13
- * 3. On agent:done OR explicit `commit-sandbox` IPC verb, atomically
14
- * promote the sandbox into the real path.
15
- * 4. On `discard-sandbox` (timeout / agent error), rm -rf the
16
- * sandbox dir.
17
- *
18
- * Wins:
19
- * - One buggy agent turn doesn't permanently corrupt the user's
20
- * project. Diffs are reviewable before commit.
21
- * - Pair with `git-vfs.ts` (#103 Claude→Codex handoff) — the sandbox
22
- * dir is a git working tree, so the agent's edits show as
23
- * untracked changes the user can `git diff` against.
24
- *
25
- * Wiring (Mac app rebuild required after this lands):
26
- * - daemon-omni-hooks.ts: add a PreToolUse hook for `Write` that
27
- * calls `wrapInSandbox()` before the agent SDK dispatches.
28
- * - daemon-omni.ts: on agent:done, fire `commitSandbox(sessionId)`.
29
- * - Add `commit-sandbox` + `discard-sandbox` IPC verbs in
30
- * packages/cli/src/ipc-server.ts so the FAB can drive a "Review
31
- * changes → Apply" approval card (#59).
32
- */
33
- import { mkdir, copyFile, rename, rm, stat, readFile, writeFile } from 'node:fs/promises';
34
- import { dirname, join, normalize as normalizePath, isAbsolute, } from 'node:path';
35
- import { homedir } from 'node:os';
36
- import { existsSync } from 'node:fs';
37
- const SANDBOX_ROOT = join(homedir(), '.beehaven', 'sandbox');
38
- /**
39
- * Defense-in-depth path check (note from 2.19.0 audit). Callers today
40
- * pre-normalize realPath via the path-transform layer, so escape-via-`..`
41
- * isn't reachable in the current wiring. But this module is library
42
- * code — future callers may invoke it directly. Reject anything that
43
- * isn't an absolute, normalized path with no `..` segments.
44
- */
45
- function assertSafeRealPath(realPath) {
46
- if (typeof realPath !== 'string' || realPath.length === 0) {
47
- throw new Error('wrapInSandbox: realPath must be a non-empty string');
48
- }
49
- if (!isAbsolute(realPath)) {
50
- throw new Error(`wrapInSandbox: realPath must be absolute (got '${realPath}')`);
51
- }
52
- const normalized = normalizePath(realPath);
53
- if (normalized !== realPath) {
54
- throw new Error(`wrapInSandbox: realPath must be normalized — '${realPath}' normalizes to '${normalized}'`);
55
- }
56
- if (normalized.split('/').includes('..')) {
57
- throw new Error(`wrapInSandbox: realPath must not contain '..' segments (got '${realPath}')`);
58
- }
59
- }
60
- const _activeSandboxes = new Map();
61
- /**
62
- * Wrap a Write call in a sandbox. Returns the sandboxed path the
63
- * agent should actually write to. The caller is responsible for
64
- * subbing that path into the agent's tool-input before dispatch.
65
- */
66
- export async function wrapInSandbox(args) {
67
- assertSafeRealPath(args.realPath);
68
- // Per-session sandbox dir. Same session reuses the dir so multi-file
69
- // writes share a coherent staging area.
70
- const sessionDir = await ensureSessionDir(args.sessionId);
71
- // Mirror the realPath structure inside the sandbox. For a real path
72
- // `/Users/me/proj/src/foo.ts` we land at
73
- // `~/.beehaven/sandbox/<sid>/Users/me/proj/src/foo.ts`. Absolute-path
74
- // preservation lets the commit step paths-back-1-to-1.
75
- const sandboxPath = join(sessionDir, args.realPath.replace(/^\//, ''));
76
- await mkdir(dirname(sandboxPath), { recursive: true });
77
- // Snapshot existing content (for restore-on-discard). Skip for
78
- // files > 5 MB — we don't want to balloon disk on a large file
79
- // sandbox. Those skip restore, and discard becomes "delete only."
80
- let originalContent;
81
- let preExisting = false;
82
- if (existsSync(args.realPath)) {
83
- preExisting = true;
84
- try {
85
- const s = await stat(args.realPath);
86
- if (s.size < 5 * 1024 * 1024) {
87
- originalContent = await readFile(args.realPath, 'utf8');
88
- }
89
- // Pre-populate the sandbox with the current file so partial
90
- // edits (e.g. agent appending) start from real state.
91
- await copyFile(args.realPath, sandboxPath);
92
- }
93
- catch {
94
- // Source unreadable — let the agent write its content fresh
95
- // into the sandbox.
96
- }
97
- }
98
- // If the agent supplied content, write it to the sandbox up-front
99
- // so the inspector RPC can show it without waiting for the agent
100
- // to finish.
101
- if (args.content !== undefined) {
102
- await writeFile(sandboxPath, args.content, 'utf8');
103
- }
104
- const sandboxedWrite = {
105
- sessionId: args.sessionId,
106
- realPath: args.realPath,
107
- sandboxPath,
108
- preExisting,
109
- originalContent,
110
- ts: Date.now(),
111
- };
112
- const list = _activeSandboxes.get(args.sessionId) ?? [];
113
- list.push(sandboxedWrite);
114
- _activeSandboxes.set(args.sessionId, list);
115
- return { sandboxPath, sandboxedWrite };
116
- }
117
- /**
118
- * Atomically promote a session's sandboxed writes into their real
119
- * paths. Uses fs.rename which is atomic on the same filesystem.
120
- *
121
- * Returns the list of {realPath, ok, error?} per file so the UI can
122
- * surface which writes landed cleanly.
123
- */
124
- export async function commitSandbox(sessionId) {
125
- const writes = _activeSandboxes.get(sessionId) ?? [];
126
- const results = [];
127
- for (const w of writes) {
128
- try {
129
- await mkdir(dirname(w.realPath), { recursive: true });
130
- // rename is atomic on same FS; if cross-FS, fall back to copy.
131
- try {
132
- await rename(w.sandboxPath, w.realPath);
133
- }
134
- catch {
135
- await copyFile(w.sandboxPath, w.realPath);
136
- await rm(w.sandboxPath, { force: true });
137
- }
138
- results.push({ realPath: w.realPath, ok: true });
139
- }
140
- catch (err) {
141
- results.push({ realPath: w.realPath, ok: false, error: err?.message ?? 'commit-failed' });
142
- }
143
- }
144
- _activeSandboxes.delete(sessionId);
145
- // Best-effort: nuke the session dir so the next session starts clean.
146
- try {
147
- await rm(await sessionDirPath(sessionId), { recursive: true, force: true });
148
- }
149
- catch { }
150
- return results;
151
- }
152
- /**
153
- * Discard a session's sandboxed writes. Restores any pre-existing
154
- * content from `originalContent` snapshots; deletes new files.
155
- */
156
- export async function discardSandbox(sessionId) {
157
- const writes = _activeSandboxes.get(sessionId) ?? [];
158
- let restored = 0;
159
- for (const w of writes) {
160
- if (w.preExisting && w.originalContent !== undefined) {
161
- try {
162
- await writeFile(w.realPath, w.originalContent, 'utf8');
163
- restored++;
164
- }
165
- catch { }
166
- }
167
- }
168
- _activeSandboxes.delete(sessionId);
169
- try {
170
- await rm(await sessionDirPath(sessionId), { recursive: true, force: true });
171
- }
172
- catch { }
173
- return { discarded: writes.length, restored };
174
- }
175
- /**
176
- * List a session's pending writes. Powers the FAB approval card's
177
- * "Review N changes" affordance.
178
- */
179
- export function listPendingWrites(sessionId) {
180
- return _activeSandboxes.get(sessionId) ?? [];
181
- }
182
- async function ensureSessionDir(sessionId) {
183
- const dir = await sessionDirPath(sessionId);
184
- await mkdir(dir, { recursive: true });
185
- return dir;
186
- }
187
- async function sessionDirPath(sessionId) {
188
- // Sanitize sessionId — agent-supplied. Anything outside [a-z0-9-_] becomes _.
189
- const safe = sessionId.replace(/[^a-z0-9\-_]/gi, '_').slice(0, 64);
190
- return join(SANDBOX_ROOT, safe);
191
- }