From 7b71f8fb00cd018fedd28aeda268aef4163b7842 Mon Sep 17 00:00:00 2001 From: Emile Date: Thu, 29 Oct 2026 12:04:02 -0400 Subject: [PATCH 1/8] Add @upstash/box (recruits can work in an Upstash Box) Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 19 ++++++++++++++++++- package.json | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 3311cc1..47e443a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,9 @@ "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.291", - "@lydell/node-pty": "^1.2.0-beta.15", + "@lydell/node-pty": "1.2.0-beta.15", "@modelcontextprotocol/sdk": "^1.30.1", + "@upstash/box": "^0.7.10", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", "express": "^5.2.1", @@ -6415,6 +6416,22 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/@upstash/box": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@upstash/box/-/box-0.7.10.tgz", + "integrity": "sha512-CPA4yyusIybDt+MlnrMgHTBFfFCYyn7OMkRhgYibSVmltzMDcRzbDCTgpN/Oge20uIzl2KVvD6fXs0odhBpjHA==", + "license": "MIT", + "dependencies": { + "ws": "^8.20.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index d41d47e..181d6c3 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.291", "@lydell/node-pty": "1.2.0-beta.15", "@modelcontextprotocol/sdk": "^1.30.1", + "@upstash/box": "^0.7.10", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", "express": "^5.2.1", From 11694defbb827129cb2fcef752f238e6354f7037 Mon Sep 17 00:00:00 2001 From: Emile Date: Thu, 29 Oct 2026 12:07:22 -0400 Subject: [PATCH 2/8] Upstash Box layer: boxes, desks and sign-in checks in a box; a CLI in a box behind the Pty interface (tmux, events file) Smoke-tested against a real box (scripts/box-smoke.ts): output, typing, events and exit code. Co-Authored-By: Claude Opus 5.5 --- scripts/box-smoke.ts | 43 ++++++++ server/boxRunner.ts | 204 +++++++++++++++++++++++++++++++++++ server/boxes.test.ts | 111 +++++++++++++++++++ server/boxes.ts | 252 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 610 insertions(+) create mode 100644 scripts/box-smoke.ts create mode 100644 server/boxRunner.ts create mode 100644 server/boxes.test.ts create mode 100644 server/boxes.ts diff --git a/scripts/box-smoke.ts b/scripts/box-smoke.ts new file mode 100644 index 0000000..4a95014 --- /dev/null +++ b/scripts/box-smoke.ts @@ -0,0 +1,43 @@ +// A real check of a CLI in an Upstash Box (server/boxRunner.ts), against a box the Overlord names: +// node scripts/box-smoke.ts +// Runs a small interactive program under tmux in the box, types into it, follows its events file, and reports the +// exit code. Needs UPSTASH_BOX_API_KEY. Uses no AI and changes nothing outside its session folder in the box. +import { BoxPty } from '../server/boxRunner.ts'; +import { BOX_SESSIONS, boxRun, getBox } from '../server/boxes.ts'; + +const boxId = process.argv[2]; +if (!boxId) throw new Error('usage: node scripts/box-smoke.ts '); +const dir = `${BOX_SESSIONS}/smoke-${Date.now()}`; +const box = await getBox(boxId); +await boxRun(box, `mkdir -p '${dir}'`); +const program = String.raw`echo "hello from $(hostname)"; echo '{"hook_event_name":"Stop","last_assistant_message":"first"}' >> "$CD_EVENTS_FILE"; read -r line; echo "you typed: $line"; echo "{\"hook_event_name\":\"Stop\",\"last_assistant_message\":\"$line\"}" >> "$CD_EVENTS_FILE"; sleep 1; exit 7`; +let screen = ''; +const events: unknown[] = []; +const t0 = Date.now(); +const p = new BoxPty({ + boxId, + dir, + cwd: '/home/boxuser', + file: 'bash', + args: ['-c', program], + env: { TERM: 'xterm-256color' }, + cols: 100, + rows: 20, + onEvent: (e) => events.push(e), + onNotice: (t) => console.log('notice:', t), +}); +p.onData((d) => (screen += d)); +const code = await new Promise((resolve) => { + p.onExit(resolve); + const typeWhenReady = setInterval(() => { + if (/hello from/.test(screen)) { + clearInterval(typeWhenReady); + p.write('fish cakes\r'); + } + }, 300); + setTimeout(() => resolve(-99), 90_000); +}); +const plain = screen.replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b[()][A-Z0-9]|\x1b[=>]|\r/g, ''); +console.log(JSON.stringify({ code, seconds: ((Date.now() - t0) / 1000).toFixed(1), hello: /hello from/.test(plain), echoed: /you typed: fish cakes/.test(plain), events }, null, 1)); +await boxRun(box, `rm -rf '${dir}'`); +process.exit(code === 7 && events.length === 2 ? 0 : 1); diff --git a/server/boxRunner.ts b/server/boxRunner.ts new file mode 100644 index 0000000..70109eb --- /dev/null +++ b/server/boxRunner.ts @@ -0,0 +1,204 @@ +// A CLI running in a recruit's Upstash Box, behind the same Pty interface as one on this computer (ptyClient.ts), so +// the session code (cliRunner.ts) drives both alike. +// The CLI runs in tmux in the box, so a dropped connection costs nothing: the office attaches to it with a terminal +// session over the Box API and reattaches when that drops. Its hooks write to an events file in the box, which a second +// session follows (tail -F) and hands to the same hook handler local CLIs reach over HTTP. +import type { Box } from '@upstash/box'; +import { BOX_DIR, boxRun, eventLines, getBox, launchScript, shq } from './boxes.ts'; +import type { Pty } from './ptyClient.ts'; + +/** tmux for a recruit's CLI: no status line, no prefix key, its own scrollback off (the office keeps one). */ +export const TMUX_CONF = ['set -g status off', 'set -g prefix None', 'unbind-key C-b', 'set -g escape-time 0', 'set -g history-limit 1', 'set -g default-terminal xterm-256color', 'setw -g alternate-screen off', ''].join('\n'); + +export interface BoxCliOptions { + boxId: string; + /** The session's folder in the box (cliRunner writes its files there). */ + dir: string; + cwd: string; + file: string; + args: string[]; + env: Record; + cols: number; + rows: number; + /** Each event the CLI's hooks wrote (the events file in `dir`). */ + onEvent: (body: Record) => void; + /** Something the office should tell the Overlord about the connection (reconnecting, lost). */ + onNotice?: (text: string) => void; +} + +/** How long to wait before the n-th reconnection: 1, 2, 4โ€ฆ up to 30 seconds. */ +export const backoff = (n: number) => Math.min(30_000, 1000 * 2 ** Math.max(0, n)); + +type Session = Awaited>; + +/** A recruit's CLI in their box. Starts at once; output and exit arrive through the callbacks. */ +export class BoxPty implements Pty { + readonly pid = 0; + private dataCbs: ((d: string) => void)[] = []; + private exitCbs: ((code: number) => void)[] = []; + private box: Box | null = null; + private term: Session | null = null; + private tail: Session | null = null; + private queued: string[] = []; + private cols: number; + private rows: number; + private ended = false; + private killed = false; + private offset = 0; // bytes of the events file already read + private pending = ''; + private decoder = new TextDecoder(); + private tailDecoder = new TextDecoder(); + private readonly sock: string; + private readonly o: BoxCliOptions; + + constructor(o: BoxCliOptions) { + this.o = o; + this.cols = o.cols; + this.rows = o.rows; + this.sock = `cd-${o.dir.split('/').pop()!.slice(0, 12)}`; + void this.start().catch((err) => this.fail(`Could not start in the box: ${(err as Error).message}`)); + } + + get events() { + return `${this.o.dir}/events.jsonl`; + } + + private get exitFile() { + return `${this.o.dir}/exit`; + } + + private tmux(...args: string[]) { + return ['tmux', '-L', this.sock, '-f', `${BOX_DIR}/tmux.conf`, ...args]; + } + + private async start() { + this.box = await getBox(this.o.boxId); + if ((await this.box.getStatus()).status === 'paused') await this.box.resume(); + await this.box.files.write({ path: `${BOX_DIR}/tmux.conf`, content: TMUX_CONF }); + const script = `${this.o.dir}/launch.sh`; + await this.box.files.write({ path: script, content: launchScript({ cwd: this.o.cwd, file: this.o.file, args: this.o.args, env: { ...this.o.env, CD_EVENTS_FILE: this.events }, exitFile: this.exitFile }) }); + // One recruit per box: a CLI the office lost track of (a restart) is stopped before a new one starts. + const started = await boxRun( + this.box, + [`pkill -x tmux 2>/dev/null; rm -f ${shq(this.exitFile)}; : > ${shq(this.events)}`, this.tmux('new-session', '-d', '-s', 'main', '-x', String(this.cols), '-y', String(this.rows), `bash ${shq(script)}`).map(shq).join(' ')].join(' && '), + ); + if (!started.ok) throw new Error(started.out.trim() || 'tmux would not start'); + if (this.killed) return void this.stopBox(); + await Promise.all([this.attach(0), this.follow(0)]); + } + + /** The terminal: a PTY session running `tmux attach`. When it ends, the CLI has exited or the connection dropped. */ + private async attach(tries: number) { + if (this.ended || !this.box) return; + let s: Session; + try { + s = await this.box.exec.session({ argv: this.tmux('attach', '-t', 'main'), tty: true, cols: this.cols, rows: this.rows, env: ['TERM=xterm-256color'], onStdout: (d) => this.emit(this.decoder.decode(d, { stream: true })) }); + } catch (err) { + return this.lostTerminal(tries, err as Error); + } + this.term = s; + for (const q of this.queued.splice(0)) s.write(q); + await s.wait().catch(() => -1); + if (this.term === s) this.term = null; + if (this.ended) return; + return this.lostTerminal(tries, null); + } + + /** The attach ended: the CLI exited (tmux is gone), or the connection dropped (reattach). */ + private async lostTerminal(tries: number, err: Error | null) { + if (this.ended || !this.box) return; + const alive = await boxRun(this.box, `${this.tmux('has-session', '-t', 'main').map(shq).join(' ')} && echo alive || echo gone`).catch(() => null); + if (alive && /gone/.test(alive.out)) return this.exited(); + if (tries === 0) this.o.onNotice?.(`๐Ÿ”Œ The connection to the box dropped${err ? ` (${err.message})` : ''}; reconnecting. The recruit carries on in the box.`); + setTimeout(() => void this.attach(tries + 1), backoff(tries)); + } + + /** The events file, followed from where the office last read it. */ + private async follow(tries: number) { + if (this.ended || !this.box) return; + try { + const s = await this.box.exec.session({ + argv: ['tail', '-c', `+${this.offset + 1}`, '-F', this.events], + onStdout: (d) => this.read(this.tailDecoder.decode(d, { stream: true }), d.byteLength), + }); + this.tail = s; + await s.wait().catch(() => -1); + if (this.tail === s) this.tail = null; + } catch { + // reconnect below + } + if (!this.ended) setTimeout(() => void this.follow(tries + 1), backoff(tries)); + } + + private read(text: string, bytes: number) { + this.offset += bytes; + const { events, rest } = eventLines(this.pending + text); + this.pending = rest; + for (const e of events) { + try { + this.o.onEvent(e); + } catch (err) { + console.warn('box event failed', err); + } + } + } + + private emit(data: string) { + if (data) for (const cb of this.dataCbs) cb(data); + } + + private async exited() { + if (this.ended || !this.box) return; + const code = await boxRun(this.box, `cat ${shq(this.exitFile)} 2>/dev/null || echo 1`).catch(() => ({ ok: false, out: '1' })); + // The last events (Stop) can still be on their way down the tail. + await new Promise((r) => setTimeout(r, 1500)); + this.finish(Number.parseInt(code.out.trim(), 10) || 0); + } + + private fail(note: string) { + this.o.onNotice?.(`โœ— ${note}`); + this.finish(1); + } + + private finish(code: number) { + if (this.ended) return; + this.ended = true; + this.term?.close(); + this.tail?.close(); + for (const cb of this.exitCbs) cb(code); + } + + private stopBox() { + if (!this.box) return; + void boxRun(this.box, `${this.tmux('kill-server').map(shq).join(' ')} 2>/dev/null; true`).catch(() => undefined); + } + + write(data: string) { + if (this.term) this.term.write(data); + else this.queued.push(data); + } + + resize(cols: number, rows: number) { + this.cols = cols; + this.rows = rows; + this.term?.resize(rows, cols); + } + + kill() { + this.killed = true; + this.stopBox(); + this.finish(130); + } + + setMeta() { + // a box's CLI isn't held by the terminal keeper: the office can't pick it up after a restart (yet) + } + + onData(cb: (data: string) => void) { + this.dataCbs.push(cb); + } + + onExit(cb: (code: number) => void) { + this.exitCbs.push(cb); + } +} diff --git a/server/boxes.test.ts b/server/boxes.test.ts new file mode 100644 index 0000000..3a34cfb --- /dev/null +++ b/server/boxes.test.ts @@ -0,0 +1,111 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { BOX_EVENT_SOURCE, boxApiKey, eventLines, gitEnv, launchScript, parseRegValue, prepareDeskScript, shq, signedIn, signInCheck } from './boxes.ts'; + +/** Runs the box's event program here, as the CLI in a box would. */ +function runEvent(dir: string, args: string[], stdin = '') { + const script = path.join(dir, 'event.cjs'); + fs.writeFileSync(script, BOX_EVENT_SOURCE); + return execFileSync(process.execPath, [script, ...args], { input: stdin, encoding: 'utf8', env: { ...process.env, CD_EVENTS_FILE: path.join(dir, 'events.jsonl') } }); +} + +describe('the event program in a box', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'box events ')); + const file = path.join(dir, 'events.jsonl'); + + it('allows every tool call itself and writes the hook down for the office', () => { + const out = runEvent(dir, ['hook'], JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash', session_id: 's1' })); + expect(JSON.parse(out).hookSpecificOutput.permissionDecision).toBe('allow'); + const { events } = eventLines(fs.readFileSync(file, 'utf8')); + expect(events.at(-1)).toMatchObject({ hook_event_name: 'PreToolUse', tool_name: 'Bash', session_id: 's1' }); + }); + + it('answers a permission request with allow, and other hooks with nothing', () => { + expect(JSON.parse(runEvent(dir, ['hook'], JSON.stringify({ hook_event_name: 'PermissionRequest' }))).hookSpecificOutput.decision.behavior).toBe('allow'); + expect(runEvent(dir, ['hook'], JSON.stringify({ hook_event_name: 'Stop', last_assistant_message: 'done' }))).toBe('{}'); + }); + + it("prints the office's status line and passes the status on", () => { + const out = runEvent(dir, ['status', '๐Ÿฐ Code Dungeon ยท Ada'], JSON.stringify({ session_id: 's1', cost: { total_cost_usd: 0.42 }, rate_limits: { five_hour: { used_percentage: 12 } } })); + expect(out).toBe('๐Ÿฐ Code Dungeon ยท Ada'); + expect(eventLines(fs.readFileSync(file, 'utf8')).events.at(-1)).toMatchObject({ hook_event_name: 'StatusLine', cost: { total_cost_usd: 0.42 } }); + }); + + it("turns Codex's turn-complete notice into TurnComplete", () => { + runEvent(dir, ['notify', JSON.stringify({ type: 'agent-turn-complete', 'thread-id': 't9', 'last-assistant-message': 'PR opened', 'input-messages': ['Please resolve #4'] })]); + expect(eventLines(fs.readFileSync(file, 'utf8')).events.at(-1)).toMatchObject({ hook_event_name: 'TurnComplete', session_id: 't9', last_assistant_message: 'PR opened', input: 'Please resolve #4' }); + }); +}); + +describe('eventLines', () => { + it('keeps a line still being written for later and skips broken ones', () => { + const { events, rest } = eventLines('{"a":1}\nnot json\n{"b":2}\n{"c":'); + expect(events).toEqual([{ a: 1 }, { b: 2 }]); + expect(rest).toBe('{"c":'); + }); +}); + +describe('quoting for a box', () => { + it('keeps any text as one bash argument', () => { + expect(shq("it's $HOME `x`")).toBe(`'it'"'"'s $HOME \`x\`'`); + }); + + it("writes a launch script that runs the CLI with its arguments as given and saves its exit code", () => { + const s = launchScript({ cwd: '/home/boxuser/desks/a__b', file: 'claude', args: ['--', "fix #3: don't break $PATH"], env: { TERM: 'xterm-256color', GH_TOKEN: 'secret' }, exitFile: '/x/exit' }); + expect(s).toContain(`export GH_TOKEN='secret'`); + expect(s).toContain(`cd '/home/boxuser/desks/a__b' || exit 1`); + expect(s).toContain(`'claude' '--' 'fix #3: don'"'"'t break $PATH'`); + expect(s).toContain(`echo $? > '/x/exit'`); + }); +}); + +describe('desks in a box', () => { + it('clones once, then resets to the default branch and makes the branch', () => { + const s = prepareDeskScript('acme/app', { defaultBranch: 'main' }, 'swarm/issue-3-ada'); + expect(s).toContain(`git clone --quiet 'https://github.com/acme/app.git' '/home/boxuser/desks/acme__app'`); + expect(s).toContain(`'+refs/heads/main:refs/remotes/origin/main'`); + expect(s).toContain(`git checkout --quiet -B 'swarm/issue-3-ada' 'origin/main'`); + expect(s).not.toContain('GH_TOKEN='); + }); + + it("checks out a pull request's head for QA and fixes", () => { + const s = prepareDeskScript('acme/app', { defaultBranch: 'main', pr: 12 }, 'qa/pr-12-bo'); + expect(s).toContain(`'+refs/pull/12/head:refs/remotes/origin/pr-12'`); + expect(s).toContain(`-B 'qa/pr-12-bo' 'origin/pr-12'`); + }); + + it('passes the GitHub token only through the command environment', () => { + expect(gitEnv('ghp_x')).toEqual({ GH_TOKEN: 'ghp_x', GIT_TERMINAL_PROMPT: '0' }); + expect(gitEnv(null)).toEqual({ GIT_TERMINAL_PROMPT: '0' }); + }); +}); + +describe('signing in', () => { + it("reads Claude Code's auth status", () => { + expect(signInCheck('claude')).toContain('claude auth status --json'); + expect(signedIn('claude', '{\n "loggedIn": true,\n "authMethod": "claude.ai"\n}')).toBe(true); + expect(signedIn('claude', '{"loggedIn": false}')).toBe(false); + expect(signedIn('claude', 'command not found')).toBe(false); + }); + + it("reads Codex's login status", () => { + expect(signedIn('codex', 'signed-in\n')).toBe(true); + expect(signedIn('codex', 'signed-out\n')).toBe(false); + }); +}); + +describe('the Box API key', () => { + it('comes from the environment first', () => { + expect(boxApiKey({ UPSTASH_BOX_API_KEY: 'box_1' }, 'linux')).toBe('box_1'); + expect(boxApiKey({}, 'linux')).toBeNull(); + }); + + it("is read from Windows' user environment output", () => { + const out = '\r\nHKEY_CURRENT_USER\\Environment\r\n UPSTASH_BOX_API_KEY REG_SZ box_abc123\r\n\r\n'; + expect(parseRegValue(out, 'UPSTASH_BOX_API_KEY')).toBe('box_abc123'); + expect(parseRegValue(out, 'OTHER')).toBeNull(); + }); +}); diff --git a/server/boxes.ts b/server/boxes.ts new file mode 100644 index 0000000..00d4719 --- /dev/null +++ b/server/boxes.ts @@ -0,0 +1,252 @@ +// Recruits who work in an Upstash Box: one Linux machine in the cloud per recruit, made from the dungeon's base +// snapshot, driven through the Box API (@upstash/box). Their CLIs sign in to the Overlord's own subscriptions inside +// the box (never a copied login), and the office follows them through a file of events, since a box can't reach this +// computer. Everything here talks to Upstash; boxRunner.ts runs a CLI in a box, and the swarm decides who goes where. +import { execFileSync } from 'node:child_process'; +import { Box } from '@upstash/box'; + +/** The box's user and the dungeon's folder in it. */ +export const BOX_HOME = '/home/boxuser'; +export const BOX_DIR = `${BOX_HOME}/.code-dungeon`; +export const BOX_BIN = `${BOX_DIR}/bin`; +export const BOX_SESSIONS = `${BOX_DIR}/sessions`; +export const BOX_SCRATCH = `${BOX_DIR}/tmp`; +export const BOX_DESKS = `${BOX_HOME}/desks`; +/** The free plan's limit; the office never runs more boxes than the Overlord allows. */ +export const FREE_PLAN_BOXES = 10; +/** Labels: every box the office makes carries them, so it can tell its own boxes from others on the account. */ +export const BOX_LABEL = 'code-dungeon'; + +/** + * The Box API key, from UPSTASH_BOX_API_KEY. On Windows a key the Overlord saved in their user environment after the + * office started isn't in its environment yet, so the registry is read too. Never logged or stored. + */ +export function boxApiKey(env: NodeJS.ProcessEnv = process.env, platform = process.platform): string | null { + if (env.UPSTASH_BOX_API_KEY) return env.UPSTASH_BOX_API_KEY; + if (platform !== 'win32') return null; + return readUserEnv('UPSTASH_BOX_API_KEY'); +} + +/** + * The GitHub token the boxes push with (a fine-grained token for the dungeon's repos), from + * CODEDUNGEON_BOX_GITHUB_TOKEN; never written to a box's disk (see gitEnv). + */ +export function boxGithubToken(env: NodeJS.ProcessEnv = process.env, platform = process.platform): string | null { + if (env.CODEDUNGEON_BOX_GITHUB_TOKEN) return env.CODEDUNGEON_BOX_GITHUB_TOKEN; + if (platform !== 'win32') return null; + return readUserEnv('CODEDUNGEON_BOX_GITHUB_TOKEN'); +} + +function readUserEnv(name: string): string | null { + try { + const out = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', name], { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }); + return parseRegValue(out, name); + } catch { + return null; + } +} + +/** The value of `name` in `reg query` output; null when it isn't there. */ +export function parseRegValue(out: string, name: string): string | null { + const m = new RegExp(`^\\s*${name}\\s+REG_\\w+\\s+(.+?)\\s*$`, 'm').exec(out); + return m ? m[1] : null; +} + +/** Quoted for bash: one argument, whatever it holds. */ +export const shq = (s: string) => `'${s.replaceAll("'", `'"'"'`)}'`; + +/** + * The environment a git or gh command in a box runs with: the token only there, never on the box's disk. git asks the + * credential helper, which reads $GH_TOKEN from that environment; gh reads it directly. + */ +export function gitEnv(token: string | null): Record { + return token ? { GH_TOKEN: token, GIT_TERMINAL_PROMPT: '0' } : { GIT_TERMINAL_PROMPT: '0' }; +} + +/** git's credential helper for github.com in a box: answers with $GH_TOKEN, stores nothing. */ +export const GIT_CREDENTIAL_HELPER = '!f(){ test "$1" = get || exit 0; test -n "$GH_TOKEN" || exit 0; echo username=x-access-token; echo "password=$GH_TOKEN"; }; f'; + +/** A desk in a box: the repo's own clone per recruit (no worktrees: one recruit per box). */ +export const boxDeskDir = (fullName: string) => `${BOX_DESKS}/${fullName.replace('/', '__')}`; + +/** The shell commands that make a box's desk ready on `branch`: cloned once, then fetched and reset like a local desk. */ +export function prepareDeskScript(fullName: string, base: { defaultBranch: string; pr?: number }, branch: string): string { + const dir = boxDeskDir(fullName); + const url = `https://github.com/${fullName}.git`; + return [ + 'set -e', + `git config --global credential.https://github.com.helper ${shq(GIT_CREDENTIAL_HELPER)}`, + 'git config --global --replace-all credential.helper ""', // the image's own "store" helper would write the token to disk + `mkdir -p ${shq(BOX_DESKS)} ${shq(BOX_SCRATCH)}`, + `if [ ! -d ${shq(dir + '/.git')} ]; then git clone --quiet ${shq(url)} ${shq(dir)}; fi`, + `cd ${shq(dir)}`, + `git fetch --quiet origin ${shq(base.pr ? `+refs/pull/${base.pr}/head:refs/remotes/origin/pr-${base.pr}` : `+refs/heads/${base.defaultBranch}:refs/remotes/origin/${base.defaultBranch}`)}`, + 'git reset --quiet --hard', + 'git clean -fdq -e node_modules', + `git checkout --quiet -B ${shq(branch)} ${shq(base.pr ? `origin/pr-${base.pr}` : `origin/${base.defaultBranch}`)}`, + ].join('\n'); +} + +/** Stops what a recruit left running on their port in the box (dev servers), never their CLI. */ +export const releaseDeskScript = (port: number) => `lsof -ti tcp:${port} 2>/dev/null | xargs -r kill 2>/dev/null; true`; + +/** Whether a box's CLI is signed in: Claude Code's `auth status --json`, or Codex's `login status` exit code. */ +export const signInCheck = (cli: 'claude' | 'codex' | 'opencode') => + cli === 'claude' ? 'claude auth status --json 2>/dev/null' : cli === 'codex' ? 'codex login status >/dev/null 2>&1 && echo signed-in || echo signed-out' : 'echo signed-in'; + +/** Reads what signInCheck printed. */ +export function signedIn(cli: 'claude' | 'codex' | 'opencode', out: string): boolean { + if (cli === 'claude') { + try { + return JSON.parse(out.slice(out.indexOf('{'))).loggedIn === true; + } catch { + return false; + } + } + return /signed-in/.test(out) && !/signed-out/.test(out); +} + +/** The command a recruit's terminal runs to sign the box's CLI in to the Overlord's subscription (typed by them). */ +export const signInCommand = (cli: 'claude' | 'codex' | 'opencode') => (cli === 'claude' ? 'claude auth login' : cli === 'codex' ? 'codex login --device-auth' : 'opencode auth login'); + +/** + * The program every CLI hook, status line and notify call runs in a box. It writes one line of JSON per event to the + * session's events file, which the office follows, and answers the CLI itself: a box can't reach the office, so its + * answers can't wait for one. Every tool call is allowed (as the office does locally); nothing is ever sent anywhere. + * The events file is in CD_EVENTS_FILE, which the CLI passes on to everything it runs. + * hook Claude Code hook: JSON on stdin + * status Claude Code status line: JSON on stdin, prints + * notify Codex's notify program (Codex adds the JSON) + * codex Codex hook: JSON on stdin + */ +export const BOX_EVENT_SOURCE = String.raw`// Code Dungeon (in an Upstash Box): passes the CLI's events to the office through a file. +const fs = require('fs'); +const [mode, extra] = process.argv.slice(2); +const file = process.env.CD_EVENTS_FILE; +const put = (body) => { try { fs.appendFileSync(file, JSON.stringify({ at: Date.now(), ...body }) + '\n'); } catch {} }; +const ALLOW = { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Approved by Code Dungeon' } }; +if (mode === 'notify') { + let event = {}; + try { event = JSON.parse(extra || '{}'); } catch {} + if (event.type === 'agent-turn-complete') { + const input = String((event['input-messages'] || [])[0] || '').slice(0, 300); + put({ hook_event_name: 'TurnComplete', session_id: event['thread-id'] || null, last_assistant_message: event['last-assistant-message'] || '', input }); + } +} else { + const chunks = []; + process.stdin.on('data', (c) => chunks.push(c)); + process.stdin.on('end', () => { + let body = {}; + try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch {} + if (mode === 'status') { + put({ hook_event_name: 'StatusLine', session_id: body.session_id, cost: body.cost, rate_limits: body.rate_limits, context_window: body.context_window }); + process.stdout.write(String(extra || 'Code Dungeon')); + return; + } + put(body); + const event = String(body.hook_event_name || ''); + if (mode === 'hook' && event === 'PreToolUse') process.stdout.write(JSON.stringify(ALLOW)); + else if (mode === 'hook' && event === 'PermissionRequest') process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow' } } })); + else process.stdout.write('{}'); + }); +} +`; + +/** + * Splits text read from an events file into whole lines and what's left over (a line still being written), and + * parses each line; a broken line is skipped. + */ +export function eventLines(buffer: string): { events: Record[]; rest: string } { + const parts = buffer.split('\n'); + const rest = parts.pop() ?? ''; + const events: Record[] = []; + for (const line of parts) { + if (!line.trim()) continue; + try { + const v = JSON.parse(line); + if (v && typeof v === 'object' && !Array.isArray(v)) events.push(v as Record); + } catch { + // a broken line: skipped + } + } + return { events, rest }; +} + +/** + * The script a recruit's tmux session runs: their CLI with its environment, its exit code saved for the office. + * Arguments are quoted one by one, so nothing in a prompt is read by the shell. + */ +export function launchScript(o: { cwd: string; file: string; args: string[]; env: Record; exitFile: string }): string { + return [ + '#!/bin/bash', + ...Object.entries(o.env).map(([k, v]) => `export ${k}=${shq(v)}`), + `cd ${shq(o.cwd)} || exit 1`, + `${[o.file, ...o.args].map(shq).join(' ')}`, + `echo $? > ${shq(o.exitFile)}`, + ].join('\n'); +} + +// ---------- the Box API ---------- + +let client: { apiKey: string } | null = null; + +/** The connection to the Box API; null without a key. */ +export function boxConnection(): { apiKey: string } | null { + if (client) return client; + const apiKey = boxApiKey(); + if (!apiKey) return null; + client = { apiKey }; + return client; +} + +/** Forget the key (it was changed, or found wrong). */ +export function resetBoxConnection() { + client = null; +} + +const BOX_TIMEOUT_MS = 30 * 60_000; + +/** A box by id. */ +export async function getBox(id: string): Promise { + const c = boxConnection(); + if (!c) throw new Error('No Upstash Box API key (UPSTASH_BOX_API_KEY)'); + return Box.get(id, { apiKey: c.apiKey, timeout: BOX_TIMEOUT_MS } as never); +} + +/** A new box for a recruit, from the dungeon's base snapshot. */ +export async function createRecruitBox(snapshotId: string, name: string, labels: string[]): Promise { + const c = boxConnection(); + if (!c) throw new Error('No Upstash Box API key (UPSTASH_BOX_API_KEY)'); + return Box.fromSnapshot(snapshotId, { apiKey: c.apiKey, name, labels: [BOX_LABEL, ...labels].slice(0, 5), timeout: BOX_TIMEOUT_MS } as never); +} + +/** The office's boxes on the account, with what each has used. */ +export async function listOfficeBoxes(): Promise<{ id: string; name: string; status: string; labels: string[]; cpuSeconds: number }[]> { + const c = boxConnection(); + if (!c) return []; + const all = await Box.list({ apiKey: c.apiKey } as never); + return all + .filter((b) => (b.labels ?? []).includes(BOX_LABEL)) + .map((b) => ({ id: b.id, name: b.name ?? '', status: String(b.status), labels: b.labels ?? [], cpuSeconds: Math.round((b.total_cpu_ns ?? 0) / 1e9) })); +} + +/** Runs a shell command in a box and returns what it printed (stdout and stderr together) and whether it succeeded. */ +export async function boxRun(box: Box, command: string, env: Record = {}): Promise<{ ok: boolean; out: string }> { + const prefix = Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' '); + const run = await box.exec.command(`${prefix ? `env ${prefix} ` : ''}bash -c ${shq(command)} 2>&1`); + return { ok: run.status === 'completed', out: String(run.result ?? '') }; +} + +/** The base snapshot's setup: everything a recruit's box needs, no logins. Kept here so a new base can be built. */ +export const BASE_SETUP = [ + 'set -e', + 'sudo apt-get update -qq >/dev/null', + 'sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq tmux lsof >/dev/null', + `mkdir -p ${BOX_DIR}/pw ${BOX_BIN} ${BOX_SESSIONS} ${BOX_SCRATCH} ${BOX_DESKS}`, + `cd ${BOX_DIR}/pw && npm init -y >/dev/null && npm install -s --no-audit --no-fund playwright@latest >/dev/null`, + 'sudo ./node_modules/.bin/playwright install-deps chromium >/dev/null', + './node_modules/.bin/playwright install chromium >/dev/null', + // Claude Code's first-run screens answered, so a recruit's CLI starts at its prompt + `node -e 'const f=require("os").homedir()+"/.claude.json";const fs=require("fs");const c=fs.existsSync(f)?JSON.parse(fs.readFileSync(f)):{};Object.assign(c,{hasCompletedOnboarding:true,theme:"dark"});fs.writeFileSync(f,JSON.stringify(c,null,2))'`, + `date -u +%FT%TZ > ${BOX_DIR}/base-ready`, +].join(' && '); From 868437d42b73d10765f0ac66cffd6608a94e5737 Mon Sep 17 00:00:00 2001 From: Emile Date: Thu, 29 Oct 2026 12:10:24 -0400 Subject: [PATCH 3/8] Sessions can run in a recruit's Upstash Box: files and hooks in the box, its CLI through BoxPty; local sessions unchanged Co-Authored-By: Claude Opus 5.5 --- server/agentRunner.ts | 5 ++ server/cliRunner.ts | 161 +++++++++++++++++++++++++++++------------- server/clis.ts | 11 ++- 3 files changed, 125 insertions(+), 52 deletions(-) diff --git a/server/agentRunner.ts b/server/agentRunner.ts index 7ef347d..e662699 100644 --- a/server/agentRunner.ts +++ b/server/agentRunner.ts @@ -44,6 +44,11 @@ export interface SessionOptions { agentId?: string; /** Follow the session of the CLI still running in the terminal after the office restarted; start nothing. */ reattach?: boolean; + /** + * The recruit works in their Upstash Box (boxRunner.ts): cwd is a path in the box, and env is added to the CLI's + * environment there (the GitHub token for git and gh). Terminal runtime only. + */ + box?: { boxId: string; env: Record }; } export interface LogEntry { diff --git a/server/cliRunner.ts b/server/cliRunner.ts index abd0aab..51f1a41 100644 --- a/server/cliRunner.ts +++ b/server/cliRunner.ts @@ -28,6 +28,8 @@ import { keepWaitingForBackground, workingAloneAfter } from './handOff.ts'; import { PHONE_BROWSER_CONFIG } from './phoneReady.ts'; import { permissionAnswer, PromptWait, scratchTemp } from './permissionGate.ts'; import { FileMeter, promptBaseline, statusUsage, UsageTally } from './usageMeter.ts'; +import { BOX_BIN, BOX_EVENT_SOURCE, BOX_SCRATCH, BOX_SESSIONS, getBox, shq } from './boxes.ts'; +import { BoxPty } from './boxRunner.ts'; import type { AgentCli } from '../shared/types.ts'; // One agent session as the real CLI in a pseudo-terminal: the same contract as the Agent SDK runner (log lines, the @@ -122,6 +124,20 @@ function writeHelpers() { /** A command line for Claude Code's shell (Git Bash on Windows): forward slashes, quoted. */ const shellArg = (p: string) => `"${p.replaceAll('\\', '/')}"`; +/** The box's event program and the browser's files, put in the box before a session there (they're small). */ +async function writeBoxHelpers(boxId: string) { + const box = await getBox(boxId); + await box.files.write({ path: `${BOX_BIN}/event.cjs`, content: BOX_EVENT_SOURCE }); + await box.files.write({ path: `${BOX_BIN}/browser-init.js`, content: fs.readFileSync(path.join(import.meta.dirname, 'browser-init.js'), 'utf8') }); + await box.files.write({ path: `${BOX_BIN}/phone-browser.json`, content: fs.readFileSync(PHONE_BROWSER_CONFIG, 'utf8') }); + return box; +} + +/** The browser for a session in a box: the same server, with the box's paths. */ +function boxPlaywrightServer(outputDir: string, phone = false) { + return { command: 'npx', args: ['-y', '@playwright/mcp@latest', '--headless', '--isolated', ...(phone ? ['--config', `${BOX_BIN}/phone-browser.json`] : []), '--output-dir', outputDir, '--init-script', `${BOX_BIN}/browser-init.js`] }; +} + // ---------- plumbing ---------- const HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure', 'Stop', 'StopFailure', 'Notification']; @@ -195,6 +211,8 @@ interface LiveCli { typedBase?: ReturnType; /** Working on its own while it waits (a turn the office didn't start): its developer isn't free until it stops. */ alone?: boolean; + /** It runs in the recruit's Upstash Box: its files and threads are there, not on this computer. */ + box?: boolean; } const lives = new Map(); /** How to trust Codex's hooks was said once this run (Codex asks at the start of every session until they are). */ @@ -226,6 +244,7 @@ function endLive(live: LiveCli): Promise { live.proc?.kill(); live.proc = null; live.term.note(`โ”€โ”€ ${cliLabel(live.cli)} session ended โ”€โ”€`); + if (live.box) return Promise.resolve(); // its files and its Codex thread are in the box fs.rm(live.dir, { recursive: true, force: true, maxRetries: 5 }, () => undefined); return live.cli === 'codex' && live.resumeId ? codexThread('archive', live.resumeId, gone) : Promise.resolve(); } @@ -393,7 +412,8 @@ export function startCliSession(opts: SessionOptions, callbacks: SessionCallback const token = crypto.randomUUID(); const sessionId = opts.resumeSessionId ?? crypto.randomUUID(); - const dir = path.join(SESSIONS_DIR, token); + const inBox = !!opts.box; + const dir = inBox ? `${BOX_SESSIONS}/${token}` : path.join(SESSIONS_DIR, token); const started = Date.now(); const statusLine = `๐Ÿฐ Code Dungeon${opts.label ? ` ยท ${opts.label}` : ''}`; const toolNames = new Map(); @@ -426,9 +446,11 @@ export function startCliSession(opts: SessionOptions, callbacks: SessionCallback let transcript = ''; let contextPct: number | null = null; let measuredAt = 0; + // In a box the session file is there, not here: the status line still reports the cost. + const metered = cli === 'claude' && !inBox; const measure = (force = false) => { const l = live; - if (cli !== 'claude' || !l || !transcript) return; + if (!metered || !l || !transcript) return; if (!force && Date.now() - measuredAt < USAGE_EVERY_MS) return; measuredAt = Date.now(); const totals = l.meter.read(transcript); @@ -437,7 +459,7 @@ export function startCliSession(opts: SessionOptions, callbacks: SessionCallback costUsd = job.costUsd; cb.usage?.({ tokens: job.tokens, costUsd, contextPct }); }; - if (cli !== 'claude') cb.usage?.({ tokens: null, costUsd: 0, contextPct: null }); + if (!metered) cb.usage?.({ tokens: null, costUsd: 0, contextPct: null }); // A permission prompt nobody answers (permissionGate.ts): the Overlord is told once it has waited ten minutes. const promptWait = new PromptWait((text) => cb.permissionWait?.(text)); @@ -447,7 +469,7 @@ export function startCliSession(opts: SessionOptions, callbacks: SessionCallback /** Codex and OpenCode don't report tool results: pass on the screenshots their browser saved instead. all: the last look. */ const collectShots = (all = false) => { const l = live; - if (!l || cli === 'claude' || !opts.browserTesting) return; + if (!l || cli === 'claude' || !opts.browserTesting || inBox) return; for (const shot of newScreenshots(path.join(l.dir, 'browser'), l.shots, all ? Infinity : Date.now())) { l.shots.add(shot.name); try { @@ -576,7 +598,8 @@ export function startCliSession(opts: SessionOptions, callbacks: SessionCallback if (cli === 'claude' && !sub && typeof b.transcript_path === 'string' && b.transcript_path) transcript = b.transcript_path; if (!sub && ['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'UserPromptSubmit', 'Stop', 'StopFailure'].includes(event)) clearWait(); if (cli === 'claude' && !sub && b.session_id !== kept) { - const id = resumableSession(b, fs.existsSync); + // in a box the session file is there: once its hooks report it, it's resumable + const id = resumableSession(b, inBox ? () => true : fs.existsSync); if (id) { kept = id; cb.sessionId(id); @@ -604,7 +627,7 @@ export function startCliSession(opts: SessionOptions, callbacks: SessionCallback cb.output?.(event === 'PostToolUseFailure' ? `${String(b.error ?? '')}\n${out.text}` : out.text); // Codex's unnamed screenshots are collected from the browser's folder (its hooks may not be trusted yet). if (cli === 'claude') for (const img of out.images) cb.screenshot(Buffer.from(img.data, 'base64'), img.mime); - const saved = name === 'mcp__playwright__browser_take_screenshot' && !out.images.length ? screenshotFile(opts.cwd, out.text) : null; + const saved = name === 'mcp__playwright__browser_take_screenshot' && !out.images.length && !inBox ? screenshotFile(opts.cwd, out.text) : null; if (saved) cb.screenshot(saved.data, saved.mime); if (name.startsWith('mcp__playwright__')) { const url = out.text.match(/Page URL:\s*(\S+)/); @@ -757,8 +780,8 @@ export function startCliSession(opts: SessionOptions, callbacks: SessionCallback // ---------- a new CLI: files for this session, and the command line ---------- - const cmd = commandFor(cli); - if (!terminalsAvailable || !cmd) { + const cmd = inBox ? { file: cli, args: [] as string[] } : commandFor(cli); // a box has the CLIs on its PATH + if (!inBox && (!terminalsAvailable || !cmd)) { const why = !terminalsAvailable ? 'the terminal module could not be loaded on this machine' : `${label} isn't installed (no "${cli}" on PATH)`; setTimeout(() => finish({ ok: false, text: '', errors: [`Could not start ${label}: ${why}.`] }), 0); return handle; @@ -767,36 +790,49 @@ export function startCliSession(opts: SessionOptions, callbacks: SessionCallback // Through the keeper, a hook waits while the office restarts instead of failing: the CLIs allow it longer. const keeper = keeperHookUrl(); const hookUrl = `${keeper ?? officeUrl}/api/hooks/${token}`; - const browser = opts.browserTesting ? playwrightServer(path.join(dir, 'browser')) : null; - const phoneBrowser = opts.browserTesting && opts.phoneBrowser ? playwrightServer(path.join(dir, 'phone-browser'), true) : null; + // In a box, paths are the box's (Linux), and the CLI's hooks write to its events file (boxRunner.ts). + const join = (...p: string[]) => (inBox ? p.join('/') : path.join(...p)); + const server = inBox ? boxPlaywrightServer : playwrightServer; + const browser = opts.browserTesting ? server(join(dir, 'browser')) : null; + const phoneBrowser = opts.browserTesting && opts.phoneBrowser ? server(join(dir, 'phone-browser'), true) : null; let prompt = opts.prompt; if (opts.outputSchema) { prompt += `\n\nWhen you are done, end your final message with your report as one JSON object in a \`\`\`json block, matching this JSON schema:\n${JSON.stringify(opts.outputSchema)}`; } - let files: { settings: string; mcp: string | null; system: string }; - try { - writeHelpers(); - fs.mkdirSync(dir, { recursive: true }); - const settings = { - hooks: Object.fromEntries(HOOK_EVENTS.map((e) => [e, [{ hooks: [{ type: 'http', url: hookUrl, timeout: keeper ? 120 : 30 }] }]])), - statusLine: { type: 'command', command: `${shellArg(process.execPath)} ${shellArg(path.join(BIN_DIR, 'statusline.cjs'))} ${shellArg(hookUrl)}`, padding: 0 }, - }; - const servers: Record = {}; - if (browser) servers.playwright = browser; - if (phoneBrowser) servers.phone = phoneBrowser; - if (opts.office) servers.office = { type: 'http', url: `${officeUrl}/api/mcp/${token}` }; - files = { settings: path.join(dir, 'settings.json'), mcp: Object.keys(servers).length ? path.join(dir, 'mcp.json') : null, system: path.join(dir, 'instructions.md') }; - fs.writeFileSync(files.settings, JSON.stringify(settings, null, 2)); - if (files.mcp) fs.writeFileSync(files.mcp, JSON.stringify({ mcpServers: servers }, null, 2)); - fs.writeFileSync(files.system, opts.systemAppend); - if (prompt.length > INLINE_PROMPT_MAX) { - const file = path.join(dir, 'task.md'); - fs.writeFileSync(file, prompt); - prompt = `Your task is in ${file}. Read all of it first, then do what it says.`; + const boxEvent = (mode: string, ...args: string[]) => ['node', `${BOX_BIN}/event.cjs`, mode, ...args].map(shq).join(' '); + const settings = inBox + ? { + hooks: Object.fromEntries(HOOK_EVENTS.map((e) => [e, [{ hooks: [{ type: 'command', command: boxEvent('hook'), timeout: 30 }] }]])), + statusLine: { type: 'command', command: boxEvent('status', statusLine), padding: 0 }, + } + : { + hooks: Object.fromEntries(HOOK_EVENTS.map((e) => [e, [{ hooks: [{ type: 'http', url: hookUrl, timeout: keeper ? 120 : 30 }] }]])), + statusLine: { type: 'command', command: `${shellArg(process.execPath)} ${shellArg(path.join(BIN_DIR, 'statusline.cjs'))} ${shellArg(hookUrl)}`, padding: 0 }, + }; + const servers: Record = {}; + if (browser) servers.playwright = browser; + if (phoneBrowser) servers.phone = phoneBrowser; + if (opts.office && !inBox) servers.office = { type: 'http', url: `${officeUrl}/api/mcp/${token}` }; // the CEO works here + const files = { settings: join(dir, 'settings.json'), mcp: Object.keys(servers).length ? join(dir, 'mcp.json') : null, system: join(dir, 'instructions.md') }; + const writes: { path: string; content: string }[] = [ + { path: files.settings, content: JSON.stringify(settings, null, 2) }, + ...(files.mcp ? [{ path: files.mcp, content: JSON.stringify({ mcpServers: servers }, null, 2) }] : []), + { path: files.system, content: opts.systemAppend }, + ]; + if (prompt.length > INLINE_PROMPT_MAX) { + const file = join(dir, 'task.md'); + writes.push({ path: file, content: prompt }); + prompt = `Your task is in ${file}. Read all of it first, then do what it says.`; + } + if (!inBox) { + try { + writeHelpers(); + fs.mkdirSync(dir, { recursive: true }); + for (const w of writes) fs.writeFileSync(w.path, w.content); + } catch (err) { + setTimeout(() => finish({ ok: false, text: '', errors: [`Could not prepare ${label}: ${(err as Error).message}`] }), 0); + return handle; } - } catch (err) { - setTimeout(() => finish({ ok: false, text: '', errors: [`Could not prepare ${label}: ${(err as Error).message}`] }), 0); - return handle; } officePrompts.push(prompt.trim()); // as given: a long one is a pointer to its file launched.push(prompt.trim()); @@ -812,38 +848,63 @@ export function startCliSession(opts: SessionOptions, callbacks: SessionCallback role: opts.role, additionalDirectories: opts.additionalDirectories, files, - notify: { script: path.join(BIN_DIR, 'notify.cjs'), url: hookUrl }, - codexHook: path.join(BIN_DIR, 'codex-hook.cjs'), + notify: inBox ? { script: `${BOX_BIN}/event.cjs`, url: '' } : { script: path.join(BIN_DIR, 'notify.cjs'), url: hookUrl }, + codexHook: inBox ? `${BOX_BIN}/event.cjs` : path.join(BIN_DIR, 'codex-hook.cjs'), plugin: pathToFileURL(path.join(BIN_DIR, 'opencode-plugin.mjs')).href, browser, phoneBrowser, + inBox, }); // Each agent must be a clean instance on the account its CLI is logged into: no API keys (they would switch Claude // Code's billing to the API) and nothing inherited from a Claude Code session the office itself was started from. + // In a box the CLI starts from the box's own environment, plus what the office gives it (its GitHub token). const env: Record = {}; - for (const [k, v] of Object.entries(process.env)) { - if (v === undefined) continue; - if (/^(ANTHROPIC_|CLAUDE)/i.test(k) && k !== 'CLAUDE_CONFIG_DIR') continue; - if (/^(NO_COLOR|FORCE_COLOR|TERM_PROGRAM|TERM_PROGRAM_VERSION|CODEX_THREAD_ID|OPENCODE_CONFIG_CONTENT)$/i.test(k)) continue; - env[k] = v; + if (inBox) { + Object.assign(env, { TERM: 'xterm-256color', COLORTERM: 'truecolor', TMPDIR: BOX_SCRATCH, TMP: BOX_SCRATCH, TEMP: BOX_SCRATCH }, launch.env, opts.box!.env); + } else { + for (const [k, v] of Object.entries(process.env)) { + if (v === undefined) continue; + if (/^(ANTHROPIC_|CLAUDE)/i.test(k) && k !== 'CLAUDE_CONFIG_DIR') continue; + if (/^(NO_COLOR|FORCE_COLOR|TERM_PROGRAM|TERM_PROGRAM_VERSION|CODEX_THREAD_ID|OPENCODE_CONFIG_CONTENT)$/i.test(k)) continue; + env[k] = v; + } + fs.mkdirSync(SCRATCH_DIR, { recursive: true }); + Object.assign(env, { TERM: 'xterm-256color', COLORTERM: 'truecolor' }, scratchTemp(SCRATCH_DIR), launch.env); } - fs.mkdirSync(SCRATCH_DIR, { recursive: true }); - Object.assign(env, { TERM: 'xterm-256color', COLORTERM: 'truecolor' }, scratchTemp(SCRATCH_DIR), launch.env); if (cli === 'claude' && opts.resumeSessionId) cb.sessionId(sessionId); - term.note(`โ”€โ”€ ${label}${opts.label ? ` ยท ${opts.label}` : ''} โ”€โ”€`); + term.note(`โ”€โ”€ ${label}${opts.label ? ` ยท ${opts.label}` : ''}${inBox ? ' ยท in its Upstash Box' : ''} โ”€โ”€`); const resumeId = cli === 'claude' ? sessionId : (opts.resumeSessionId ?? null); - const l: LiveCli = { agentId: opts.agentId ?? '', cli, term, proc: null, token, dir, resumeId, statusLine, shots: new Set(), session: { hook, exited }, meter: new FileMeter(), costSeen: opts.resumeSessionId ? null : 0 }; - const begin = () => { + const l: LiveCli = { agentId: opts.agentId ?? '', cli, term, proc: null, token, dir, resumeId, statusLine, shots: new Set(), session: { hook, exited }, meter: new FileMeter(), costSeen: opts.resumeSessionId ? null : 0, box: inBox }; + /** A box's CLI: its files go into the box first, then it starts there (boxRunner.ts). */ + const startInBox = async (): Promise => { + const box = await writeBoxHelpers(opts.box!.boxId); + await box.exec.command(`mkdir -p ${shq(dir)} ${shq(BOX_SCRATCH)}`); + for (const w of writes) await box.files.write(w); + return new BoxPty({ + boxId: opts.box!.boxId, + dir, + cwd: opts.cwd, + file: cmd!.file, + args: launch.args, + env, + cols: term.cols, + rows: term.rows, + onEvent: (b) => void handleHook(token, b), + onNotice: (text) => log([{ kind: 'system', text }]), + }); + }; + const begin = async () => { if (done) return; // stopped while its thread was being unarchived let p: Pty; try { - p = spawnPty(cmd.file, [...cmd.args, ...launch.args], { cols: term.cols, rows: term.rows, cwd: opts.cwd, env }, metaOf(l)); + p = inBox ? await startInBox() : spawnPty(cmd!.file, [...cmd!.args, ...launch.args], { cols: term.cols, rows: term.rows, cwd: opts.cwd, env }, metaOf(l)); } catch (err) { - setTimeout(() => finish({ ok: false, text: '', errors: [`Could not start ${label}: ${(err as Error).message}`] }), 0); + setTimeout(() => finish({ ok: false, text: '', errors: [`Could not start ${label}${inBox ? ' in its box' : ''}: ${(err as Error).message}`] }), 0); return; } + if (done) return void p.kill(); // stopped while its box was being made ready l.proc = p; live = l; wire(l, opts.office); @@ -882,9 +943,9 @@ export function startCliSession(opts: SessionOptions, callbacks: SessionCallback if (!done && !begun && cli === 'claude') log([{ kind: 'error', text: `โš  ${label} hasn't started on the task yet. Open the terminal to see what it's waiting for.` }]); }, BOOT_MS); }; - // Codex won't resume a thread the office archived (see codexThread): it's unarchived first. - if (cli === 'codex' && opts.resumeSessionId) void codexThread('unarchive', opts.resumeSessionId).then(begin); - else begin(); + // Codex won't resume a thread the office archived (see codexThread): it's unarchived first (not in a box: never archived). + if (cli === 'codex' && opts.resumeSessionId && !inBox) void codexThread('unarchive', opts.resumeSessionId).then(begin); + else void begin(); return handle; } diff --git a/server/clis.ts b/server/clis.ts index e069edb..04f528e 100644 --- a/server/clis.ts +++ b/server/clis.ts @@ -185,6 +185,11 @@ export interface LaunchContext { browser: { command: string; args: string[] } | null; /** QA's touch-only browser for the phone check (Phone-ready checks), as the MCP server named phone. */ phoneBrowser?: { command: string; args: string[] } | null; + /** + * The CLI runs in an Upstash Box (boxRunner.ts): Codex's notify and hooks run the box's event program with the box's + * node (notify.script and codexHook are its path there), and report through the events file, not an address. + */ + inBox?: boolean; } export interface Launch { @@ -236,17 +241,19 @@ export function launchArgs(id: AgentCli, ctx: LaunchContext): Launch { } case 'codex': { const toml = (s: string) => JSON.stringify(s); // a JSON string is a valid TOML basic string + const notify = ctx.inBox ? ['node', ctx.notify.script, 'notify'] : [process.execPath, ctx.notify.script, ctx.notify.url]; + const hookCommand = ctx.inBox ? `node '${ctx.codexHook}' codex` : codexHookCommand(process.execPath, ctx.codexHook); const args = [ '--no-alt-screen', '-c', - `notify=[${[process.execPath, ctx.notify.script, ctx.notify.url].map(toml).join(',')}]`, + `notify=[${notify.map(toml).join(',')}]`, '-c', `developer_instructions=${toml(ctx.systemAppend)}`, ...(ctx.model ? ['-m', ctx.model] : []), ...(ctx.effort ? ['-c', `model_reasoning_effort=${toml(EFFORT_CODEX[ctx.effort])}`] : []), ...(ctx.browser ? ['-c', `mcp_servers.playwright={command=${toml(ctx.browser.command)},args=[${ctx.browser.args.map(toml).join(',')}]}`] : []), ...(ctx.phoneBrowser ? ['-c', `mcp_servers.phone={command=${toml(ctx.phoneBrowser.command)},args=[${ctx.phoneBrowser.args.map(toml).join(',')}]}`] : []), - ...CODEX_HOOK_EVENTS.flatMap((e) => ['-c', `hooks.${e}=[{hooks=[{type="command",command=${toml(codexHookCommand(process.execPath, ctx.codexHook))},timeout=10}]}]`]), + ...CODEX_HOOK_EVENTS.flatMap((e) => ['-c', `hooks.${e}=[{hooks=[{type="command",command=${toml(hookCommand)},timeout=10}]}]`]), // Like the manager's own Codex, but it can't stop to ask: no approvals and no sandbox (its sandbox can't reach // the credential store, so git and gh fail in it). '--dangerously-bypass-approvals-and-sandbox', From bd49304bdc129f469cfe9de7a70be325500573cf Mon Sep 17 00:00:00 2001 From: Emile Date: Thu, 29 Oct 2026 12:17:44 -0400 Subject: [PATCH 4/8] Recruits can work in an Upstash Box: 'Works on' per coder, box made from the base snapshot, desk and sign-in check in the box, sign-in in their terminal, Cloud boxes card in Settings Co-Authored-By: Claude Opus 5.5 --- client/src/api.ts | 7 +- client/src/ui/ManagerConsole.tsx | 100 ++++++++++++++- client/src/world/Lobby.tsx | 2 + server/boxRunner.ts | 1 + server/boxes.ts | 18 +++ server/index.ts | 4 + server/swarm.ts | 210 +++++++++++++++++++++++++++++-- shared/cloudBoxes.test.ts | 61 +++++++++ shared/cloudBoxes.ts | 43 +++++++ shared/types.ts | 27 ++++ 10 files changed, 463 insertions(+), 10 deletions(-) create mode 100644 shared/cloudBoxes.test.ts create mode 100644 shared/cloudBoxes.ts diff --git a/client/src/api.ts b/client/src/api.ts index 88083f7..b6bd32c 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -1,5 +1,5 @@ import { useStore } from './store'; -import type { AgentCli, GhRepoSummary, OfficeUpdateView, PreviewView, ProjectFolderView, RepoView, SwarmSettings } from '../../shared/types'; +import type { AgentCli, AgentMachine, BoxesView, GhRepoSummary, OfficeUpdateView, PreviewView, ProjectFolderView, RepoView, SwarmSettings } from '../../shared/types'; import type { ShipView } from '../../shared/ship'; import type { LedgerView } from '../../shared/ledger'; @@ -89,8 +89,11 @@ export const api = { sendBack: (repoId: string, n: number, note: string, agentId?: string) => call<{ to: string }>('POST', `${r(repoId)}/pulls/${n}/send-back`, { note, agentId }), hireAgent: (repoId: string, opts: { name?: string; model?: string; effort?: string; role?: 'dev' | 'qa'; title?: string; specialty?: string } = {}) => call('POST', `${r(repoId)}/agents`, opts), - updateAgent: (id: string, patch: { name?: string; model?: string; effort?: string; cli?: AgentCli | ''; look?: 'feminine' | 'masculine'; title?: string; specialty?: string; brief?: string }) => + updateAgent: (id: string, patch: { name?: string; model?: string; effort?: string; cli?: AgentCli | ''; look?: 'feminine' | 'masculine'; title?: string; specialty?: string; brief?: string; machine?: AgentMachine }) => call('PATCH', `/api/agents/${id}`, patch), + signInBox: (id: string) => call('POST', `/api/agents/${id}/box/sign-in`), + boxes: () => call('GET', '/api/boxes'), + buildBaseBox: () => call('POST', '/api/boxes/base'), fireAgent: (id: string) => call('DELETE', `/api/agents/${id}`), assign: (id: string, issueNumber: number, note?: string) => call('POST', `/api/agents/${id}/assign`, { issueNumber, note }), stop: (id: string) => call('POST', `/api/agents/${id}/stop`), diff --git a/client/src/ui/ManagerConsole.tsx b/client/src/ui/ManagerConsole.tsx index 1cbcf12..5870c55 100644 --- a/client/src/ui/ManagerConsole.tsx +++ b/client/src/ui/ManagerConsole.tsx @@ -3,7 +3,8 @@ import { api } from '../api'; import { PreviewPill, PreviewSettings } from './AppViewer'; import { agentsOnRepo, pendingRequests, useStore, type ManagerTab } from '../store'; import { CHAMBER_DYE_NAMES, CHAMBER_DYES } from '../../../shared/palette'; -import { CEO_ID, type AgentCli, type CliView, type EffortLevel, type OfficeUpdateView, type RepoView } from '../../../shared/types'; +import { CEO_ID, type AgentCli, type AgentMachine, type BoxesView, type CliView, type EffortLevel, type OfficeUpdateView, type RepoView } from '../../../shared/types'; +import { DEFAULT_CLOUD_BOXES } from '../../../shared/cloudBoxes'; import { effectiveModel } from '../../../shared/models'; import { BRIEF_MAX, MISSION_MAX, briefCount, tooLong } from '../../../shared/briefs'; import { canPostpone, canUpdateNow, drainDeadline, officeUpdateText } from '../officeUpdate'; @@ -558,6 +559,36 @@ function TeamTab() { )} + {terminal && settings.cloudBoxes?.enabled && ( + + + {a.machine === 'box' && ( + <> + {' '} + + {a.box?.signedIn === true ? '๐Ÿ”‘ signed in' : a.box?.signedIn === false ? '๐Ÿ”’ not signed in' : a.box?.id ? 'box ready' : 'no box yet'} + {' '} + {a.box?.signedIn !== true && ( + + )} + + )} + + )} s.settings); + const agents = useStore((s) => s.agents); + const cloud = settings.cloudBoxes ?? DEFAULT_CLOUD_BOXES; + const [view, setView] = useState(null); + const refresh = () => void attempt(() => api.boxes()).then((v) => v && setView(v)); + useEffect(() => { + refresh(); + const t = setInterval(refresh, 60_000); + return () => clearInterval(t); + }, [cloud.enabled, cloud.snapshotId]); + const set = (p: Partial) => void attempt(() => api.updateSettings({ cloudBoxes: { ...cloud, ...p } })); + const nameOf = (id: string | null) => (id ? (Object.values(agents).find((a) => a.id === id)?.name ?? 'someone gone') : 'no one'); + const used = view?.cpuSeconds ?? 0; + return ( +
+

