# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A web UI for spinning up isolated [devcontainer](https://containers.dev/) instances from any local folder **or Git repo URL**, each exposing a browser-based VS Code (`code-server`). Pick a source → the app copies the folder (or clones the repo), injects code-server into its `devcontainer.json`, runs `devcontainer up`, and publishes the editor on a unique host port.

## Runtime & commands

This runs on **Bun only** (Node.js is not supported). The app is built on **Mochi**, an SSR Svelte 5 framework with islands-based selective hydration, installed from npm as `mochi-framework`.

```sh
bun run dev        # dev server (MODE=development, project-local DATA_DIR, no browser launch)
bun run start      # runs src/index.ts directly (no separate build step needed)
bun run build      # mochi-framework build → .mochi/
bun run typecheck  # svelte-check (with a custom warning-ignore flag) + tsc --noEmit; run after any TS/Svelte change
bun test           # all tests
bun test src/index.isolated.test.ts          # single file
bun test -t "renders Hello world"            # single test by name
bun run checks     # format + eslint + typecheck + tests — run this after every change (see below)
bun run clean      # remove .mochi build output
bun run lint       # prettier --check + eslint (read-only; `checks` runs format instead of prettier --check, but the same eslint)
bun run gen:chimes # re-render attention chimes to public/sounds/ WAVs (after editing scripts/gen-chimes.ts)
bun run prod       # build + serve the production bundle
bun run verify:package # pack the tarball, install + boot it elsewhere (see npm packaging below)
```

**Always run `bun run checks` after implementing a change** (it runs `format`, then `eslint`, then `typecheck`, then `test`) and fix anything it surfaces before considering the work done.

**Docker availability & testing strategy.** The automated suite (`bun test` / `bun run checks`) is fully self-contained — even `docker.isolated.test.ts` stubs dockerode via `globalThis.__codebayDocker`, so **no Docker daemon is required** and the checks always run even where no daemon exists. Only end-to-end verification of the instance lifecycle — actually creating an instance, `devcontainer up`, the boot/health flow — needs a **live daemon**. Before attempting any such live verification, probe with `docker info` (or `docker context inspect`): if it's unavailable, don't try to boot instances — say so and fall back to the unit/isolated tests, which cover the orchestration logic against stubs. When Docker _is_ running, live-boot verification becomes an option on top of the suite. The repo's own `.devcontainer/` runs a **nested** daemon (`docker-in-docker`) rather than mounting the host's socket, because instances booted from inside it publish into that container's network namespace — the only place the proxy and the health probes look.

Tests that touch server state are named `*.isolated.test.ts`; pure unit tests (e.g. `src/avatars/*.test.ts`) use plain `*.test.ts`. `bunfig.toml` sets `root = "src"` (so vendored framework tests never run) and preloads `test-setup.ts`, which wipes `./.test-data` and forces `DATA_DIR` there before any module loads — a bare `bun test` can never open the real `~/.codebay` DB. Note that `db.server.ts` pins its SQLite handle to `globalThis`, so all tests in a run share one DB connection: a per-file `DATA_DIR` override only wins if that file opens the DB first, and don't `rmSync(DATA_DIR)` in a test's `afterAll` or you'll yank state out from under other tests.

Both `dev` and `start` execute `src/index.ts` with Bun. In dev mode Mochi compiles pages on the fly; in production (`start`, no `MODE`) it serves pages **and** client islands from the prebuilt `.mochi/` manifest read once at boot, while `routes.ts` and every `.server.ts` load from source — so after editing a `.svelte` (or a `.ts` it imports) you must `bun run build` **and restart**, or the API and the client drift apart (a stale island crashes on a changed response shape and vanishes). `src/index.ts` warns at boot when `.mochi/` is older than any source file it was built from (`build-freshness.server.ts`; checkout-only, since a package install stamps every file with its extraction time). The `dev` script already sets `MODE=development`, `DISABLE_OPEN_BROWSER=1`, `DATA_DIR=./.codebay` (keeps state inside the repo while developing), and `HOST=0.0.0.0` (so the dev server is reachable through a container port mapping — note this also flips `PUBLISH_HOST`, and dev sets no `BASIC_AUTH_PASSWORD`, so a dev server is unauthenticated on the LAN).

