sbx host. It owns the Discord
surface and the sandbox lifecycle; each project’s OpenCode server runs inside
its sandbox and is reached over a loopback-published port.
Topology
Stack: TypeScript, Node 24.x (pinned viaengines + .nvmrc), discord.js 14.x,
the exact-pinned @opencode-ai/sdk, and node:sqlite. Tests run on vitest,
development uses tsx, and the build is tsc.
Module map
Invariant: only
src/sbx.ts spawns processes. src/shell.ts and
src/projects.ts call its API. A unit test guards this by checking for
child_process imports.
Create saga
/project add and /project create run a serialized create saga with
compensation at every step (ProjectService.addProject in src/projects.ts):
- Validate the host directory.
/project addrequires the path to resolve insidePROJECTS_ROOT;/project createcreates it underPROJECTS_ROOT/<name>. The sensitive-path denylist is applied. - Allocate a port and password. Pick a free host port from the port range,
generate a 32-hex server password, and insert the
projectsrow withstatus = 'provisioning'. sbx create. Create the sandbox (celly-<slug>) from the template, publishing<port>:4096and applying the CPU/memory limits.- Wake check.
sbx exec <name> trueensures the sandbox is running before any copy. - Bootstrap. Write the celly-managed OpenCode config and env file inside the
sandbox over stdin (mode
0600), so the server password never touches a host command line and a project-levelopencode.jsoncannot clobber global config. - Start and wait. Start the supervised serve child, read the actual port
mapping back from
sbx ports --json, and wait for health within the boot timeout. - Enforce the policy. PATCH the permission policy and assert the running
server reports it; then mark the project
ready, resolve its in-sandbox path, and create the Discord channel under the Forge category.
sbx rm --force <name>,
release the port, delete the channel/DB row, and report the failing step. A
transient bootstrap failure gets one retry before rollback.
Supervised server
One long-lived child per project, held in a registry keyed by channel:- Because an exec session is active, the sandbox is not idle-stopped.
- The child’s stdout/stderr are captured to
data/logs/<project>.log. - Boot is idempotent: if
/global/healthsucceeds or a tracked child is alive, no second child is started.ensureReady()is single-flighted per project. - If the child exits, the project is marked
degraded, the channel is notified once, and the next prompt or/project startre-establishes it. - A healthy server with no tracked child (an orphan from a previous bot process) is adopted rather than duplicated.
Event router and run state
- One SSE subscription per project reads
/global/event.EventRouterdemultiplexes frames bysessionIDto the owning thread, routing to the thread that owns the current run (or the most recently active one). - Resync is idempotent by part id: the renderer tracks assistant message and part ids and replaces rather than appends, so a reconnect does not duplicate output.
- Each thread runs a small state machine (
idle,running,aborting,errored). Prompts are queued (bounded byMAX_QUEUE) while a run is active, and a globalMAX_CONCURRENT_RUNScap bounds concurrency. /abortmoves the run toaborting, calls session abort, and force-finalizes after a 10-second timer.
Boot recovery
On boot, before subscribing, each ready project goes throughensureReady()
because its sandbox may have stopped and its serve child died with the previous
bot process. Threads left running or aborting are recovered from session
history and either finalized or re-attached to a new renderer. The in-memory
queue is not restored — see the limitations in the
changelog.