diff --git a/client/src/api.ts b/client/src/api.ts index b6bd32c..8ef349b 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -94,6 +94,7 @@ export const api = { signInBox: (id: string) => call('POST', `/api/agents/${id}/box/sign-in`), boxes: () => call('GET', '/api/boxes'), buildBaseBox: () => call('POST', '/api/boxes/base'), + deleteBox: (id: string) => call('DELETE', `/api/boxes/${encodeURIComponent(id)}`), 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 84ce7ab..a40fed5 100644 --- a/client/src/ui/ManagerConsole.tsx +++ b/client/src/ui/ManagerConsole.tsx @@ -771,7 +771,7 @@ function CloudBoxesCard() { Let coders work in Upstash Boxes, chosen per coder in the Guild tab (Works on). - {cloud.enabled && } + {cloud.enabled && } ); } @@ -783,12 +783,14 @@ function CloudBoxesDetails({ set, nameOf, used, + onChanged, }: { cloud: CloudBoxesSettings; view: BoxesView | null; set: (p: Partial) => void; nameOf: (id: string | null) => string; used: number; + onChanged: () => void; }) { return ( <> @@ -815,6 +817,26 @@ function CloudBoxesDetails({ {b.status} {nameOf(b.agentId)} {Math.round(b.cpuSeconds / 60)} CPU min + + {!b.agentId && ( + + )} + ))} @@ -822,7 +844,13 @@ function CloudBoxesDetails({ )} {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)}%). + CPU used by the boxes above: {(used / 3600).toFixed(2)} h of the free plan's {FREE_CPU_SECONDS / 3600} h a month ({Math.round((100 * used) / FREE_CPU_SECONDS)}%). Deleted + boxes aren't counted; the Upstash console shows the month's total. +
+ )} + {view && used >= 0.8 * FREE_CPU_SECONDS && ( +
+ ⚠ Close to the free plan's {FREE_CPU_SECONDS / 3600} hours a month. Check your usage in the Upstash console, or move coders back to This PC.
)} diff --git a/client/src/ui/userGuideChapters.ts b/client/src/ui/userGuideChapters.ts index 781e16e..3142d28 100644 --- a/client/src/ui/userGuideChapters.ts +++ b/client/src/ui/userGuideChapters.ts @@ -119,7 +119,7 @@ export const CHAPTERS: GuideChapter[] = [ "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.", + "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 and warns at 80%; deleted boxes aren't in its total, so the Upstash console has the month's full figure. A coding job used about 17 minutes in our tests. A box nobody in the dungeon works in has a **Delete** button. 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( diff --git a/package-lock.json b/package-lock.json index 33decc3..010267e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codedungeon", - "version": "0.22.1", + "version": "0.22.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codedungeon", - "version": "0.22.1", + "version": "0.22.2", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.291", diff --git a/package.json b/package.json index 980744e..9842707 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codedungeon", - "version": "0.22.1", + "version": "0.22.2", "description": "A medieval dungeon you walk through, where every coder is a real AI coding agent working through your GitHub issues.", "keywords": [ "claude-code", diff --git a/server/boxes.test.ts b/server/boxes.test.ts index 3a34cfb..01f367f 100644 --- a/server/boxes.test.ts +++ b/server/boxes.test.ts @@ -3,7 +3,7 @@ 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'; +import { BOX_EVENT_SOURCE, boxApiKey, eventLines, gitEnv, launchScript, parseRegValue, prepareDeskScript, sessionCwd, 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 = '') { @@ -77,6 +77,11 @@ describe('desks in a box', () => { expect(s).toContain(`-B 'qa/pr-12-bo' 'origin/pr-12'`); }); + it("starts a box session in the box's desk, even when given this computer's path", () => { + const local = 'H:\\home\\workspaces\\brainit-consulting__captain-pickle\\desks\\osric-f000'; + expect(sessionCwd(true, 'brainit-consulting/captain-pickle', local)).toBe('/home/boxuser/desks/brainit-consulting__captain-pickle'); + expect(sessionCwd(false, 'brainit-consulting/captain-pickle', local)).toBe(local); + }); 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' }); diff --git a/server/boxes.ts b/server/boxes.ts index ac58f3b..f8a0cd3 100644 --- a/server/boxes.ts +++ b/server/boxes.ts @@ -72,6 +72,12 @@ export const GIT_CREDENTIAL_HELPER = '!f(){ test "$1" = get || exit 0; test -n " /** 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('/', '__')}`; +/** + * Where a session starts. In a box it's always the box's desk, whatever the caller worked out: a resumed session once + * got this computer's desk path, and its CLI exited 1 at the `cd` (Carry on, late messages, recovery after a restart). + */ +export const sessionCwd = (inBox: boolean, fullName: string, given: string) => (inBox ? boxDeskDir(fullName) : given); + /** 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); diff --git a/server/index.ts b/server/index.ts index d80c349..814be53 100644 --- a/server/index.ts +++ b/server/index.ts @@ -197,6 +197,7 @@ app.post('/api/agents/:id/carry-on', route((req) => swarm.carryOn(String(req.par app.post('/api/agents/:id/box/sign-in', route((req) => swarm.signInBox(String(req.params.id)))); app.get('/api/boxes', route(() => swarm.boxesView())); app.post('/api/boxes/base', route(() => swarm.buildBaseBox())); +app.delete('/api/boxes/:id', route((req) => swarm.deleteBox(String(req.params.id)))); app.get('/api/agents/:id/screen', (req, res) => { const shot = swarm.screenshot(String(req.params.id)); if (!shot) return void res.status(404).end(); diff --git a/server/swarm.ts b/server/swarm.ts index 40d7c52..f0dc2bd 100644 --- a/server/swarm.ts +++ b/server/swarm.ts @@ -54,6 +54,7 @@ import { listOfficeBoxes, prepareDeskScript, releaseDeskScript, + sessionCwd, signedIn, signInCheck, signInCommand, @@ -1911,6 +1912,17 @@ export class Swarm { }; } + /** Deletes one of the office's boxes that no recruit here uses (left by a spike, a let-go recruit, another dungeon). */ + async deleteBox(id: string): Promise<{ ok: true }> { + const user = this.state.agents.find((a) => a.boxId === id); + if (user) throw new HttpError(409, `${user.name} works in that box. Let them go, or move them to this computer first.`); + if (this.buildingBase) throw new HttpError(409, 'The base box is being built; try again when it is ready.'); + const box = (await listOfficeBoxes()).find((b) => b.id === id); + if (!box) throw new HttpError(404, "That box isn't one of the dungeon's boxes on this Upstash account."); + await (await getBox(id)).delete(); + return { ok: true }; + } + private buildingBase = false; /** Builds the base snapshot recruits' boxes are made from (tmux, a test browser; no logins). A few minutes. */ @@ -2243,9 +2255,10 @@ export class Swarm { const how = this.sessionRuntime(a, resumeSessionId); this.emitAgent(a); rt.usageBase = { tokens: a.tokens ?? NO_TOKENS, costUsd: a.costUsd, seenTokens: 0, seenCost: 0 }; + const box = this.inBox(a) && a.boxId && how.terminal ? { boxId: a.boxId, env: gitEnv(boxGithubToken()) } : undefined; rt.session = this.backend.startSession( { - cwd, + cwd: sessionCwd(!!box, repo.fullName, cwd), prompt, systemAppend, model: this.modelFor(a, how.cli), @@ -2255,7 +2268,7 @@ export class Swarm { resultReady: this.resultReady(a, repo), // linked repos are reference clones on this computer: not in a box (yet) additionalDirectories: this.inBox(a) ? [] : this.linkedRepos(repo).map((r) => this.backend.mainDir(r.fullName)), - box: this.inBox(a) && a.boxId && how.terminal ? { boxId: a.boxId, env: gitEnv(boxGithubToken()) } : undefined, + box, role: a.task === 'qa' ? 'qa' : a.role, // a developer covering QA works under QA's rules outputSchema, // A developer's CLI stays at its prompt afterwards, for the manager and for follow-ups; QA's closes.