**Port:** the server reads `PORT` (default 6969 — the fallback lives in `src/lib/config.server.ts`; everything else imports `PORT` from there rather than re-reading the env).

**Browser launch:** on startup the server opens the web UI in the user's default browser. Set `DISABLE_OPEN_BROWSER=1` to skip it — _you_ (Claude Code) should always run with this set (the `dev` script already does).

**Dev-only routes:** when `MODE=development`, `/debug` (UI component showcase) is mounted.

## Comments

Comment the **why**, never the **what** — the code already says what it does, and a comment that restates it just rots. **One sentence.** Allow a second only when the why is genuinely incomprehensible without it (a non-obvious constraint, a bug being worked around, an ordering dependency between two calls); never a third. A comment that keeps growing usually means the code needs a better name or a smaller function, not more prose. Prefer no comment to an obvious one.

## Architecture

**Request flow.** `src/index.ts` boots `Mochi.serve` (with the global `basicAuth` handle wrapping every request — see Auth below) and the route table in `src/routes.ts`. Routes are `Mochi.page` (SSR Svelte page), `Mochi.api` (JSON handler), or `Mochi.ws` (WebSocket). Pages SSR with minimal `serverProps` (plus a `snapshot` of the current instance list to avoid a loading flash); live data is then hydrated client-side over WebSockets. The dashboard (`/`) and tabbed IDE (`/ide/:id`) both render the same persistent hydrated `App.svelte` shell so navigating between them is an in-place client transition that keeps the code-server iframes mounted. An IDE iframe is only mounted once that instance's `codeServerAccessible` health probe has passed — `status === 'running'` means the container is up **and** its post-up injections have finished, not that code-server has bound its port.

**Server-only modules.** Files ending in `.server.ts` (everything in `src/lib/`) are stripped from the client bundle by Mochi's tree-shaker. Keep Node/Bun APIs, Docker calls, SQLite, and filesystem access in `.server.ts` files — importing them into a hydrated component would leak them (or fail) on the client.

**Instance lifecycle** (`src/lib/instances.server.ts`) — the core orchestration:

