Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ Server (`server/`, Node + Express 5 + ws, run by tsx in development; esbuild bun
`ptyClient.ts` is the office's side (start/connect, spawn, adopt after a restart, local fallback); `ptyProtocol.ts`
their JSON-lines messages. The launcher's `office:shutdown` says `restart: false` when quitting: CLIs stop then.
A developer's CLI stays at its prompt after the task (`keepAlive`): follow-ups and prompts typed there reuse it.
- `boxes.ts`, `boxRunner.ts`: coders in Upstash Boxes (`@upstash/box`). `boxes.ts` is the box layer: the API key and
GitHub token (env or the Windows user environment), the base snapshot (`BASE_SETUP`), desk and sign-in scripts, and
the events program every hook and status line runs in a box. `boxRunner.ts`'s `BoxPty` implements `Pty` for a CLI
in tmux in the box: `tmux attach` over a PTY session (reconnects with backoff), events followed with `tail -F`.
`shared/cloudBoxes.ts` holds the settings and who may move into a box. Testers stay local.
- `clis.ts`: the CLIs (Claude Code from the SDK's bundled binary, Codex, OpenCode): detection, Windows `.cmd` shim
unwrapping, each one's command line, and the helper scripts they call back with.
- `terminal.ts`: `AgentTerminal`, a headless xterm mirror per agent (replay for late viewers, saved to disk), its
Expand Down
7 changes: 5 additions & 2 deletions client/src/api.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<BoxesView>('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`),
Expand Down
100 changes: 99 additions & 1 deletion client/src/ui/ManagerConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -558,6 +559,36 @@ function TeamTab() {
</select>
</td>
)}
{terminal && settings.cloudBoxes?.enabled && (
<td className="nowrap">
<select
value={a.machine}
disabled={a.role === 'qa' && a.machine === 'local'}
title={a.role === 'qa' ? 'Testers work on this computer for now' : 'Where they work: this computer, or their own Upstash Box in the cloud'}
onChange={(e) => void attempt(() => api.updateAgent(a.id, { machine: e.target.value as AgentMachine }))}
>
<option value="local">🖥 This PC</option>
<option value="box">☁ Upstash Box</option>
</select>
{a.machine === 'box' && (
<>
{' '}
<span className="chip" title={a.box?.id ? `Box ${a.box.id}` : 'Their box is made before their next task'}>
{a.box?.signedIn === true ? '🔑 signed in' : a.box?.signedIn === false ? '🔒 not signed in' : a.box?.id ? 'box ready' : 'no box yet'}
</span>{' '}
{a.box?.signedIn !== true && (
<button
className="btn btn-small"
title="Runs the coding agent's own sign-in in their terminal: open the link it shows, sign in, and paste the code back"
onClick={() => void attempt(() => api.signInBox(a.id)).then(() => openOverlay({ kind: 'terminal', agentId: a.id }))}
>
Sign in the box
</button>
)}
</>
)}
</td>
)}
<td>
<input
key={`m-${a.model}-${workerCli(a)}`}
Expand Down Expand Up @@ -701,6 +732,70 @@ function IssuesTab({ initialRepo }: { initialRepo?: string }) {

// ---------- settings ----------

/** The free plan's CPU hours a month, in seconds. */
const FREE_CPU_SECONDS = 5 * 3600;

/** Recruits in Upstash Boxes: on or off, the limit, the base box, and what the boxes have used. */
function CloudBoxesCard() {
const settings = useStore((s) => s.settings);
const agents = useStore((s) => s.agents);
const cloud = settings.cloudBoxes ?? DEFAULT_CLOUD_BOXES;
const [view, setView] = useState<BoxesView | null>(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<typeof cloud>) => 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 (
<div className="card">
<h3>☁ Cloud boxes</h3>
<p className="muted small">
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.
</p>
<label className="field row">
<input type="checkbox" checked={cloud.enabled} onChange={(e) => set({ enabled: e.target.checked })} /> Let coders work in Upstash Boxes
</label>
<label className="field">
<span>Most boxes at once (the free plan allows 10)</span>
<input type="number" min={1} max={100} defaultValue={cloud.maxBoxes} key={cloud.maxBoxes} onBlur={(e) => Number(e.target.value) !== cloud.maxBoxes && set({ maxBoxes: Number(e.target.value) })} />
</label>
<div className="small">
<div>{view?.keyPresent ? '✓ Upstash Box API key found' : '✗ No Upstash Box API key: set UPSTASH_BOX_API_KEY on this computer, then restart the dungeon.'}</div>
<div>{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).'}</div>
<div>
{cloud.snapshotId ? '✓ Base box ready' : '✗ The base box isn\'t built yet.'}{' '}
<button className="btn btn-small" disabled={!view?.keyPresent} onClick={() => void attempt(() => api.buildBaseBox())} title="Makes the snapshot every recruit's box starts from: tmux and a test browser, no logins. A few minutes.">
{cloud.snapshotId ? 'Rebuild the base box' : 'Build the base box'}
</button>
</div>
</div>
{view && view.boxes.length > 0 && (
<table className="team small">
<tbody>
{view.boxes.map((b) => (
<tr key={b.id}>
<td>{b.name || b.id}</td>
<td className="muted">{b.status}</td>
<td>{nameOf(b.agentId)}</td>
<td>{Math.round(b.cpuSeconds / 60)} CPU min</td>
</tr>
))}
</tbody>
</table>
)}
{view && (
<div className="muted small">
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)}%).
</div>
)}
</div>
);
}

