@clearlysh/beehaven 0.8.37 → 0.8.38
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/dist/agent-mint-response.js +10 -0
- package/dist/build-channel.js +2 -2
- package/dist/clearly.js +0 -0
- package/dist/cli.js +13 -3
- package/dist/commands/agent.js +5 -3
- package/dist/commands/daemon-cmd.js +17 -1
- package/dist/connection-ready.js +25 -0
- package/dist/daemon-process.js +26 -11
- package/dist/daemon.js +4 -3
- package/dist/relay.js +10 -1
- package/package.json +1 -1
- package/dist/daemon-codex.js +0 -349
- package/dist/daemon-write-sandbox.js +0 -191
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Only an explicit appearance choice may rebind a portable identity on another device. */
|
|
2
|
+
export function accountMintRequest(agentId, requestedAvatarId) {
|
|
3
|
+
return { agentId, ...(requestedAvatarId ? { avatarId: requestedAvatarId } : {}) };
|
|
4
|
+
}
|
|
5
|
+
export function accountMintResponse(response) {
|
|
6
|
+
const value = response?.result ?? response;
|
|
7
|
+
if (!value?.token)
|
|
8
|
+
throw Object.assign(new Error(`${String(value?.error || 'The server returned no agent token')}${value?.code ? ` (${value.code})` : ''}`), { code: value?.code || 'mint-refused' });
|
|
9
|
+
return value;
|
|
10
|
+
}
|
package/dist/build-channel.js
CHANGED
|
@@ -53,9 +53,9 @@ export const PUBLIC_BUILD = true;
|
|
|
53
53
|
* ⚠ REWRITTEN IN `dist/` AT BUILD TIME, like `PUBLIC_BUILD`. `unstamped` is what a source checkout
|
|
54
54
|
* carries and what `tsx`/`vitest` see, and it is honest: nothing has stamped them.
|
|
55
55
|
*/
|
|
56
|
-
export const BUILD_SHA = '
|
|
56
|
+
export const BUILD_SHA = '6f5ce18b17c5';
|
|
57
57
|
/** ISO-8601 minute precision, UTC. Rewritten in `dist/` by `scripts/stamp-build-channel.mjs`. */
|
|
58
|
-
export const BUILD_TIME = '2026-10-
|
|
58
|
+
export const BUILD_TIME = '2026-10-30T07:25Z';
|
|
59
59
|
/**
|
|
60
60
|
* The build identity as one short string, for `--version`.
|
|
61
61
|
*
|
package/dist/clearly.js
CHANGED
|
File without changes
|
package/dist/cli.js
CHANGED
|
@@ -3064,13 +3064,13 @@ async function run() {
|
|
|
3064
3064
|
}
|
|
3065
3065
|
console.log('');
|
|
3066
3066
|
console.log(` ${color.green('✓')} Signed in as ${color.bold(captured.email ?? captured.userId)}`);
|
|
3067
|
-
console.log(`
|
|
3067
|
+
console.log(` Sign-in saved on this device`);
|
|
3068
3068
|
// Auto-restart the daemon. New tokens won't take effect until
|
|
3069
3069
|
// the daemon restarts (it reads creds at startup, keeps them
|
|
3070
3070
|
// in memory, has no hot-reload IPC verb). Doing the restart
|
|
3071
3071
|
// here matches gh / firebase-tools UX — login-then-go.
|
|
3072
3072
|
const { findLiveDaemons, stopDaemonProcess } = await import('./daemon.js');
|
|
3073
|
-
const { spawnStandaloneDaemon } = await import('./commands/daemon-cmd.js');
|
|
3073
|
+
const { spawnStandaloneDaemon, waitForLoginConnection } = await import('./commands/daemon-cmd.js');
|
|
3074
3074
|
/**
|
|
3075
3075
|
* ⚠⚠ THE RESTART IS PLUMBING AND WAS NARRATED AS THOUGH IT WERE THE POINT. What a person saw
|
|
3076
3076
|
* after signing in was:
|
|
@@ -3115,6 +3115,7 @@ async function run() {
|
|
|
3115
3115
|
* started with developer scope.
|
|
3116
3116
|
*/
|
|
3117
3117
|
const pid = spawnStandaloneDaemon(values);
|
|
3118
|
+
const connection = await waitForLoginConnection();
|
|
3118
3119
|
/**
|
|
3119
3120
|
* ⚠ ONE OUTCOME LINE, IN THE SAME SHAPE AS THE ONE ABOVE IT. `✓ (PID 73532)` was a tick, a
|
|
3120
3121
|
* bracket and a number — grammatically nothing, and unreadable as a result. This says what
|
|
@@ -3125,7 +3126,16 @@ async function run() {
|
|
|
3125
3126
|
* `beehaven status` labels the same thing `Relay:`. Naming the outcome rather than the
|
|
3126
3127
|
* process is what stops a sign-in ending on the word "stopped".
|
|
3127
3128
|
*/
|
|
3128
|
-
|
|
3129
|
+
if (connection.ready) {
|
|
3130
|
+
console.log(` ${color.green('✓')} Connected — your new sign-in is live`);
|
|
3131
|
+
}
|
|
3132
|
+
else {
|
|
3133
|
+
console.error(` ${color.yellow('!')} Signed in, but the workspace connection is not ready.`);
|
|
3134
|
+
console.error(connection.reason === 'environment-mismatch'
|
|
3135
|
+
? ' The running service is on a different environment. Run `beehaven status` before retrying.'
|
|
3136
|
+
: ' Run `beehaven status` to check the connection, then retry your workspace command.');
|
|
3137
|
+
process.exitCode = 2;
|
|
3138
|
+
}
|
|
3129
3139
|
console.log(` Background service ${wasRunning ? 'restarted' : 'started'} (PID ${pid})`);
|
|
3130
3140
|
/**
|
|
3131
3141
|
* ⚠⚠ TEACH THE AGENTS AS PART OF SIGNING IN (PPO-190). "Install the CLI, log in, and your
|
package/dist/commands/agent.js
CHANGED
|
@@ -25,6 +25,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
|
25
25
|
import { join, dirname } from 'node:path';
|
|
26
26
|
import { homedir } from 'node:os';
|
|
27
27
|
import { rpcOnce } from '../ipc-client.js';
|
|
28
|
+
import { accountMintRequest, accountMintResponse } from '../agent-mint-response.js';
|
|
28
29
|
import { saveAgent, loadAgent, listAgents, deleteAgent, findAgentByAvatar, isValidAgentName, resolveActiveAgent, activeAgentName, saveAgentSession, clearAgentSession, clearAgentSessionId, decodeAgentTokenClaims, } from '../agent-state.js';
|
|
29
30
|
const VALID_KINDS = ['claude-code', 'daemon-agent', 'clearly-ai'];
|
|
30
31
|
export const FREE_SKINS = [
|
|
@@ -643,7 +644,7 @@ async function agentLogin(args, values) {
|
|
|
643
644
|
}
|
|
644
645
|
const avatarId = requestedAvatarId || existingRecord?.avatarId || chosenSkin || name;
|
|
645
646
|
// Local-first avatar-collision guard (durable guard is the cloud pool).
|
|
646
|
-
const collision = findAgentByAvatar(
|
|
647
|
+
const collision = requestedAvatarId ? findAgentByAvatar(requestedAvatarId, name) : null;
|
|
647
648
|
if (collision) {
|
|
648
649
|
console.error(` Avatar "${avatarId}" is already claimed by agent "${collision}".`);
|
|
649
650
|
console.error(` Pick another: beehaven agent login ${name} --avatar <other-id>`);
|
|
@@ -670,7 +671,7 @@ async function agentLogin(args, values) {
|
|
|
670
671
|
* credential.
|
|
671
672
|
*/
|
|
672
673
|
try {
|
|
673
|
-
const cloud = await rpcOnce('agent-account-mint',
|
|
674
|
+
const cloud = accountMintResponse(await rpcOnce('agent-account-mint', accountMintRequest(name, requestedAvatarId), 15_000));
|
|
674
675
|
const tok = cloud?.token ?? cloud?.result?.token;
|
|
675
676
|
if (tok) {
|
|
676
677
|
minted = {
|
|
@@ -698,7 +699,8 @@ async function agentLogin(args, values) {
|
|
|
698
699
|
}
|
|
699
700
|
catch (err) {
|
|
700
701
|
console.error(` Could not mint identity: ${err?.message ?? err}`);
|
|
701
|
-
|
|
702
|
+
if (!err?.code || /^(ECONN|ENOENT|ETIMEDOUT)/.test(String(err.code)))
|
|
703
|
+
console.error(' Is the daemon connected? Check with: beehaven status');
|
|
702
704
|
process.exit(1);
|
|
703
705
|
}
|
|
704
706
|
/**
|
|
@@ -16,6 +16,22 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
import { existsSync } from 'node:fs';
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
18
|
import { findLiveDaemons, resolveRelayUrlForDaemon } from '../daemon.js';
|
|
19
|
+
import { waitForConnection } from '../connection-ready.js';
|
|
20
|
+
/** Each attempt authenticates over IPC; a PID or greeting alone cannot prove a usable sign-in. */
|
|
21
|
+
export async function waitForLoginConnection() {
|
|
22
|
+
const { IpcClient } = await import('../ipc-client.js');
|
|
23
|
+
return waitForConnection(async () => {
|
|
24
|
+
const client = new IpcClient({ timeout: 1000 });
|
|
25
|
+
try {
|
|
26
|
+
await client.connect();
|
|
27
|
+
const state = await client.call('daemon:session-status', {}, 1000);
|
|
28
|
+
return { connected: state?.relay?.connected === true, endpoint: state?.relay?.endpoint };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
client.disconnect();
|
|
32
|
+
}
|
|
33
|
+
}, resolveRelayUrlForDaemon());
|
|
34
|
+
}
|
|
19
35
|
/** dist/daemon-entry.js — sibling of dist/cli.js (this file is dist/commands/). */
|
|
20
36
|
function daemonEntryPath() {
|
|
21
37
|
return join(dirname(fileURLToPath(import.meta.url)), '..', 'daemon-entry.js');
|
|
@@ -82,7 +98,7 @@ export async function startHeadlessDaemon(values) {
|
|
|
82
98
|
const { daemonRpc } = await import('../daemon-client.js');
|
|
83
99
|
let activeRelay = null;
|
|
84
100
|
try {
|
|
85
|
-
const status = await daemonRpc('daemon:status', {});
|
|
101
|
+
const status = await daemonRpc('daemon:session-status', {});
|
|
86
102
|
activeRelay = status?.relay?.endpoint ?? null;
|
|
87
103
|
}
|
|
88
104
|
catch { /* status is advisory; the socket probe already succeeded */ }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export async function waitForConnection(probe, expectedEndpoint, options = {}) {
|
|
2
|
+
const now = options.now ?? Date.now;
|
|
3
|
+
const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
4
|
+
const deadline = now() + (options.timeoutMs ?? 30_000);
|
|
5
|
+
const normalize = (value) => value.replace(/\/+$/, '');
|
|
6
|
+
let reason = 'starting';
|
|
7
|
+
do {
|
|
8
|
+
try {
|
|
9
|
+
const state = await probe();
|
|
10
|
+
if (state.endpoint && normalize(state.endpoint) !== normalize(expectedEndpoint))
|
|
11
|
+
return { ready: false, reason: 'environment-mismatch' };
|
|
12
|
+
if (state.connected && state.endpoint && normalize(state.endpoint) === normalize(expectedEndpoint))
|
|
13
|
+
return { ready: true, reason: 'connected' };
|
|
14
|
+
reason = 'relay-unavailable';
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
reason = 'starting';
|
|
18
|
+
}
|
|
19
|
+
const remaining = deadline - now();
|
|
20
|
+
if (remaining <= 0)
|
|
21
|
+
break;
|
|
22
|
+
await sleep(Math.min(options.pollMs ?? 250, remaining));
|
|
23
|
+
} while (now() < deadline);
|
|
24
|
+
return { ready: false, reason };
|
|
25
|
+
}
|
package/dist/daemon-process.js
CHANGED
|
@@ -8,8 +8,15 @@
|
|
|
8
8
|
* daemon. Windows reads command lines from one CIM query instead.
|
|
9
9
|
*/
|
|
10
10
|
import { execFileSync } from 'child_process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
export const CURRENT_DAEMON_ENTRY = fileURLToPath(new URL('./daemon-entry.js', import.meta.url));
|
|
13
|
+
/** pgrep receives a regex argument, so every metacharacter in a user-chosen path is literal. */
|
|
14
|
+
export function daemonEntryPattern(entry = CURRENT_DAEMON_ENTRY) {
|
|
15
|
+
const special = '\\.^$*+?()[]{}|';
|
|
16
|
+
return Array.from(entry, character => special.includes(character) ? '\\' + character : character).join('');
|
|
17
|
+
}
|
|
11
18
|
/**
|
|
12
|
-
* Whether a full command line is a beehaven daemon or CLI
|
|
19
|
+
* Whether a full command line is a beehaven daemon or a legacy CLI-start daemon.
|
|
13
20
|
*
|
|
14
21
|
* Strict two-condition test (unchanged on macOS/Linux):
|
|
15
22
|
* 1. argv[0] is a node/tsx/beehaven binary — not a shell, an editor, a grep, etc.;
|
|
@@ -20,14 +27,17 @@ import { execFileSync } from 'child_process';
|
|
|
20
27
|
* an extension (`"C:\Program Files\nodejs\node.exe" C:\…\daemon-entry.js`). It is reduced to the
|
|
21
28
|
* Unix shape (`C:/Program_Files/nodejs/node …`) so the same two conditions apply, unquoted.
|
|
22
29
|
*/
|
|
23
|
-
export function isBeehavenCommandLine(raw, platform = process.platform) {
|
|
30
|
+
export function isBeehavenCommandLine(raw, platform = process.platform, expectedEntry = CURRENT_DAEMON_ENTRY, executable = process.execPath) {
|
|
24
31
|
let cmd = (raw ?? '').trim();
|
|
25
32
|
if (!cmd)
|
|
26
33
|
return false;
|
|
34
|
+
const ownExecutable = platform !== 'win32' && (cmd.startsWith(executable + ' ') || cmd.startsWith(`"${executable}" `));
|
|
35
|
+
let tail = ownExecutable ? cmd.slice(cmd.startsWith('"') ? executable.length + 3 : executable.length + 1).trim() : cmd.replace(/^\S+\s*/, '');
|
|
27
36
|
if (platform === 'win32') {
|
|
28
37
|
const m = /^"([^"]*)"\s*(.*)$/s.exec(cmd) ?? /^(\S+)\s*(.*)$/s.exec(cmd);
|
|
29
38
|
if (!m)
|
|
30
39
|
return false;
|
|
40
|
+
tail = m[2].trim();
|
|
31
41
|
const argv0 = m[1].replace(/\\/g, '/').replace(/\.(exe|cmd)$/i, '').replace(/\s+/g, '_').toLowerCase();
|
|
32
42
|
cmd = `${argv0} ${m[2].replace(/"/g, '')}`.trim();
|
|
33
43
|
}
|
|
@@ -35,15 +45,20 @@ export function isBeehavenCommandLine(raw, platform = process.platform) {
|
|
|
35
45
|
// running a heredoc that contains "node .../cli.js" deeper in its argv doesn't false-positive.
|
|
36
46
|
const argv0Ok = /^(\S*\/)?node(\s|$)/.test(cmd) ||
|
|
37
47
|
/^(\S*\/)?tsx(\s|$)/.test(cmd) ||
|
|
38
|
-
/^(\S*\/)?beehaven(\s|$)/.test(cmd);
|
|
48
|
+
/^(\S*\/)?beehaven(\s|$)/.test(cmd) || ownExecutable;
|
|
39
49
|
if (!argv0Ok)
|
|
40
50
|
return false;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
51
|
+
const firstArgument = tail.startsWith('"') ? tail.slice(1).split('"')[0] : tail.split(/\s/)[0];
|
|
52
|
+
if (/^-(?:e|p|ep|pe)$|^--(?:eval|print)(?:=|$)/.test(firstArgument))
|
|
53
|
+
return false;
|
|
54
|
+
// A custom installer prefix need not contain the word "beehaven". Trust only this package's
|
|
55
|
+
// exact entry as the direct script argument; a quoted mention inside `node -e` is not a daemon.
|
|
56
|
+
const normalize = (s) => platform === 'win32' ? s.replace(/\\/g, '/').toLowerCase() : s;
|
|
57
|
+
const rest = normalize(tail), entry = normalize(expectedEntry);
|
|
58
|
+
if ([entry, `"${entry}"`].some(value => rest === value || rest.startsWith(value + ' ')))
|
|
59
|
+
return true;
|
|
60
|
+
// Windows has no legacy CLI-start daemons. Unix retains that old start shape, but ordinary
|
|
61
|
+
// CLI calls and eval/print processes are never daemon candidates on any platform.
|
|
47
62
|
if (platform === 'win32')
|
|
48
63
|
return /[/\\]\.?beehaven([-_]sdk)?[/\\].*daemon-entry\.(js|ts)\b/.test(cmd);
|
|
49
64
|
// Condition 2: a subsequent argument must reference a beehaven script.
|
|
@@ -59,8 +74,8 @@ export function isBeehavenCommandLine(raw, platform = process.platform) {
|
|
|
59
74
|
/[/\\]\.?beehaven([-_]sdk)?[/\\].*daemon-entry\.(js|ts)\b/.test(cmd) ||
|
|
60
75
|
/Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]daemon-entry\.js\b/.test(cmd) ||
|
|
61
76
|
// Legacy `cli.js start` — for old bundles pre-dating daemon-entry.js.
|
|
62
|
-
/[/\\]\.?beehaven([-_]sdk)?[/\\].*cli\.(js|ts)
|
|
63
|
-
/Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]cli\.js
|
|
77
|
+
/[/\\]\.?beehaven([-_]sdk)?[/\\].*cli\.(js|ts)["']?\s+start(?:\s|$)/.test(cmd) ||
|
|
78
|
+
/Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]cli\.js["']?\s+start(?:\s|$)/.test(cmd) ||
|
|
64
79
|
// Bare `beehaven start` — wrapper already matched argv[0].
|
|
65
80
|
/^(\S*\/)?beehaven\s+start\b/.test(cmd));
|
|
66
81
|
}
|
package/dist/daemon.js
CHANGED
|
@@ -14,7 +14,7 @@ import { browsePath } from './file-browser.js';
|
|
|
14
14
|
import { CloudBeeProxy } from './bee/cloud-proxy.js';
|
|
15
15
|
import { IpcServer, probeIpcSocket } from './ipc-server.js';
|
|
16
16
|
import { ipcSocketPath } from './ipc-path.js';
|
|
17
|
-
import { isBeehavenCommandLine, windowsNodeProcesses, selectWindowsDaemons } from './daemon-process.js';
|
|
17
|
+
import { isBeehavenCommandLine, windowsNodeProcesses, selectWindowsDaemons, daemonEntryPattern } from './daemon-process.js';
|
|
18
18
|
import { mintIpcAuthTokens, clearIpcAuthToken } from './ipc-auth.js';
|
|
19
19
|
/**
|
|
20
20
|
* What THIS daemon minted, so shutdown can avoid scrubbing a successor's token.
|
|
@@ -41,7 +41,7 @@ import { AgentSessions } from './agent-sessions.js';
|
|
|
41
41
|
import { RELAY_PRESETS, relayUrlFromConfig, resolveRelaySelection } from './relay-presets.js';
|
|
42
42
|
import { readFileSync, existsSync, mkdirSync, mkdtempSync, writeFileSync, readdirSync, chmodSync, unlinkSync, rmSync, } from 'fs';
|
|
43
43
|
import { createHash } from 'crypto';
|
|
44
|
-
import { execFile, execSync, spawn } from 'child_process';
|
|
44
|
+
import { execFile, execFileSync, execSync, spawn } from 'child_process';
|
|
45
45
|
import { homedir, tmpdir } from 'os';
|
|
46
46
|
import { join, dirname, relative } from 'path';
|
|
47
47
|
import { fileURLToPath } from 'url';
|
|
@@ -1228,13 +1228,14 @@ export function findLiveDaemons() {
|
|
|
1228
1228
|
// re-validated by isBeehavenProcess so a broad pgrep hit still has
|
|
1229
1229
|
// to pass the strict regex check before being considered ours.
|
|
1230
1230
|
const patterns = [
|
|
1231
|
+
daemonEntryPattern(),
|
|
1231
1232
|
'beehaven.*daemon-entry\\.(js|ts)',
|
|
1232
1233
|
'beehaven.*cli\\.(js|ts)',
|
|
1233
1234
|
'Clearly.*Resources/beehaven',
|
|
1234
1235
|
];
|
|
1235
1236
|
for (const pattern of patterns) {
|
|
1236
1237
|
try {
|
|
1237
|
-
const out =
|
|
1238
|
+
const out = execFileSync('pgrep', ['-f', pattern], {
|
|
1238
1239
|
encoding: 'utf8',
|
|
1239
1240
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1240
1241
|
}).trim();
|
package/dist/relay.js
CHANGED
|
@@ -518,7 +518,16 @@ export class Relay {
|
|
|
518
518
|
expiredNoRefresh = true;
|
|
519
519
|
}
|
|
520
520
|
}
|
|
521
|
-
catch {
|
|
521
|
+
catch {
|
|
522
|
+
// A damaged cached ID token has no decodable exp, but a valid refresh session can still
|
|
523
|
+
// recover it. Do not strand that account in an unauthenticated reconnect loop. Opaque
|
|
524
|
+
// self-hosted tokens without a refresh session retain their existing dial behavior.
|
|
525
|
+
if (this.config.refreshToken) {
|
|
526
|
+
const refreshed = await this.refreshIdToken();
|
|
527
|
+
if (refreshed && this.config)
|
|
528
|
+
token = this.config.token;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
522
531
|
// If the token is already expired AND refresh failed, dialing would just
|
|
523
532
|
// get rejected — spinning the reconnect loop forever with a dead
|
|
524
533
|
// credential (the classic "daemon stuck reconnecting" state). Back off
|
package/package.json
CHANGED
package/dist/daemon-codex.js
DELETED
|
@@ -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
|
-
}
|