1. `createInstance` validates the source (a **local folder** — copied — or a **Git repo URL** — cloned; `repo-url.ts` parses/normalizes the URL, `git.server.ts` does the clone and reads the live branch), allocates a host port, writes a `creating` row, and kicks off `boot()` in the background (the HTTP request returns immediately).
2. `boot()` (background): `copyWorkspace`/`cloneRepo` (copy skips the basenames in the "copy ignore patterns" setting — a comma-separated `options` entry, `node_modules` by default, empty means copy everything — but **always keeps `.git`** so each instance retains its history/remote) → `writeOverrideConfig` (merges the project's `devcontainer.json` — which is **never modified**, so the instance's git status stays clean — plus the code-server feature, a staged local `codebay-tmux` feature (best-effort build-time tmux install for firewalled containers; the post-up `tmux` injection is the runtime fallback), appPort + a `postStartCommand` launcher into a Codebay-owned sibling file (`.devcontainer/codebay.devcontainer.json`, or `.codebay.devcontainer.json` at the root for a flat-form project; git-hidden via `.git/info/exclude`), seeding the project's declared `forwardPorts`/`appPort` as port forwards, injecting the user's Custom environment variables as `containerEnv`, and returns the resolved `image_source` + both config paths) → `devcontainerUp` (boots from the merged file via `--override-config`; streams CLI output to the log buffer — masked through `appendLog`'s secret redaction — parses the final JSON result line for the container ID) → post-up injection: runs every **container injection** in order (see Container injections below).
3. Status transitions: `creating → running` / `error`; later reconciled against live Docker state. The row stays `creating` for the **whole** boot — the flip to `running` happens only after the last injection, the `.codebay-injections-done` sentinel, and `relaunchSurface`, since `running` is what opens the IDE/terminal, the proxy, and the health + log-capture monitors. Reconcile leaves a `creating` row alone only while its boot is in the in-memory in-flight set (`__codebayProvisioning`); an orphaned one (manager restarted mid-boot) is re-derived from Docker so it can't strand.

**Live streams over WebSocket** (`instances.server.ts`, `bridge.server.ts`, `health.server.ts`):

- _Central stream_ (`/api/stream`, `Mochi.ws`): one socket carries typed events for the whole UI — the full reconciled instance list **and** per-container health snapshots. A global `hub` broadcasts the list to all subscribers; it ticks every few seconds to catch external Docker state changes and pushes immediately on any mutation via `triggerReconcile()`. `listInstances()` reconciles persisted status against `docker inspect` and folds in in-memory attention + health state. A fresh socket is seeded with current state.
- _Per-instance logs_ (`/api/instances/:id/logs`, `Mochi.ws`): an in-memory `registry` keeps a capped log buffer (2000 lines) per instance; subscribing replays the buffer then streams new chunks.

The hub, log registry, attention map, health monitors, in-flight boots, and DB handle are all pinned to `globalThis` (`__codebayHub`, `__codebayRegistry`, `__codebayAttention`, `__codebayHealth`, `__codebayProvisioning`, `__codebayDb`) so dev-mode hot reload doesn't reopen connections, orphan timers, or lose state.

**Reverse proxy** (`src/lib/proxy.server.ts`, spread into the route table as `proxyRoutes`): each running instance's code-server is served **same-origin** under `/p/:id/` instead of via its raw host port. `/p/:id/*` forwards HTTP to the instance's loopback `host_port`, and a WebSocket relay (dispatched through an internal `/__codebay_proxy_ws` sentinel pattern) tunnels code-server's sockets. Use `proxyPathFor(id)` to build IDE URLs.

**Bridge / attention** (`src/lib/bridge.server.ts`): an in-memory, UI-only signal each container can raise — `done` (Claude finished) or `waiting` (Claude needs input) — to pulse its IDE tab. The injected Claude hooks `curl` `host.docker.internal:$PORT/api/bridge/attention` with the instance id, a per-instance `bridge_token`, and a state. The `/api/bridge/attention` route is **exempt from Basic Auth** and authenticates by token instead; `bridge_token` is a container-only secret and is stripped from instance rows before they reach the client.

**Container injections** (`src/container-injections/`): each post-up thing the manager installs into a container is a self-contained `Injection` module — currently `git-safe-directory.ts`, `tmux.ts`, `git-identity.ts`, `claude-code-credentials.ts`, `claude-code-custom.ts` (opt-in LiteLLM/Bedrock endpoint + model IDs read from the `options` store; skipped when disabled or unconfigured), `claude-code-update.ts` (reinstalls the latest `@anthropic-ai/claude-code` at boot when the cached build is behind — Claude Code 2.x's native binary is dropped by npm's postinstall into the _invoking_ user's HOME, so this reinstall, like `claude-code-install.ts`'s npm path, must run **as the remote user with HOME set** (`getent passwd … | su -m`), never as root, or the binary strands in `/root`; success is judged by a post-install `claude --version`, not npm's exit code), `github-credentials.ts`, `attention-hooks.ts`, `claude-permission-mode.ts` (the `claude` alias, carrying whatever flags the permission-mode setting resolves to — the same flags `writeOverrideConfig` bakes into the launchers), `claude-aliases.ts`, `custom-env-vars.ts` (freeform `NAME=value` secrets from the `options` store — the values are injected as devcontainer.json `containerEnv` by `writeOverrideConfig`, so this module only verifies presence + draws the health row; the values are also masked in the boot log via `appendLog`'s redaction) (in registry/apply order) — exporting `apply()` (install + log), an optional `auth.status()` (host-credential availability), and an optional `check()` (live presence probe). The `Injection` contract and the registry (`injections[]`) live in `src/lib/injections.server.ts` — the single source of truth: `instances.server.ts` runs every `apply()` at boot, `health.server.ts` runs every `check()`, and `routes.ts` builds the setup-UI auth chips from every `auth`. Add or remove an injection by adding its module to `src/container-injections/` (which holds only the injection modules + their test) and editing the registry list. All container command execution goes through the shared `execInContainer` helper (`src/lib/exec.server.ts`), which runs `bash -lc <script>` via dockerode's exec API. Scripts read secrets from `$CODEBAY_STDIN`; the helper smuggles the secret in via a private exec environment variable (never argv), rebinds it to `CODEBAY_STDIN`, and `unset`s the carrier — so secrets never hit the process arg list. Linux caps a single env string at 128 KiB (`MAX_ARG_STRLEN`), so the helper refuses a larger payload up front with a clear error (`EXEC_STDIN_MAX_BYTES`); MCP `write_file` content (base64'd, so ¾ of that) and run prompts inherit the ceiling, and their tool descriptions state it.