function SettingsTab() {
const settings = useStore((s) => s.settings);
const suggested = useSystemFeed().latest?.sessions.suggested;
Expand Down Expand Up @@ -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.
</p>
</div>
<div style={{ display: 'contents' }}>
{terminal && <CloudBoxesCard />}
<h3><Icon name="castle" /> Your hold</h3>
<label className="field">
<span>Your name</span>
Expand Down
16 changes: 16 additions & 0 deletions client/src/ui/userGuideChapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@ export const CHAPTERS: GuideChapter[] = [
p(
'By default every recruit waits for your yes. In the ledger\'s Settings you can switch to auto-approve while a chamber is under the guild cap; anyone beyond the cap still waits for you.',
),
h('Coders in the cloud'),
p(
'A coder can work in their own Upstash Box instead of on this computer: a small Linux machine in the cloud with its own copy of the repo. It takes the builds, tests and test browser off this computer. The coder still uses your own Claude or Codex subscription, and still pushes a branch and opens a pull request, so the notice board, QA and merging work the same. Testers stay on this computer.',
),
p('Setting it up once:'),
list(
'Make an Upstash Box API key in the Upstash console, and a fine-grained GitHub token for your repositories (Contents and Pull requests, read and write).',
'Save them on this computer as the user environment variables `UPSTASH_BOX_API_KEY` and `CODEDUNGEON_BOX_GITHUB_TOKEN`, then restart the dungeon. The dungeon never writes them to a file; the token reaches a box only for its git and GitHub commands.',
"In the ledger's Settings, under **Cloud boxes**, tick **Let coders work in Upstash Boxes** and press **Build the base box**. It takes a few minutes and makes the starting point every coder's box is copied from.",
),
p(
"Then, in the Guild tab, set a coder's **Works on** to **☁ Upstash Box** while they have no task. Press **Sign in the box**: their terminal opens and runs the coding agent's own sign-in. Open the link it shows, sign in, and paste the code back into the terminal. Each box signs in once and stays signed in; nothing is copied from this computer.",
),
p(
"The free Upstash plan allows 10 boxes and 5 hours of processor time a month; the Cloud boxes card shows what each box has used. Upstash pauses a box after an hour without work and wakes it for the next task. If the dungeon restarts while a coder works in a box, the coder carries on there. Setting **Works on** back to **🖥 This PC** keeps the box; letting the coder go deletes it, so anything they hadn't pushed goes with it.",
),
h('Letting someone go'),
p(
'Open their terminal (press [[E]] on their bench) or find them in the Guild tab, and press **Let go**. Their worktree is removed; any branches they pushed stay on GitHub. The last tester in a chamber can\'t be let go.',
Expand Down
2 changes: 2 additions & 0 deletions client/src/world/Lobby.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ function candidateAgent(r: HireRequestView): Agent {
model: r.model,
effort: r.effort,
cli: '',
machine: 'local',
box: null,
terminal: false,
status: 'idle',
issueNumber: null,
Expand Down
1 change: 1 addition & 0 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ By default (the ledger's Settings tab → **How coders run: Real terminals**) ev
- A session is finished when the CLI's turn ends and it doesn't pick up another prompt within 3 seconds. A coder's CLI then stays at its prompt for 30 minutes: type into it and the dungeon takes it on as a follow-up, and the message box (or the dungeon itself, e.g. to fix QA findings) continues in the same CLI. Testers' and the DungeonMaster's CLIs close. After that, a follow-up resumes the session in a new CLI (only the CLI that made a session can resume it).
- The dungeon answers the folder-trust question for its own worktrees (moving to "Yes" first where the CLI selects "No"). Anything else a CLI asks before it starts (sign in, first-run screens) waits for you in its terminal, and the agent's log says so.
- **Agents keep working while the dungeon restarts** (a code change during development, a crash). The CLIs run in the dungeon's *terminal keeper* (`server/ptyHost.ts`), a small process of its own that the dungeon starts and talks to over a local socket (a named pipe on Windows, `<SWARM_HOME>/pty.sock` elsewhere, with a secret in `<SWARM_HOME>/pty.secret`). Their hooks go to the keeper too, which holds each one for up to 110 seconds while the dungeon is away, so none fails and nothing is lost. When the dungeon is back it takes each CLI into its agent's terminal again (what it printed meanwhile, then a redraw) and follows the busy ones' sessions. The DungeonMaster's session is resumed instead, since its tools live in the dungeon's own process. When the dungeon quits for good (Ctrl+C) the CLIs stop with it, and a keeper that no dungeon comes back to for 10 minutes stops them itself. If the keeper can't start, terminals run inside the dungeon and stop when it restarts.
- **Coders in Upstash Boxes** (Guild → *Works on*; Settings → *Cloud boxes*; real terminals only). A coder set to work in a box gets their own [Upstash Box](https://upstash.com/box), a Linux machine made from the dungeon's base snapshot (tmux, the Playwright MCP and its Chromium; no logins), through the Box API (`@upstash/box`, key in `UPSTASH_BOX_API_KEY`). Their desk is a clone in the box; the CLI runs there in tmux, and the dungeon's terminal for them is a PTY session running `tmux attach` that reconnects when the link drops. Hooks, the status line and Codex's notify append to an events file in the box, which the dungeon follows (`tail -F`) and feeds to the same handlers as the HTTP hooks. Each box's CLI signs in once with its own login (Sign in the box runs `claude auth login` or `codex login --device-auth` in their terminal); nothing is copied from this computer. Git and `gh` get a shared fine-grained token (`CODEDUNGEON_BOX_GITHUB_TOKEN`) only in their command environment, through a credential helper, never on the box's disk. Box CLIs aren't held by the terminal keeper: after a restart the dungeon stops what was left running in the box and resumes the session there, so the turn in progress starts again from the transcript, with the desk as it was. Quitting stops them like local CLIs. Testers stay on this computer. Per-session token metering and browser screenshots aren't collected from boxes; cost comes from the status line.
- **Agent SDK** runs Claude Code through the SDK instead, shown as a log of its steps: cubefarm's original runtime.

## Safety model
Expand Down
23 changes: 20 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codedungeon",
"version": "0.21.4",
"version": "0.22.0",
"description": "A medieval dungeon you walk through, where every coder is a real AI coding agent working through your GitHub issues.",
"keywords": [
"claude-code",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading