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
31 changes: 17 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,23 @@ updates live over the hub's SSE stream and falls back to polling.
It has **no build-time coupling** to this repo — it's a Swift Package Manager / AppKit app (Swift
5.10, no Xcode, no external deps) that drives the two public surfaces:

- **Boxes + actions** via the installed CLI, shelled through a login shell (`/bin/zsh -lc 'agentbox …'`,
because a GUI app has no inherited PATH). The list comes from `agentbox list -g --json` — it keeps
the CLI (not hub REST `/api/v1/boxes`) because only `ListedBox` carries the resolved Web/VNC URLs.
- **Approvals + live events** via the local **Control Hub** at `127.0.0.1:8787`: REST
`/api/v1/approvals` (+ `…/{id}/answer`) and the SSE `/api/events` stream. **Auth split to remember
when changing the hub:** `/api/v1/*` uses `Authorization: Bearer <token>`, but `/api/events` reads
the **`agentbox_hub_token` cookie** (Bearer there 401s). Token is `~/.agentbox/hub/token`. SSE
events are refetch signals only (empty `data: {}`).

**When you change the hub API, the SSE event/auth contract, the `agentbox list --json` shape, or any
CLI command/flag the app uses (git/services/url/screen/start/stop/hub), update the tray app too** —
its own [`CLAUDE.md`](../agentbox-tray/CLAUDE.md) documents exactly which surfaces it depends on. The
app's data/action layer sits behind a `BoxSource` protocol so a future `HubAPIBoxSource` can target
the hosted control-plane unchanged (aligns with [`docs/control-plane-roadmap.md`](./docs/control-plane-roadmap.md)).
- **Boxes + actions** via the local **Control Hub** REST API at `127.0.0.1:8787`: `GET /api/v1/boxes`
(which carries the raw host-side fields — `state`, `projectRoot`, endpoint URLs, session titles —
and the synthetic `creating`/`error` boxes for in-flight/failed creates) plus the lifecycle
(`start`/`pause`/`resume`/`stop`/`destroy`), git, rename, and services routes. Approvals use
`/api/v1/approvals` (+ `…/{id}/answer`), live events the SSE `/api/events` stream. **Auth split to
remember when changing the hub:** `/api/v1/*` uses `Authorization: Bearer <token>`, but
`/api/events` reads the **`agentbox_hub_token` cookie** (Bearer there 401s). Token is
`~/.agentbox/hub/token`. SSE events are refetch signals only (empty `data: {}`).
- **Inherently-local actions** via the installed CLI, shelled through a login shell
(`/bin/zsh -lc 'agentbox …'`, because a GUI app has no inherited PATH): `open --in`/`open --targets`
(terminal attach in iTerm2/cmux/Herdr), and `hub status`/`hub start` to bootstrap the hub itself.

**When you change the hub `/api/v1` API (the Box payload especially), the SSE event/auth contract, or
the CLI commands the app still shells (`open`, `hub`), update the tray app too** — its own
[`CLAUDE.md`](../agentbox-tray/CLAUDE.md) documents exactly which surfaces it depends on. The app's
data/action layer sits behind a `BoxSource` protocol (implemented by `HubAPIBoxSource`) so it can
target the hosted control-plane unchanged (aligns with [`docs/control-plane-roadmap.md`](./docs/control-plane-roadmap.md)).

## Documentation map