_To add one:_ create `src/container-injections/<name>.ts` exporting an `Injection` (`id`, `label`, required `apply(target, log)`, optional `auth` for a host dependency, optional `check()` for the health list), then add it to the `injections[]` array in `src/lib/injections.server.ts`. Array order is apply order — keep `git-safe-directory` first since later git-touching steps depend on it. That single edit wires it into boot, health probing, and the setup-UI auth chips at once; no other file needs touching.

**Health** (`src/lib/health.server.ts`): when reconcile sees a container running it starts a per-container monitor that re-probes every few seconds (is code-server answering on its host port, plus each injection's `check()`) and keeps the latest snapshot in memory; the snapshot rides the central stream. The per-injection presence rows are driven by the injection registry, so they track whatever injections exist. Live-only, never persisted; stops when the container is gone.

**Log capture** (`src/lib/log-capture.server.ts`): a second per-container monitor, started/stopped from the same reconcile hook as health (`syncLogCapture(rows)` beside `syncHealthMonitors(rows)` in `listInstances()`). Every ~10s it pulls Claude Code's append-only transcripts (`~/.claude/projects/**/*.jsonl`) and `history.jsonl` out of the container and mirrors them into one flat `<DATA_DIR>/logs/` folder (`history-<id>.jsonl`, `transcript-<id>-<session>.jsonl`, plus an `index.json` mapping ids → instance). There is no `docker cp`/tar path, so extraction runs a manifest+delta pair through `execInContainer` and ships only the new bytes as base64 (immune to the capture path's trim/demux). Offsets are just the host mirror's on-disk size, so it's restart-safe; a container whose file shrank below the mirror (a rebuild wipes `~/.claude`) **rolls the mirror over** to `<name>.<n>.jsonl` rather than overwriting it. The mirrors deliberately **outlive instance deletion** (`deleteInstance` runs one last pass, then `stopLogCapture`, but never removes the files — only the full `deleteDatabaseAndShutdown` teardown does); `stopInstance` and `provision` (the rebuild path) drain a final pass the same way, since neither a stopped nor a replaced container can be exec'd afterwards. Always on, both modes, no setting.

**MCP server** (`src/mcp/`, `src/lib/mcp-auth.server.ts`): an agent-facing control plane at `/mcp`, built on `tmcp` + `@tmcp/transport-http` and spread into the route table as `mcpRoutes`. `Mochi.api` hands the transport a raw `Request` and returns its `Response`, which is exactly `transport.respond`'s shape. **Off by default** (`mcp_enabled` in `options`); enabling it in Settings mints `mcp_token`. `/mcp` is exempt from `basicAuth` and Mochi's CSRF filter — the same trade `/api/bridge/` makes — and authenticates with `Authorization: Bearer` via `timingSafeEqualStr`. Mounting outside `/api/` is deliberate: `basicAuth` 403s every mutating `/api/` request lacking the `x-codebay-request` header, which no MCP client sends. Unlike every other secret here the token **is** sent to the client, because a human has to copy it into an MCP client. Tools live in `src/mcp/tools.server.ts` and register through a local `define()` that wraps each handler in `safe()`, so a thrown error reaches the model as tool content rather than a JSON-RPC protocol error. All real work stays in `src/lib/*.server.ts` — the MCP layer is protocol only.

**Agent runs** (`src/lib/agent-runs.server.ts`, `src/lib/agent-run-stream.ts`, `agent_runs` table): one `claude -p` invocation inside an instance, driven by the MCP server. `execInContainer` has no streaming and (by default) no timeout, so a run can't be awaited inside an exec — instead `startRun` inserts a `queued` row, then a poller stages `prompt.txt` + `run.sh` under `$HOME/.codebay/runs/<runId>/` and launches it **detached**. The load-bearing part of the launch is `>/dev/null 2>&1 </dev/null &`: `execInContainer` resolves on the exec stream's `end`, which only fires once every attached fd is closed, so a child inheriting them would hang the call for the whole run. `setsid` earns its place separately — it makes `run.sh` a session leader so `$$` is also the PGID `stop_run` signals (guarded against an empty/non-numeric/`<2` pgid, since `kill -- -0` would hit every process the user owns).

The poll script reads the **exit file before the stream tail** — an exit code observed first proves claude was already dead, so the bytes read after it are final; the reverse order lets a run finish between the two reads and report a complete exit alongside a truncated stream. `run.sh` writes that exit file via tmp+`mv` so it is never read half-written, and exports `IS_SANDBOX=1` under uid 0 because Claude Code refuses `--dangerously-skip-permissions` as root (which is what an instance with no resolved `remote_user` execs as). Liveness is an identity check against `/proc/<pid>/cmdline`, not a bare `kill -0`, since a restarted container reuses low PIDs. New bytes append to `<DATA_DIR>/logs/run-<runId>.jsonl` and the offset is that mirror's size on disk, so the whole thing is restart-safe for free — the same trick log capture uses, and it reuses log capture's `tailBlockScript`/`parseFetchBlocks` framing. The terminal fold (the pass that sees the exit file) re-reads the **whole mirror** rather than trusting the in-memory cursor: a chunk another process fetched is on disk even though this cursor never saw it, and finishing from the cursor alone once wrote a NULL result over a successful 26-turn run. A `--json-schema` run that exhausts its structured-output retries is still an error, but not an empty one: the row keeps the last plain-text assistant answer as `result`, the refused payload as `rejected_structured_output`, and `structured_output_failed` as its `error`, so a caller can tell a schema failure from a crash. `agent-run-stream.ts` folds the `stream-json` events into the run row (session id, the model Claude actually ran with from the `init` event — the requested alias stays in `options` and both ride every run payload — live `last_activity`, cost, the terminal `result`), carrying an incomplete trailing line between chunks.

The poller runs on **its own timer**, not off the reconcile hook: `syncLogCapture`/`syncHealthMonitors` ride `listInstances()`, which only ticks while a browser WebSocket is open, and an MCP caller has no browser. Run progress broadcasts as its own `run` stream event rather than through `triggerReconcile()`, which would run a `docker inspect` per instance every few seconds. One run per sandbox (Claude Code shares one session dir per project), and while a run holds `$HOME/.codebay-agent-active` the surface launchers drop to a plain shell instead of auto-starting a second interactive Claude — `launchClaude` guards on it, `run.sh` owns the marker's lifetime via an `EXIT INT TERM` trap, and the poll pass that sees the exit file clears it in case of a hard kill. `stopInstance`/`rebuildInstance`/`deleteInstance` all settle the active run first, since a run's detached process can't outlive its container.

**Agent log** (`src/components/AgentLogBox.svelte`, `GET /api/instances/:id/agent-log`): the instance page renders an MCP run's steps — prompt, each tool call, subagent steps indented, retries, and the terminal result — parsed from the host mirror by `parseRunTimeline` (`agent-run-stream.ts`). Separate from `readRunChunk`, which keeps only the _latest_ of each field. Reading the mirror rather than the container means the log survives the sandbox. Each run is a collapsible box — newest expanded, older ones collapsed to a one-line peek; a run that starts while the panel is open collapses the rest and expands itself. The route takes `run_ids` so one request refreshes every expanded box, and a box expanded by hand lazily fetches its own timeline. It refreshes off the central stream's `run` frame rather than a socket of its own; the effect must read `bump` as a real value, since a bare `void bump;` is dead code the compiler drops — that silently unsubscribed the panel the first time round. The live row says "Working…" rather than echoing `last_activity`, which while running is the same step the final timeline row already shows, and a `result` whose text repeats the closing assistant message supersedes it.

