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
1 change: 1 addition & 0 deletions client/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const api = {
signInBox: (id: string) => call('POST', `/api/agents/${id}/box/sign-in`),
boxes: () => call<BoxesView>('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`),
Expand Down
32 changes: 30 additions & 2 deletions client/src/ui/ManagerConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -771,7 +771,7 @@ function CloudBoxesCard() {
<b>Let coders work in Upstash Boxes</b>, chosen per coder in the Guild tab (Works on).
</span>
</label>
{cloud.enabled && <CloudBoxesDetails cloud={cloud} view={view} set={set} nameOf={nameOf} used={used} />}
{cloud.enabled && <CloudBoxesDetails cloud={cloud} view={view} set={set} nameOf={nameOf} used={used} onChanged={refresh} />}
</div>
);
}
Expand All @@ -783,12 +783,14 @@ function CloudBoxesDetails({
set,
nameOf,
used,
onChanged,
}: {
cloud: CloudBoxesSettings;
view: BoxesView | null;
set: (p: Partial<CloudBoxesSettings>) => void;
nameOf: (id: string | null) => string;
used: number;
onChanged: () => void;
}) {
return (
<>
Expand All @@ -815,14 +817,40 @@ function CloudBoxesDetails({
<td className="muted">{b.status}</td>
<td>{nameOf(b.agentId)}</td>
<td>{Math.round(b.cpuSeconds / 60)} CPU min</td>
<td>
{!b.agentId && (
<button
className="btn btn-small btn-ghost"
onClick={() =>
void confirmDialog({
tone: 'danger',
icon: '☁',
title: `Delete the box ${b.name || b.id}?`,
body: 'Nobody in this dungeon works in it. If another dungeon uses the same Upstash account, it may be one of theirs. Anything in it that was not pushed is lost.',
confirm: 'Delete the box',
}).then((ok) => {
if (ok) void attempt(() => api.deleteBox(b.id)).then(onChanged);
})
}
>
Delete
</button>
)}
</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)}%).
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.
</div>
)}
{view && used >= 0.8 * FREE_CPU_SECONDS && (
<div className="term-error small">
⚠ 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.
</div>
)}
</>
Expand Down
2 changes: 1 addition & 1 deletion client/src/ui/userGuideChapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 6 additions & 1 deletion server/boxes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '') {
Expand Down Expand Up @@ -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' });
Expand Down
6 changes: 6 additions & 0 deletions server/boxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 15 additions & 2 deletions server/swarm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
listOfficeBoxes,
prepareDeskScript,
releaseDeskScript,
sessionCwd,
signedIn,
signInCheck,
signInCommand,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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),
Expand All @@ -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.
Expand Down
Loading