โ˜ Cloud boxes

+

+ Coders can work in their own Upstash Box: a Linux machine in the cloud, signed in to your own Claude or Codex subscription. It takes their work off this computer. Testers stay here for now. +

+ + +
+
{view?.keyPresent ? 'โœ“ Upstash Box API key found' : 'โœ— No Upstash Box API key: set UPSTASH_BOX_API_KEY on this computer, then restart the dungeon.'}
+
{view?.githubTokenPresent ? 'โœ“ GitHub token for the boxes found' : 'โœ— No GitHub token for the boxes: set CODEDUNGEON_BOX_GITHUB_TOKEN (a fine-grained token for your repositories).'}
+
+ {cloud.snapshotId ? 'โœ“ Base box ready' : 'โœ— The base box isn\'t built yet.'}{' '} + +
+
+ {view && view.boxes.length > 0 && ( + + + {view.boxes.map((b) => ( + + + + + + + ))} + +
{b.name || b.id}{b.status}{nameOf(b.agentId)}{Math.round(b.cpuSeconds / 60)} CPU min
+ )} + {view && ( +
+ CPU used so far: {(used / 3600).toFixed(2)} h of the free plan's {FREE_CPU_SECONDS / 3600} h a month ({Math.round((100 * used) / FREE_CPU_SECONDS)}%). +
+ )} +
+ ); +} + function SettingsTab() { const settings = useStore((s) => s.settings); const suggested = useSystemFeed().latest?.sessions.suggested; @@ -815,6 +910,9 @@ function SettingsTab() { Coders work like your own coding agents in a terminal, with your skills, MCP servers and settings, and don't stop to ask. The dungeon's workflow (branches, pull requests, QA reporting back) is in their instructions.

+ +
+ {terminal && }

Your hold