**Sandbox operations** (`src/lib/sandbox-ops.server.ts`): what the MCP workspace/git tools sit on — `execCommand` (bounded by the container's own `timeout`, with `execInContainer`'s `timeoutMs` as a backstop for a wedged docker stream), `readWorkspaceFile`/`writeWorkspaceFile`, `gitDiff`, `gitPush`, `createPr` (whose body gets the Codebay attribution footer only when the `mcp_pr_attribution` option is on — off by default, since an attribution line on someone's PR is theirs to opt into). The command rides in through the stdin carrier rather than the script body, so nothing needs escaping and it never reaches the container's process list; paths go in as argv entries for the same reason. Output is base64-framed because the capture path trims and the demuxed stream is text.

**Auth** (`src/lib/auth.server.ts`): a global HTTP Basic Auth gate (`basicAuth` handle) protects the UI, the APIs, **and** every proxied code-server — including the `/p/:id/*` proxy's WS upgrades (browsers send them with the cached `Authorization` header) — with one password. Disabled when `BASIC_AUTH_PASSWORD` is unset/empty (local dev). Password and `bridge_token` checks use `timingSafeEqualStr` (`src/lib/crypto.server.ts`) so comparisons don't leak length/prefix via timing. The `/api/bridge/` paths are skipped because containers can't carry the app password. **Caveat:** Mochi's `handle` middleware wraps only page/api routes and the fetch fallback, **not** `Mochi.ws` routes — so `/api/stream` and `/api/instances/:id/logs` bypass `basicAuth` and instead enforce `wsUpgradeAllowed()` (same-origin + Basic Auth) in their own `upgrade` callbacks. Every WS upgrade (proxy + `Mochi.ws`) also gets a cross-site-WebSocket-hijacking guard: the `Origin` must match the request `Host`, which holds even when no password is set. Bind defaults to loopback (`127.0.0.1`); set `HOST=0.0.0.0` to expose on the LAN (do so with a password).

**Persistence** (`src/lib/db.server.ts`): `bun:sqlite`, WAL mode. Tables: `instances` (one row per instance, incl. the resolved `image_source`), `agent_runs` (one row per MCP-driven `claude -p` run), `port_forwards` (extra host↔container port mappings per instance — see Port forwards below), and `options` (a generic key/value store, e.g. the picker's last-browsed folder via `getOption`/`setOption`). State lives under `DATA_DIR` (`src/lib/config.server.ts`) — default `~/.codebay` (outside the repo), overridable via the `DATA_DIR` env var (relative values resolve against cwd; `dev` sets it to `./.codebay`). **One manager per `DATA_DIR`**: `data-dir-lock.server.ts` writes `<DATA_DIR>/manager.lock` at boot and refreshes its mtime on a heartbeat; a second manager that finds a fresh lock exits with an error, because two managers share the DB and both poll every run (and a second Bun server binds the same port without an error — seen on macOS — so they don't even collide on the socket). A stale lock ages out on its own, so a crash never wedges the next boot. The DB is `<DATA_DIR>/app.sqlite`; per-instance workspace copies live under `<DATA_DIR>/instances/<id>/`; extracted Claude Code logs are mirrored to a flat `<DATA_DIR>/logs/` (see Log capture below) that survives instance deletion. Schema changes are numbered SQL files in the repo-root `migrations/` dir (`0001_init.sql` … current `0011_agent_run_rejected_structured_output.sql`), applied on `open()` via `migrate(database, getMigrations(MIGRATIONS_DIR))` from `@zihaolam/bun-sqlite-migrations` — add a new higher-numbered file to evolve the schema.

**Port forwards** (`src/lib/devcontainer.server.ts`, `port_forwards` table, `src/components/PortsBox.svelte`): a project's own declared `forwardPorts`/`appPort` (minus code-server's 8080) are captured at boot and, together with any the user adds later, published on unique host ports bound to `PUBLISH_HOST` (`config.server.ts`) — loopback, or `0.0.0.0` when the server itself is bound there via `HOST`. code-server's own host port is always `127.0.0.1`, since it has no auth of its own and must stay behind the proxy's Basic Auth gate. `writeOverrideConfig` renders `appPort` **deterministically** from `hostPort` + the current forwards (discarding whatever the file held), so removing a forward actually drops its mapping on the next rewrite.

**Folder picker** (`src/lib/picker.server.ts`): `browse(path)` lists subdirectories for the source-folder chooser, flags which contain a devcontainer config, and persists the last-viewed folder in `options` so the next visit resumes there.

