Two UIs drive the same agent runtime today, through incompatible control planes:
- The main app (
packages/clientsrc/pages/agents.tsx→AgentSession.tsx) creates sessions viaPOST /harness-sessionsonpackages/server, which provisions one sandbox (one agent-server process) per session and exposes its data plane only through a cookie-authenticated proxy. - The diagnostic web-ui (
agent/packages/web-ui-react) talks directly to a single long-lived agent-server on :4001/:4000 and shows many sessions inside that one process.
The data plane is already shared — both UIs use the same agent-client package and wire protocol against the same agent-server HTTP/SSE API. The incompatibility is purely control-plane: who creates sandboxes, who knows where they live, who is allowed to see them.
Decision (Jordan): build a local control plane as a new package next to agent/packages/agent-server. It absorbs the local sandbox-management code currently living in packages/server/src/agent-sandbox/ (native/docker runtimes, registry, reaping) and exposes a Daytona-like HTTP API. packages/server's local adapter becomes a thin HTTP client of it — structurally the same as the Daytona adapter. The web-ui does NOT connect to packages/server at all. bun run prompt spins up a new agent (sandbox) per invocation.
Outcomes:
- A session started from the main app appears in the diagnostic web-ui (same control plane, same registry), with full drive (prompt/abort/stop) from the web-ui.
- Parallel agents locally: every session is its own sandboxed agent-server process, created via the control plane from any client (main app, web-ui, prompt CLI).
packages/serverstops being prescriptive about local process management; local looks like Daytona from its perspective.
packages/server (gamma) agent/packages/web-ui-react bun run prompt
LocalAgentSandboxAdapter client pool + registry sandbox-per-run
(thin HTTP client, (full drive + diagnostics) │
sibling of Daytona adapter) │ │
└───────────────┬───────────────┴───────────────────────────────┘
▼
agent/packages/agent-control-plane (new bun daemon, 127.0.0.1:4025, /v1/*)
registry + sandbox.json persistence + reconcile + reap + SSE events
│ spawns (native: local-sandbox-child.ts detached; docker: docker run)
▼
child agent-server processes, one per sandbox, ports 4100–4999, /api/* each
- Identity:
id === name === tokenSandboxId(unchanged) — writer lease, liveness, and proxy routing inharness-session/stay keyed on it. - Data plane unchanged: main-app client keeps going through the gamma proxy; web-ui/CLI hit
http://127.0.0.1:<port>/apidirectly (agent-server alreadycors origin:'*',agent/packages/agent-server/src/index.ts:1148). Dev-only, loopback bind. - Daemon port 4025 (outside sandbox range 4100–4999 and 4000/4001 lanes).
A1. Wire types in agent/packages/agent-protocol/src/sandbox.ts (re-export from index.ts). This is the coordination artifact; the daemon imports it, agent-client imports it, packages/server hand-mirrors it.
CONTROL_PLANE_DEFAULT_PORT = 4025
SandboxState = 'starting' | 'started' | 'stopped' | 'destroyed' // matches LOCAL_SANDBOX_STATE_SEQUENCE
SandboxInfo = { id, name, state, sessionId: string | null, port, previewUrl,
labels: Record<string,string>, // origin: 'harness'|'web-ui'|'cli', harnessSessionId?
createdAt, updatedAt, lastActivityAt, autoStopInterval, errorReason? } // lastActivityAt/autoStopInterval mirror Daytona (A7)
CreateSandboxRequest = { name, runtime: 'native'|'docker', snapshot?, envVars, labels,
public?, autoStopInterval?, autoDeleteInterval? } // last three accepted, ignored locally
// No first-class scope: checkout identity is just a label the server stamps
// (Daytona-style). The daemon owns its whole working-dir root; scope fencing
// existed only for server-owned reaping, which no longer exists.
SandboxStreamEvent — snapshot `sandboxes_replaced` + `sandbox.created|started|stopped|deleted`; seq + Last-Event-ID replayA2. New package agent/packages/agent-control-plane/ (@gamma/agent-control-plane; register in agent/package.json workspaces/typecheck/test; root script "control-plane": "bun run packages/agent-control-plane/src/main.ts"; entry in agent/process-compose.yaml so bun run agent brings it up).
src/main.ts # bin entry: env config, startControlPlane(), signals
src/server.ts # Hono + Bun.serve on 127.0.0.1:4025; exports startControlPlane(opts) for tests
src/config.ts # ported defaults from packages/server/src/config/agent-sandbox.config.ts
# (workingDirRoot, port range 4100–4999, sessionVolumesRoot — keep existing
# default spelling `.gamma/harness-session-volmes`, agentRepoRoot from import.meta)
src/sandbox-manager.ts # ported local-agent-sandbox.adapter.ts, de-NestJS'd: registry, atomic
# sandbox.json, create-with-rollback, reconcile, reap, + startup REHYDRATION
src/sandbox.types.ts # ported local-sandbox.types.ts (state machine, layout, env rewrites, TLS flag)
src/ports.ts # ported local-sandbox-ports.ts (verbatim)
src/runtimes/native.ts # ported local-native-sandbox.runtime.ts (detached spawn, pgid, 0600
# runtime-env.json, ps-marker, kill escalation — verbatim behavior)
src/runtimes/docker.ts # ported local-docker-sandbox.runtime.ts
src/events.ts # SSE hub (~100 lines, modeled on agent-server/src/event-hub.ts)
src/redactor.ts # COPY of startup-log-redactor.ts (server keeps its own for Daytona)
A3. HTTP API (all /v1, JSON, loopback; CORS for localhost dev origins so the web-ui can connect directly; errors { error: { code, message } }):
| Route | Notes |
|---|---|
GET /v1/info |
identity/version/pid/repoRoot — probe + port-squat detection |
POST /v1/sandboxes |
201 → SandboxInfo; daemon owns full rollback before returning create_failed/preflight_failed; 409 on name conflict |
GET /v1/sandboxes?label.k=v |
list with exact-match label filters (Daytona-style); reconcile runtime state on read |
GET /v1/sandboxes/:idOrName |
get + reconcile; 404 not_found |
DELETE /v1/sandboxes/:idOrName |
terminal teardown; idempotent 204 on missing |
POST .../stop |
terminal for local v1 (parity with today) |
POST .../refresh-activity |
keep-alive; bumps lastActivityAt, no state change, 204. Mirror of Daytona refreshActivity() (A7) |
POST .../autostop |
{ interval } minutes; mirror of setAutostopInterval; 0 disables |
POST .../start |
409 unsupported_lifecycle |
POST .../resize |
compatibility no-op |
GET .../preview-url?port=4000&ttl= |
{ url: "http://localhost:<hostPort>" }; "signed" is an explicit no-op; only port 4000 valid |
GET .../entrypoint-logs |
{ output, stdout, stderr }, redacted daemon-side |
GET /v1/events?label.k=v |
SSE lifecycle feed, ring buffer + Last-Event-ID, optional label filter |
A4. Env provenance (required, easy to miss):
HOCUSPOCUS_HTTP_PORT/HOCUSPOCUS_URL(docker rewrite,local-docker-sandbox.runtime.ts:204-214) andOTEL_TRACES_SAMPLER(local-native-sandbox.runtime.ts:355-358) currently read server config/env → must travel in the create requestenvVars; daemon reads them only from the request.- Standalone sandboxes (web-ui/CLI origin, no
GAMMA_AGENT_TOKEN/HARNESS_SESSION_ID): the daemon merges its own model-auth env keys into the child env when the request lacks them (same keysgetRuntimeProviderEnvforwards; the daemon runs fromagent/where.envhas them). Required for v1 — without it, web-ui- and prompt-created sandboxes can't call models and goals 2–3 don't function. - Daemon never logs request bodies (secrets transit loopback HTTP in create requests); log id/labels only.
A5. Persistence/reconciliation: children are detached/containers, so they survive daemon restarts. On startup the daemon rehydrates from workingDirRoot/<id>/sandbox.json (flat by sandbox id — no scope subdirs; the daemon owns its whole root), validates runtime handles (pid/pgid+ps-marker native, docker inspect docker), flips dead starting/started → stopped with errorReason. Reconcile-on-read for crashes + child.once('exit') publishes sandbox.stopped. The daemon is the only reaper — no external caller triggers cleanup; accumulation is bounded by A7 auto-stop + explicit deletes. SIGINT/SIGTERM does NOT kill children unless AGENT_CONTROL_PLANE_REAP_ON_EXIT=1 (tests).
A6. Tests (bun): ported sandbox.types/ports/redactor specs; server.spec.ts drives the full HTTP contract in-process with a fake runtime (CRUD, filters, error codes, idempotent delete, SSE replay, reap, rehydration from fixture trees); opt-in real-native smoke reusing AGENT_SANDBOX_RUN_NATIVE_RUNTIME_TESTS=1.
A7. Idle auto-stop + keep-alive — Daytona-identical, deliberately blind. Same model, same blindness: lastActivityAt + autoStopInterval + auto-stop loop, fed ONLY by client push. The daemon does not introspect the workload — no child polling, no running veto, no traffic sniffing, no presence tracking. Rationale: fidelity is the point of a simulator. A workload-aware local control plane masks broken keep-alive wiring that then only fails in staging — exactly the bug we validated (Daytona autoStopInterval: 10, zero refreshActivity callers, mid-generation auto-stops possible today). A blind local daemon surfaces that class of bug on every dev machine.
lastActivityAtper sandbox in the registry, persisted tosandbox.json(survives rehydration). Set on create; bumped ONLY by mutating control-plane ops +refresh-activity. Reads never count:GET /sandboxes/GET /:id/SSE watchers do NOT bump the clock — the web-ui polls the list on an interval, and read-as-activity would make auto-stop dead code. That's the complete keep-alive logic.- Keep-alive
POST /v1/sandboxes/:id/refresh-activity= Daytona'srefreshActivity: bumpslastActivityAt, no state change, 204.ControlPlaneClient.refreshActivity(id)wraps it (C1).autoStopIntervalrides the create request;POST .../autostop {interval}mirrorssetAutostopInterval. - Auto-stop loop: the reconcile interval terminal-stops any
startedsandbox wherenow - lastActivityAt > autoStopInterval(same path asPOST .../stop: frees port, emitssandbox.stoppedwitherrorReason: 'auto_stopped'). Never touchesstarting(grace window). - Drivers own their keep-alive (same responsibility model on both providers): main-app — the proxy activity relay (Phase B:
adapter.refreshActivity(), one code path serving Daytona and local); web-ui — heartbeat while a session's SSE stream is open / on composer send; CLI — keep-alive while streaming, or--rm. A driver that forgets its keep-alive gets its sandbox auto-stopped — on both providers, loudly (auto_stoppedin the web-ui row) — instead of only in staging. - Tab-closed background work auto-stops on both providers identically. The provider-agnostic fix, if it bites: the gamma server polls the child's
GET /api/activitythrough the preview URL and translatesrunning: trueintoadapter.refreshActivity()— lives in the gamma server, covers Daytona and local with the same code. Follow-up, not part of this stack. (For web-ui-origin sandboxes, auto-stop on tab close is the self-cleaning behavior a dev tool wants; durable state survives.) - Config:
AGENT_CONTROL_PLANE_AUTOSTOP_MINUTES(default 15 = Daytona's default;0disables) — this is only the fallback for creates that don't specifyautoStopInterval; harness creates passAGENT_AUTO_STOP_MINUTES=10explicitly on both providers, so main-app sandboxes behave identically everywhere.AGENT_CONTROL_PLANE_REAP_INTERVAL_MS(default 60s).server.spec.ts(fake clock): idle auto-stops,refreshActivityresets timer, reads don't reset it, busy-but-unpushed sandbox stops (Daytona-faithful). - No webhooks (deliberate). Daytona's webhook exists to fence the JWT when a restartable stopped container might come back holding its token; local stop is terminal, so that threat doesn't exist. Routing discovers stopped holders via reconcile-on-read (proxy
getSandboxper request → 503 → resume), and revocation happens at resume-time lease takeover (revokeBySandboxId) — same server-visible outcomes, no daemon→server callback registration/secrets/retries. Follow-up shape if push is ever needed: daemon POSTssandbox.state.updatedto a configured URL, existinghandleDaytonaWebhook-style handler consumes it. - Token side-benefit: an auto-stopped-then-recreated sandbox mints fresh, so idle gaps stop being a token-staleness source; only a sandbox kept continuously alive past the token TTL still needs the deferred refresh (unreachable locally under E1's 30d dev TTL).
- New
src/agent-sandbox/control-plane-api.types.ts— hand-mirrored wire types + error codes (workspaces can't share imports; header-comment both files as mirrors; adapter warns on/v1/info.versionmismatch). - Rewritten
local-agent-sandbox.adapter.ts—implements AgentSandboxAdapteras an HTTP client, structural sibling ofdaytona-agent-sandbox.adapter.ts:getProviderTraits()/isCreateOnlyReadinessReason()stay local (server env), no daemon call.- CRUD/lifecycle/preview/logs → 1:1 route calls, ~5s timeouts so harness liveness checks stay snappy and fail closed (dead daemon = 503 through
resolveRoutableProxyTarget, correct behavior). - Rollback: non-2xx create means daemon leaked nothing; existing service failure path (revoke JWT, best-effort delete) hits idempotent DELETE — safe double-cleanup.
- No lifecycle hooks, no spawning, no reaps — guiding principle: the server treats the control plane exactly like Daytona. It's an external service assumed running; unreachable daemon → local readiness unavailable with a clear message (
cd agent && bun run control-plane, or dev-cli), same failure mode as a missingDAYTONA_API_KEY. Sandboxes survive server restarts/redeploys by design: the lease is durable in Postgres andresumeSessionalready routes back to a live+routable holder (the Daytona path — local just never exercised it); the daemon's rehydrate/reconcile + A7 auto-stop are the cleanup authority. The server stamps a checkout-identity label on create (Daytona-style) so its own sandboxes are identifiable, but never bulk-manages them.
- Daemon startup ownership (not the server's): dev-cli manages the control plane as a local service (
packages/dev-cliwiring, alongside the existing infra it already runs withAGENT_SANDBOX_PROVIDER=local);bun run agent's process-compose runs it for agent-only workflows;bun run control-planeis the manual path. Config:AGENT_CONTROL_PLANE_URL(defaulthttp://127.0.0.1:4025). - New
local-sandbox.errors.tsshim —LocalSandboxNotFoundError,LocalSandboxUnsupportedLifecycleError,LOCAL_SANDBOX_TARGET(still imported byagent-sandbox.service.ts). agent-sandbox.service.ts— stays entirely (JWT mint,buildCreateParams, readiness/preview polling, metrics). Only change:buildCreateParamsadditionally injectsHOCUSPOCUS_*+OTEL_TRACES_SAMPLERintoenvVarsfor the local provider (A4).agent-sandbox.module.ts— dropLocalNativeSandboxRuntime/LocalDockerSandboxRuntimeproviders; keep provider/env validation.config/agent-sandbox.config.ts— add control-plane keys; path/port-range keys migrate to the daemon config.- Delete after cutover:
local-native-sandbox.runtime.ts,local-docker-sandbox.runtime.ts,local-sandbox-ports.ts, bulk oflocal-sandbox.types.ts. Updatesrc/agent-sandbox/CLAUDE.md+packages/server/CLAUDE.md(both currently claim "no separate control-plane process"). harness-session/— lease/liveness/proxy routing unchanged, plus one addition: the proxy activity relay.AgentSandboxAdaptergainsrefreshActivity(idOrName)(Daytona:sandbox.refreshActivity(); local:POST /v1/sandboxes/:id/refresh-activity— shape-identical by design, one relay serves both providers). The proxy service calls it (a) debounced per proxied request (skip if refreshed within ~interval/3), and (b) on a timer while a proxied SSE/streaming response is held open — the proxy is the one choke point that sees all main-app data-plane traffic, and the open-stream heartbeat is what keeps a single long generation alive on Daytona (preview traffic is invisible to Daytona's idle clock; validated against SDK docs). Fire-and-forget with error swallow — keep-alive must never fail a data-plane request. This also fixes the latent staging bug:AGENT_AUTO_STOP_MINUTES=10with zerorefreshActivitycallers means mid-generation auto-stops are possible today.
Mandated opt-in suites keep their commands and scenarios; setup changes only:
local-native-sandbox.runtime.spec.ts/local-docker-sandbox.runtime.spec.ts— setup helper spawns a private daemon on an ephemeral port with test-scoped env (its own workingDirRoot/port range), health-polls/v1/info, builds the HTTP adapter against it; scenarios (create/preview/crash-reconcile/rollback/reap/teardown) run unchanged, now exercising the real seam.afterAll: list-by-test-label → delete each → kill daemon (Daytona-style cleanup, no scope reap).local-harness-session.e2e.spec.ts+ model-backed first-prompt — spec setup boots the same private daemon and setsAGENT_CONTROL_PLANE_URL; test-owned, not server-owned.- Default-run jest: rewrite
local-agent-sandbox.adapter.spec.tsagainst a stubbed control-plane server; updateagent-sandbox.module.spec.ts.
C1. ControlPlaneClient in agent/packages/agent-client/src/control-plane.ts (~150 lines, plain fetch + EventSource — don't reuse StreamTransport, its topic model is agent-server-specific): info/list/create/get/stop/delete, refreshActivity(id)/setAutostopInterval(id, minutes) (mirror the Daytona SDK, A7), entrypointLogsUrl, dataPlaneUrl(sandbox), waitForReady(id) (poll state + GET <preview>/api/info), watch(onEvent) with lastEventId reconnect. Tests against a Bun.serve fake control plane (kept in-repo as the executable spec of the contract) + the FakeEventSource pattern from transport.test.ts.
C2. De-singletonize the web-ui (mechanical, behavior-identical, merges first):
- New
web-ui-react/src/app/client-pool.ts— module-level, mirrorspackages/client/src/modules/agent-chat/useRemoteSession.ts(proven per-base-URL client pattern):acquireClient(baseUrl)refcounted →client.stop()at 0 (with StrictMode-linger, copy thecancelleddance);clientForSession(sessionId)reverse index. src/app/agent-client.tsshrinks to nullable legacy-server config (__AGENT_SERVER_URL__, dropmain.tsxhard-throw) + pool re-exports.- Thread
sessionIdthrough the singleton's 7 importers:store/agent-client-hooks.ts(useAgentState/useAgentToolUpdate/optimistic append resolve store viaclientForSession),app/useSessionState.ts(per-owning-client transport + workspace-version subscription),tools/renderers/renderer-utils.ts(relativePath(sessionId, path)— each sandbox has its ownworkspaceDir;ToolUpdatesContextalready carries sessionId), export URLs inApp.tsx.
C3. Sandbox registry + sidebar — new src/app/sandbox-registry.ts (vanilla zustand createStore):
- Inputs: initial
listSandboxes()+watch()events; per-started-sandbox mirror of the pooled client'ssessionsslice. - Merged view, control plane optional: probe both on boot. Control plane up → "Sandboxes" section (+ "Shared server" section if legacy also up); down → exactly today's behavior. No mode toggle.
- Eager-light, lazy-heavy connections: eager
client.start()per started sandbox (one EventSource per sandbox = separate origin, no per-origin limit stacking; powers live sidebar spinners/titles), capped (~15, newest-activity first, poll fallback beyond); heavysession:<id>:agent/:uitopics only for the active session via existingconnectSession. - Rows render sandbox diagnostics from
SandboxInfoalone: state dot, origin badge (harness/web-ui/cli), port,errorReason, entrypoint-logs link. Stopped sandboxes need no client. ?session=stays the URL currency (sandbox ⇒ one session;sessionIdIS the harness session id for main-app sandboxes). Boot resolution: registry → legacy list → stopped-sandbox row with "sandbox stopped" state (no auto-restart in v1, matches terminal-stop contract).
C4. Full drive: New session → createSandbox({labels:{origin:'web-ui'}}) → row appears starting via watch → waitForReady → acquireClient → POST /sessions {model} on the child → connectSession → updateUrl. First prompt = normal composer send. Stop/delete actions per row; deleteAgentSessionAndConnectNext grows a sandbox branch. Export builds URLs from the session's base URL; import targets legacy server, or create-sandbox-then-import when control-plane-only.
C5. FilePanel per sandbox: active session's apiBaseUrl = ${dataPlaneUrl}/api (absolute; already threaded through FilesView/FilePreview/Gamma views); workspace-changed invalidation from the owning client (C2). Grep remaining literal '/api' under src/app/views/ + src/tools/ during implementation.
C6. Vite/URL wiring: keep /api proxy for legacy; add __CONTROL_PLANE_URL__ define (default http://127.0.0.1:4025); control plane + sandboxes reached directly (no proxy — avoids EventSource buffering; CORS covered).
In gamma-agent/src/cli/prompt-client.ts (+ flags in commands/prompt.ts), extract testable resolvePromptTarget(env, fetchImpl):
GAMMA_AGENT_SERVER_URL/AGENT_SERVER_URLset → existing direct path unchanged (env keeps meaning "that server"; preserves Docker recipe).- Else probe
GAMMA_AGENT_CONTROL_PLANE_URL→127.0.0.1:4025; reachable → create new sandbox (origin:'cli'),waitForReady, then run the existing per-server flow (POST /sessions, SSE, prompt, exit onagent_settled) against the sandbox preview URL. Streaming code unchanged. - Unreachable → today's 4001/4000 probe, with a one-line note of which mode ran.
Flags: --sandbox (require control plane), --no-sandbox, --rm (delete sandbox after settle/SIGINT). Default: leave the sandbox running, print Sandbox: <id> — inspect at <web-ui>?session=<id>, delete from sidebar or --rm. Sandbox-mode UI URL from GAMMA_AGENT_WEB_UI_URL (default http://localhost:4000), not the 4001→4000 rewrite.
Dev stack: bun run agent process-compose gains the control-plane process; startup output prints all three URLs (web-ui 4000 / shared server 4001 / control plane 4025).
The control-plane split (A–D) fixes the process-ownership half of this — server stops orphaning child processes — but not the token half; A5 rehydration actually makes the token staleness more visible. Both belong in this plan.
Local, AGENT_SANDBOX_PROVIDER=local. A harness (main-app-origin) session that stays open keeps hitting the Gamma API with an expired agent JWT — Agent JWT verification failed: jwt expired → 401 on every /agent-tools/* call. Never self-corrects while the sandbox stays alive.
- Mint-once, no refresh.
AgentSandboxService.mintSession(agent-sandbox.service.ts:666, nottlSeconds→ config default, was 6h) mints at create; injected asGAMMA_AGENT_TOKEN(:1772), read once at boot bylocal-sandbox-child.ts, cached in-process by gamma-fs. No refresh, no retry-on-401 anywhere (gamma-fs/src/auth.ts,gml-edit/src/lib/auth.ts). - No local recycle. No idle reaper / interval / webhook locally. Prod forces fresh mints via Daytona
AGENT_AUTO_STOP_MINUTES=10+ thesandbox.state.updatedwebhook'srevokeBySandboxId. Locally only a server restart reaps (as orphan) — and A5 rehydration removes even that escape hatch. - Reuse/resume is blind to token expiry.
resumeSessionreuses the holder whenisLive+isSandboxRoutable(harness-session.service.ts:625-637);isSandboxRoutableprobes the sandbox's unauthenticated/api/info— inbound health. The token is an outbound credential; an alive+routable sandbox with a dead token is classified healthy forever.
- Fix — process ownership: server churn (
nest start --watchskippingonApplicationShutdown) no longer orphans detached children; the daemon owns, rehydrates, reconciles. Structural fix for leaked/hanging processes, stands on its own. - Don't fix — token staleness: gaps 1 & 3 untouched.
bun run prompt(D) is immune (fresh mint per run). Standalone web-ui/CLI sandboxes mostly immune (gamma-fs falls back to/dev/mint-agent-token, 30d dev token) but still cache-per-process. The bug lives on the harness (main-app) path, and A5 widens its window.
- E1. Dev TTL — immediate mitigation. ALREADY IMPLEMENTED out of band (uncommitted in
packages/server/src/config/oauth-provider.config.ts):DEFAULT_AGENT_TOKEN_TTL_SECONDSenv-aware — 6h prod, 30d whenAPPLICATION_ENVIRONMENT === 'dev'(mirrorsdefaultAgentSandboxProvider()). - E2. Idle auto-stop + keep-alive (in scope — A7, client-push, Daytona-identical). Bounds live-process accumulation and how long a fixed token can linger; a reaped-then-recreated sandbox mints fresh. With E1's 30d dev TTL, the continuously-alive-past-TTL edge isn't reachable locally.
- Deferred — refresh-on-401 in the agent (invalidate pinned token on Gamma 401, re-mint). Out of scope for this stack; tracked separately.
- Token TTL: sandbox created under
APPLICATION_ENVIRONMENT=devcarries ~30dexp; prod/unset still 6h. - Auto-stop: low
AGENT_CONTROL_PLANE_AUTOSTOP_MINUTES, idlestartedsandbox → stopped withauto_stopped, port freed;refreshActivityresets timer; reads don't; a busy sandbox with no keep-alive caller stops on schedule (Daytona-faithful — this failing would have masked the staging bug). - Proxy activity relay: with a low autostop interval, a session held open through the gamma proxy (idle tab, then a long streaming generation) stays alive on both providers; closing the tab lets it auto-stop on schedule. Staging spot-check on Daytona once deployed.
- Process ownership:
kill -9child → reconciled stopped; daemon restart with live children → rehydrated; server restart mid-generation → sandbox keeps running, session resumes to the same holder.
PR 1 — agent/ control plane + clients (everything in the bun workspace; no packages/server changes):
agent-protocolsandbox types +Bun.servefake control plane fixture (the contract).agent-control-planepackage: ported runtimes/types/ports/manager + HTTP server + bun tests + process-compose entry.- Web-ui de-singletonization (behavior-identical; regression gate: control plane down +
bun run agentbehaves exactly as today). ControlPlaneClient+ sandbox registry + sidebar + full drive + FilePanel + vite defines.bun run promptsandbox mode + flags.
Note: during PR 1, the runtime code is temporarily duplicated (original still in packages/server, ported copy in agent/). That's the price of the split — acceptable because PR 2 deletes the originals and the stack merges together.
PR 2 (stacked on PR 1) — packages/server cutover:
mirrored wire types, HTTP LocalAgentSandboxAdapter (no launcher — daemon is external infra), buildCreateParams env additions, module/config changes, dev-cli control-plane service wiring, jest spec rewrites, delete the moved runtime files, CLAUDE.md updates. Merge gated on all four mandated opt-in suites passing.
- Per-package
lint:fix(yarn) /bun run check+bun test(agent/). - All four mandated opt-in harness suites (root CLAUDE.md commands verbatim): native runtime, docker runtime, native harness-session e2e, model-backed first-prompt — required before PR 5 merges.
- Manual end-to-end:
- Control plane down,
bun run agent→ web-ui identical to today. - Web-ui: create two sandboxes, chat both in parallel, independent spinners, per-sandbox FilePanel workspaces.
bun run prompt "..."twice → two cli-origin sandboxes appear live in sidebar; open via printed URL; delete from UI.- Main app (
AGENT_SANDBOX_PROVIDER=local): start a session fromagents.tsx→ sandbox appears in web-ui with harness badge; prompt it from the web-ui; main-app client still works through the gamma proxy. kill -9a child → row flips stopped with reason; UI healthy. Restart the daemon while children run → rehydration keeps them.?session=deep link into a sandbox session after reload; export JSONL.
- Control plane down,
- Contract drift between the two hand-mirrored type files → keep contract small,
info.versionwarn-on-mismatch, fake-server fixture as executable spec. - Survival across server restarts is a deliberate behavior change (today a server restart reaps everything, an artifact of the registry being in-memory): needs explicit coverage — restart the server mid-generation, session resumes to the same live sandbox; drift after a redeploy is mitigated by auto-stop +
info.versionwarn, acceptable locally. - Daemon-not-running ergonomics: with no auto-spawn,
yarn devusers need the daemon managed by dev-cli (wired in PR 2); until then the readiness error message must make the one-liner fix obvious. - Relay edges (identical on both providers by design): (a) background work with no client connected (tab closed, no proxied stream) gets no keep-alive and auto-stops — the follow-up if it bites is a gamma-server poll of the child's
GET /api/activity(via preview URL) translated toadapter.refreshActivity(), provider-agnostic; (b) residual unknown whether Gamma's control-plane reads (getSignedPreviewUrl,get/list) reset Daytona's clock — the relay makes it moot for correctness, but a staging experiment (autoStopInterval: 1, hit only those endpoints) would settle it. Locally reads explicitly don't count, so if Daytona's do, that's a (benign) fidelity gap to note. - Env provenance audit during the runtime port: known leaks are
OTEL_TRACES_SAMPLER,HOCUSPOCUS_*, andoptions.env: process.envin the docker rewrite (local-docker-sandbox.runtime.ts:214); anything else found moves into the request or daemon env. - N EventSources at dev scale: fine per-origin; cap + poll fallback from day one; if
workspacetopic proves chatty, add astartSessionsOnly()client option later. - Open (deferred, current answer in parens): restartable stop now that the daemon outlives the server (keep terminal for parity); one daemon per machine vs per checkout (single daemon, checkouts distinguished by labels, repoRoot in
/v1/info); import UX in control-plane-only mode (create-then-import is acceptable for a diagnostics UI).
Daytona SDK (as called in daytona-agent-sandbox.adapter.ts) |
Local control plane /v1 |
Parity notes |
|---|---|---|
daytona.list({ labels, limit: 100 }) |
GET /v1/sandboxes?label.k=v |
Exact-match label filter, both. Local reconciles runtime state on read. Reads never bump activity (explicit local rule; ambiguous in Daytona docs) |
sandboxApi.createSandbox({ snapshot, target, envVars, labels, autoStopInterval, autoDeleteInterval }) |
POST /v1/sandboxes { name, runtime, snapshot?, envVars, labels, autoStopInterval, autoDeleteInterval } |
No first-class scope — checkout identity is a label. target/cpu/mem/disk meaningless locally; daemon owns full rollback; 409 on name conflict |
daytona.get(idOrName) + sandbox.refreshData() |
GET /v1/sandboxes/:idOrName |
Reconcile-on-read = refreshData() |
sandbox.delete(timeout) |
DELETE /v1/sandboxes/:idOrName |
Local idempotent 204 on missing |
sandbox.stop(timeout) |
POST /v1/sandboxes/:id/stop |
Divergence: Daytona restartable; local terminal (v1). Auto-stop uses this same path (errorReason: 'auto_stopped') |
sandbox.start(timeout) |
POST /v1/sandboxes/:id/start |
Divergence: 409 unsupported_lifecycle locally |
sandbox.resize(resources, timeout) |
POST /v1/sandboxes/:id/resize |
Local no-op returning current info |
sandbox.getSignedPreviewUrl(port, ttl) |
GET /v1/sandboxes/:id/preview-url?port=4000&ttl= |
Local: unsigned http://localhost:<port>, ttl ignored, only port 4000 valid |
sandbox.process.getEntrypointLogs() |
GET /v1/sandboxes/:id/entrypoint-logs |
Local redacts daemon-side |
sandbox.refreshActivity() |
POST /v1/sandboxes/:id/refresh-activity |
Called via the proxy activity relay through AgentSandboxAdapter.refreshActivity() — one code path serves both providers. Load-bearing on Daytona; presence-override locally |
sandbox.setAutostopInterval(minutes) |
POST /v1/sandboxes/:id/autostop { interval } |
0 disables, both. Not called by server today; available to web-ui/CLI locally |
| Concern | Daytona | Local control plane |
|---|---|---|
| Identity | Daytona-minted id + separate name |
id === name === tokenSandboxId |
| Grouping | Labels | Labels only (scope demoted to a label; no scope routes) |
| States | Full Daytona enum incl. archiving/archived/error/build_failed |
starting → started → stopped → destroyed |
| Idle detection | Blind to workload — client-push refreshActivity only; preview traffic explicitly excluded (validated against SDK docs) |
Identical by design: blind, client-push only; reads explicitly don't count. Fidelity over cleverness — a workload-aware local daemon would mask broken keep-alive wiring until staging |
| Auto-stop default | 15 min when create doesn't specify; harness creates pass 10 | Same: 15 when create doesn't specify; harness creates pass the same 10 (one buildCreateParams, both providers) |
| Auto-delete / archive | autoDeleteInterval (-1 = never), autoArchiveInterval → object storage |
Accepted, ignored — delete stays explicit |
| Stop consequences | Filesystem preserved, restartable, webhook fires → server revokes JWT | Terminal; durable state on session volume; resume = fresh sandbox + fresh mint |
| State-change push | Svix webhooks → server (fences JWT because stopped Daytona containers are restartable with their old token) | None to server, by design: local stop is terminal so the fencing threat doesn't exist; reconcile-on-read + resume-time revocation give the same outcomes. SSE /v1/events → web-ui only |
| Lifecycle coupling to server | None (external service) | None (same principle) — no spawn, no reaps, no hooks |
| Auth | DAYTONA_API_KEY |
None; loopback bind + /v1/info identity check, dev-only |