Expand Down
2 changes: 1 addition & 1 deletion apps/hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ statuses, no schema).
- **Envelope:** success returns the resource directly; errors are
`{ error: { code, message, details? } }` with a matching HTTP status.
- **Routes:** `GET /boxes`, `GET /boxes/:id`,
`POST /boxes/:id/{pause,resume,stop,destroy}`, `POST /boxes` (create →
`POST /boxes/:id/{start,pause,resume,stop,destroy}`, `POST /boxes` (create →
`202 {jobId}`), `GET|POST /projects`, `GET /approvals`,
`POST /approvals/:id/answer`, `GET /jobs/:id`, `GET /jobs/:id/logs` (SSE),
`GET /health`, `GET /openapi.json` (OpenAPI 3.1), `GET /docs` (Scalar).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// POST /api/v1/boxes/:id/:action — lifecycle: pause | resume | stop | destroy.
// POST /api/v1/boxes/:id/:action — lifecycle: start | pause | resume | stop | destroy.
// Mutations need the in-process host backend; the Postgres/plane path 503s (hosted
// writes are a documented follow-up).
import { backendOrNull } from '../../../lib/backend';
import { fail, failFromAction, ok } from '../../../lib/envelope';
import { isLifecycleAction } from '../../../lib/validate';
import { isLifecycleAction, LIFECYCLE_ACTIONS } from '../../../lib/validate';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
Expand All @@ -14,7 +14,7 @@ export async function POST(
): Promise<Response> {
const { id, action } = await ctx.params;
if (!isLifecycleAction(action)) {
return fail('invalid_request', `unknown action: ${action}`, { allowed: ['pause', 'resume', 'stop', 'destroy'] });
return fail('invalid_request', `unknown action: ${action}`, { allowed: [...LIFECYCLE_ACTIONS] });
}
// In-flight create jobs surface in GET /boxes as synthetic `creating`/`error`
// boxes with a `job:` id — they have no real container yet, so lifecycle would
Expand Down
28 changes: 26 additions & 2 deletions apps/hub/app/(dashboard)/api/v1/lib/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,11 @@ export function buildOpenApi(): Record<string, unknown> {
post: {
tags: ['Boxes'],
summary: 'Run a lifecycle action',
description: 'One of pause | resume | stop | destroy.',
description:
'One of start | pause | resume | stop | destroy. start brings a stopped box back up (resumes if paused, no-op if already running); it does not restart the agent session — that happens on the next attach.',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'action', in: 'path', required: true, schema: { type: 'string', enum: ['pause', 'resume', 'stop', 'destroy'] } },
{ name: 'action', in: 'path', required: true, schema: { type: 'string', enum: ['start', 'pause', 'resume', 'stop', 'destroy'] } },
],
responses: {
'200': { description: 'Done', content: { 'application/json': { schema: { type: 'object', properties: { ok: { const: true } }, required: ['ok'] } } } },
Expand Down Expand Up @@ -448,6 +449,29 @@ export function buildOpenApi(): Record<string, unknown> {
commits: { type: ['number', 'null'] },
filesTouched: { type: ['number', 'null'] },
error: { type: ['string', 'null'] },
displayName: { type: ['string', 'null'], description: 'Cosmetic user-set label (rename); null when unset' },
webUrl: { type: ['string', 'null'], description: 'Host-openable web-service URL; null when absent/unreachable (e.g. paused)' },
vncUrl: { type: ['string', 'null'], description: 'Host-openable VNC desktop URL; null when absent/unreachable' },
state: {
type: 'string',
enum: ['running', 'paused', 'stopped', 'missing'],
description:
'Raw provider runtime state (host topology only). Absent on synthetic creating/error rows — presence distinguishes a real box whose agent errored from a failed create job.',
},
name: { type: 'string' },
provider: { type: 'string', description: "Raw provider id ('docker', 'daytona', …; plugin ids possible)" },
projectRoot: { type: 'string', description: 'Absolute host path of the project. Host topology only — never emitted by the hosted plane' },
projectIndex: { type: 'number' },
vncEnabled: { type: 'boolean' },
gitWorktrees: {
type: 'array',
items: { type: 'object', properties: { kind: { type: 'string' }, branch: { type: 'string' } } },
},
claudeSessionTitle: { type: 'string' },
codexSessionTitle: { type: 'string' },
opencodeSessionTitle: { type: 'string' },
claudeActivity: { type: 'string', description: 'working | idle | waiting | end-plan | question | compacting | error | unknown' },
codexActivity: { type: 'string' },
},
required: ['id', 'projectId', 'status', 'agent'],
},
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/app/(dashboard)/api/v1/lib/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const AGENTS = ['claude', 'codex', 'opencode', 'none'] as const;
// that package out of the Next bundle, like AGENTS above). The backend enforces
// that the chosen provider is actually configured on the host.
const PROVIDERS = ['docker', 'daytona', 'hetzner', 'vercel', 'e2b'] as const;
export const LIFECYCLE_ACTIONS = ['pause', 'resume', 'stop', 'destroy'] as const;
export const LIFECYCLE_ACTIONS = ['start', 'pause', 'resume', 'stop', 'destroy'] as const;
export type LifecycleAction = (typeof LIFECYCLE_ACTIONS)[number];

function isObject(v: unknown): v is Record<string, unknown> {
Expand Down
3 changes: 3 additions & 0 deletions apps/hub/lib/boxes/backend-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ export interface HubBackend {
// authMode is an env-derived concern layered on by source.ts, not the host
// backend — so the backend produces everything else.
getData(): Promise<Omit<HubState, 'authMode'>>;
// Start a fully-stopped box (resumes when paused, no-op when running). Does
// not restore agent tmux sessions — that's a CLI-only concern.
start(id: string): Promise<ActionResult>;
pause(id: string): Promise<ActionResult>;
resume(id: string): Promise<ActionResult>;
stop(id: string): Promise<ActionResult>;
Expand Down
19 changes: 19 additions & 0 deletions apps/hub/lib/boxes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ export interface Box {
// box has no such endpoint or it isn't reachable (e.g. paused/stopped).
webUrl?: string | null;
vncUrl?: string | null;
// ── Raw host-side fields (host/localhost topology only; the hosted/Postgres
// source leaves them all undefined). Native clients (the tray app) key off
// these instead of the UI-normalized fields above. ──
// Raw provider runtime state. ABSENT on synthetic job boxes — its presence is
// how a client tells a real box whose agent errored (state 'running', status
// 'error') from a failed create job (no state). 'destroyed' exists only for
// assignability from BoxState; the list never emits it.
state?: 'running' | 'paused' | 'stopped' | 'missing' | 'destroyed';
name?: string;
// Absolute host path of the project — host topology only, never hosted.
projectRoot?: string;
projectIndex?: number;
vncEnabled?: boolean;
gitWorktrees?: Array<{ kind?: string; branch?: string }>;
claudeSessionTitle?: string;
codexSessionTitle?: string;
opencodeSessionTitle?: string;
claudeActivity?: string;
codexActivity?: string;
}

export interface Project {
Expand Down
29 changes: 29 additions & 0 deletions apps/hub/lib/hub-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ function mapBox(b: ListedBox): Box {
error: status === 'error' ? (b.claudeSessionTitle ?? 'Agent reported an error') : null,
webUrl: eps.find((e) => e.kind === 'web')?.url ?? null,
vncUrl: eps.find((e) => e.kind === 'vnc')?.url ?? null,
// Raw host-side fields for native clients (tray) — see Box for semantics.
state: b.state,
name: b.name,
projectRoot: root,
projectIndex: b.projectIndex,
vncEnabled: b.vncEnabled ?? false,
gitWorktrees: b.gitWorktrees?.map((w) => ({ kind: w.kind, branch: w.branch })),
claudeSessionTitle: b.claudeSessionTitle,
codexSessionTitle: b.codexSessionTitle,
opencodeSessionTitle: b.opencodeSessionTitle,
claudeActivity: b.claudeActivity,
codexActivity: b.codexActivity,
};
}

Expand Down Expand Up @@ -323,6 +335,10 @@ function mapJobToBox(job: QueueJob, status: BoxStatus): Box {
commits: null,
filesTouched: null,
error: status === 'error' ? (job.reason ?? 'create failed') : null,
// Raw host-side fields so native clients can group/label the synthetic row.
// `state` is deliberately absent — that's the synthetic-box marker.
name: job.boxName,
projectRoot: root,
};
}

Expand Down Expand Up @@ -882,6 +898,19 @@ export function createHubBackend(handle: RelayServerHandle): HubBackend {
async providersWithFreshness(): Promise<ProviderOption[]> {
return listProvidersWithFreshness(listProviders(await loadQueue()));
},
start: (id) =>
runLifecycle(id, async (box, provider) => {
// Mirrors the CLI dashboard's resumeBox: docker `start` rejects a paused
// container, so probe first. No-op when already running (idempotent).
// Unlike CLI `agentbox start` this does not restore agent tmux sessions
// (restoreAgentSessions is CLI-only) — the agent restarts on next attach.
const state = await provider.probeState(box);
if (state === 'running') return;
if (state === 'paused') await provider.resume(box);
else await provider.start(box);
// Refresh the box's SSH-config alias now it's back online (IP may have changed).
await hubWriteSshConfig(box, provider);
}),
pause: (id) => runLifecycle(id, (box, provider) => provider.pause(box)),
resume: (id) =>
runLifecycle(id, async (box, provider) => {
Expand Down
15 changes: 12 additions & 3 deletions apps/web/content/docs/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ the interactive reference at `GET /docs`.
| `GET /boxes` | List every box (normalized view). |
| `GET /boxes/{id}` | One box. |
| `POST /boxes` | Create a box — returns `202 { jobId }`. Body: `{ projectId, agent, provider?, name?, prompt?, fromBranch?, setupWizard? }`. `agent: "none"` just creates the box without starting an agent (`prompt` ignored). `provider` defaults to `docker`; a cloud provider must be configured on the host (see `GET /providers`). `fromBranch` bases the box's per-box branch on a chosen ref (branch/tag/SHA) instead of the project's HEAD — validated against the host repo. `setupWizard: true` seeds the agent's first turn to generate an `agentbox.yaml` (for projects that have none); inert for `agent: "none"`. |
| `POST /boxes/{id}/pause` | Pause a running box. |
| `POST /boxes/{id}/start` | Start a stopped box (resumes if paused, no-op if already running). Does not restart the agent session — that happens on the next attach. |
| `POST /boxes/{id}/pause` | Pause a running box. |
| `POST /boxes/{id}/resume` | Resume a paused box. |
| `POST /boxes/{id}/stop` | Stop a box. |
| `POST /boxes/{id}/destroy` | Destroy a box (container + volumes). |
Expand Down Expand Up @@ -119,8 +120,16 @@ the interactive reference at `GET /docs`.
| `GET /jobs/{id}/logs` | Stream the build log (SSE). |

The box view is provider-agnostic: `{ id, projectId, repo, branch, task, agent,
status, createdAt, lastActivity, host, commits, filesTouched, error }`, where
`status` is one of `running | paused | stopped | creating | error`.
status, createdAt, lastActivity, host, commits, filesTouched, error, displayName,
webUrl, vncUrl }`, where `status` is one of
`running | paused | stopped | creating | error`. Boxes still being created (and
failed creates) appear as synthetic `creating`/`error` entries with a `job:` id
until the real box lands. On the local host topology each box also carries raw
host-side fields for native clients (the tray app): `state` (the raw provider
state — absent on synthetic entries, which is how a client tells a failed create
from a running box whose agent errored), `name`, `provider`, `projectRoot`,
`projectIndex`, `vncEnabled`, `gitWorktrees`, per-agent session titles, and
`claudeActivity`/`codexActivity`.

## Creating a box

Expand Down
Loading