**Docker & devcontainer CLI** (`docker.server.ts`, `docker-client.server.ts`, `devcontainer.server.ts`): Docker Engine operations go through **`dockerode`** (the Docker HTTP API), via the shared client in `docker-client.server.ts`. dockerode honors `DOCKER_HOST` but not `docker context`, so the client resolves the daemon once at startup — `DOCKER_HOST` if set, else a single `docker context inspect` shell-out to read the active context's socket (Desktop, Colima, OrbStack, remote), else the default `/var/run/docker.sock` — and pins the instance to `globalThis` (`__codebayDocker`). That `docker context inspect` is the only remaining `docker` CLI spawn and exists purely for connection discovery. **Container creation still uses the `@devcontainers/cli` binary** (`devcontainer up`, resolved from local `node_modules/.bin`) — dockerode can't replace it. Host ports are allocated from the 8001–8999 range; code-server always listens on 8080 inside the container.

> Bun caveat: dockerode's `exec` _stdin streaming_ is unusable on Bun (the hijack path needs an HTTP `upgrade` event Bun doesn't emit; the non-hijack path can't half-close the request body). `execInContainer` works around this by passing the secret in the exec's environment and `printf`-piping it into the script's stdin — see below.

**Handles / theme** (`src/lib/theme.server.ts`): `src/index.ts` composes middleware as `sequence(basicAuth, themeHandle)`. `themeHandle` rewrites the SSR'd `<html>` tag with `data-theme` from the theme cookie via `transformPage`, so the first paint has no flash-of-wrong-theme; it fires for every HTML response including 404s and proxy passthrough. Client-only UI prefs (e.g. the attention chime toggle) live in `src/settings.ts` on `localStorage` and no-op during SSR.

