Skip to main content
Celly is a Discord bot that runs natively on the 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 via engines + .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):
  1. Validate the host directory. /project add requires the path to resolve inside PROJECTS_ROOT; /project create creates it under PROJECTS_ROOT/<name>. The sensitive-path denylist is applied.
  2. Allocate a port and password. Pick a free host port from the port range, generate a 32-hex server password, and insert the projects row with status = 'provisioning'.
  3. sbx create. Create the sandbox (celly-<slug>) from the template, publishing <port>:4096 and applying the CPU/memory limits.
  4. Wake check. sbx exec <name> true ensures the sandbox is running before any copy.
  5. 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-level opencode.json cannot clobber global config.
  6. 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.
  7. 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.
On failure at any step: kill the child if started, 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/health succeeds 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 start re-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. EventRouter demultiplexes frames by sessionID to 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 by MAX_QUEUE) while a run is active, and a global MAX_CONCURRENT_RUNS cap bounds concurrency.
  • /abort moves the run to aborting, calls session abort, and force-finalizes after a 10-second timer.

Boot recovery

On boot, before subscribing, each ready project goes through ensureReady() 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.
  • Security — the invariants behind this design.
  • Commands — the user-facing surface.