**Design tokens** (`src/shell.html`, the single `:root` block): every themed value is declared once as `light-dark(light, dark)` and the theme switch only flips `color-scheme` — there is no second stylesheet and no `prefers-color-scheme` query. Light is a pale LCD panel; dark is an amber-phosphor CRT, **not** an inversion of light. That distinction is held by four structural tokens that must stay separate from `--ink` (which is text only): `--edge` (the `.panel` hairline), `--shadow` (the hard un-blurred offset block — light casts it darker than the page, dark can't, so there it's a warm plate), and `--fill`/`--fill-ink` (the inverted chip — panel bars, active tabs, primary buttons, filled ticks — which is where dark's amber lives). Folding any of them back into `--ink` turns every one of those into a glowing near-white slab in dark mode. `--scrim` and `--warn-chip` exist for the same reason: both must darken/lighten in a direction `--ink` can't express in both themes. The `/debug` palette gallery reads its swatch values back off the rendered chips, so it never needs hand-syncing. Note `light-dark()` is `<color>`-only — `--dim`/`--dim-panel` are deliberately unthemed numbers.

**Avatars** (`src/avatars/`): each instance gets a deterministic 8×8 sprite via `pickAvatar(id)` against the `avatars[]` registry in `index.ts`. `serialize.ts` powers the avatar-editor easter egg at `/avatars` (reached from the coin at the bottom of `/settings`; the page also lists the whole set) — it turns a drawing into the exact module source a sprite ships as, plus a pre-filled GitHub issue URL. Those "Avatar contribution: …" issues are landed via the `avatar-contribution-pr` skill in `.claude/skills/`.

**Distribution.** Three ways this ships: `bunx codebay` (see npm packaging below); a GHCR image (`Dockerfile` + `docker-compose.yml`) that drives the **host's** daemon Docker-out-of-Docker — needs `--network host`, hence `BASIC_AUTH_PASSWORD`, and a `DATA_DIR` bind-mounted at an identical host↔container path; and running from source. Versioning/publishing is automated by release-please (`release-please-config.json`), so use conventional commit messages. README's Configuration section is the canonical env-var list (`PORT`, `DATA_DIR`, `DOCKER_HOST`, `HOST`, `PUBLIC_ORIGIN`, `TRUSTED_ORIGINS`, `BASIC_AUTH_PASSWORD`, `MOCHI_KEY`, `CODEBAY_CLAUDE_CODE_TOKEN`, `CODEBAY_GITHUB_TOKEN`, `DISABLE_OPEN_BROWSER`).

**npm packaging (`bunx codebay`).** The bin entry `bin/codebay.ts` `chdir`s to the package root before importing `src/index.ts`, because the server resolves the shell, pages, `public/`, and the `.mochi` manifest relative to cwd. It absolutizes a relative `DATA_DIR` **first**, so a user's `DATA_DIR=./state` lands where they ran the command instead of inside the npm cache. Invariants a published tarball must satisfy:

- It ships a **built `.mochi/`**. In production Mochi loads SSR components from the prebuilt manifest; without it every page compiles on first request. Mochi ≥ 0.9's manifest v2 stores every disk path relative to the build root, so the build is relocatable — an earlier attempt on 0.8.x shipped absolute build-machine paths and only booted on the machine that built it.
- It ships **`public/`**. Since 0.9 the build never copies `publicDir`; the runtime reads it from disk on every boot. Drop it from `files` and every static asset 404s.
- `files` (not `.gitignore`) governs the tarball, and `package.json` uses **`resolutions`, not `overrides`** — npm rejects an `overrides` entry that conflicts with a direct dep (`EOVERRIDE`) and refuses to pack at all.

`bun run verify:package` (`scripts/verify-package.ts`) is the guard for all of the above: it packs the tarball, installs it into a throwaway project, boots it there from a different cwd in production mode, and asserts the page renders, `/sounds/done.wav` is served, no island SSR errors are logged, no publicDir warning appears, and no manifest disk path escapes the package. Run it after touching anything in this list; `.github/workflows/release.yml` runs it right before `npm publish`.

Releases: release-please (`release-please-config.json`) opens the version PR from conventional commits; merging it cuts a tag, and the publish job builds **from that tag** (never from moving `main`). npm is configured as a **trusted publisher** for this repo (OIDC), so there is no `NPM_TOKEN` secret — the job's `id-token: write` permission is what authenticates it, and provenance is attached automatically. The trust is pinned to the workflow _filename_, so renaming `.github/workflows/release.yml` breaks publishing until the setting on npmjs.com is updated to match.

`writeOverrideConfig` parses the project's `devcontainer.json` as JSONC (custom `stripJsonc` strips comments/trailing commas) and merges in the code-server feature non-destructively — the project's file is read-only input; the merged result lands in the Codebay-owned config that `devcontainer up --override-config` boots from. It **must** be `--override-config`: the CLI validates `--config` against `/\/\.?devcontainer\.json$/` and rejects any other basename. `--config` is still passed, pointing at the project's own config, because that path is what the CLI resolves relative `build.dockerfile`/`dockerComposeFile`/local-feature paths against and what it stamps as the `devcontainer.config_file` label — and its own lookup doesn't know the `.devcontainer/<folder>/` form. Since `overrideConfigPath()` puts the merged file in that same directory, resolution is identical either way. Instances created before this scheme had the injections written into the tracked file itself; `restoreCanonicalConfig` (called at the top of `provision()`) detects that legacy fingerprint (`codebay-tmux`) and `git checkout`s the tracked file (or deletes an untracked one Codebay created) on the next rebuild — stashing the pre-restore file as `*.codebay-backup` first, and acting only when git definitively answers the tracked-probe (a git failure returns `'unknown'` and is retried). The whole migration is one-shot per instance, gated by the `config_migrated` column (new rows are born with it set), so a legit config merely containing the fingerprint is never repeatedly clawed back; configs git can't clean (injection committed, or no `.git`) instead have the injected features/launcher stripped from the merge input by `stripLegacyInjections`. `provision` also removes the old container by ID (warning on failure) since the CLI's `--remove-existing-container` label match keys on the config path and skips a workspace match whose label disagrees.

## Mochi framework

The framework is a normal npm dependency, `mochi-framework@^0.8.2` (published from `github.com/khromov/mochi`, the `packages/mochi` subdir). Bump it like any other dependency; there is no vendoring step.

Two MCP servers are configured (`.mcp.json`) for reference: `mochi` (framework docs) and `bun-docs`. Consult them when working with Mochi APIs (`Mochi.page/api/ws`, hydration directives like `mochi:hydrate`) or Bun-specific APIs.
