# CLAUDE.md

<!-- Last meaningful update: 2026-06-08 (v2.8.20-paseo-orchestrator-ph3-5) -->

## You cannot
- Write, edit, or delete files (BooChat only — use BooCoder for writes)
- Run shell commands (use booterm terminal panes)
- Make commits, push, or pull (Sam reviews and commits manually)
- `git add -A` (stage only files you changed)

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

**Cursor agents:** start with `docs/ARCHITECTURE.md` (diagram); this file is the deep engineering reference. `data/AGENTS.md` is the agent *registry*, not navigation (the root navigation `AGENTS.md` was removed).

## What is BooCode

Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) against a local llama-swap inference server. Sessions organized by project, multi-pane workspace (chat + file browser side by side).

Plus `apps/booterm` (second container, port 9501, bookworm-slim+glibc): Fastify + node-pty + tmux. Browser terminal panes WS to `/ws/term/sessions/:sid/panes/:pid`; per-session tmux session `bc-<sid>`, per-pane window `term-<pid>`. Shells drop privs to samkintop via `gosu` in `tmux.conf` default-command.

## Commands

```bash
# Development (run in separate terminals)
pnpm dev:server          # tsx watch, port 3000
pnpm dev:web             # Vite dev server, port 5173 (proxies /api to :3000)

# Build
pnpm build               # builds web then server
pnpm -C apps/server build  # server only (tsc + copy schema.sql)
pnpm -C apps/web build     # web only (vite)

# Type checking (no emit)
# Per-app is authoritative. There is NO root tsconfig.json (only tsconfig.base.json),
# so a bare `npx tsc --noEmit` at root compiles nothing.
npx tsc -p apps/web/tsconfig.app.json --noEmit   # web (authoritative)
pnpm -C apps/server build                        # server typecheck (tsc + copy schema)
pnpm -C apps/coder build                         # coder typecheck
pnpm -C apps/booterm typecheck                   # booterm typecheck

# Production
docker compose build --no-cache boocode && docker compose up -d
```

Tests: `pnpm -C apps/server test` (vitest); `apps/coder` has its own suite — `pnpm -C apps/coder test` (`globals:false`, so import `describe`/`it`/`expect` from `vitest`). No `apps/web` test harness, no linters. Vitest pinned to `^3` (Vite 5 / vitest 4 incompatible). Include glob is `src/**/__tests__/**/*.test.ts` — tests outside it silently won't run. Extract pure helpers to unit-test (`backends/turn-guard.ts`, `lifecycle-decisions.ts` are the pattern).

## Architecture

**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres), `apps/web` (React + Vite), `apps/booterm` (Fastify + node-pty + tmux), `apps/coder` (BooCoder, host service), `packages/contracts` (`@boocode/contracts`, cross-app wire-contract SSOT — builds FIRST).

### Per-app deep references

Detailed engineering notes live in per-app `CLAUDE.md` files, **auto-loaded when you read/edit files in that subtree** (and worth opening before non-trivial work there):

- **`apps/server/CLAUDE.md`** — inference pipeline, AI-SDK adapter gotchas, tools, compaction, broker, the `messages_with_parts` view, sidecar routing, secret guard, the `data/AGENTS.md` registry.
- **`apps/coder/CLAUDE.md`** — BooCoder dispatch, provider registry/probe/snapshot, opencode/ACP/PTY/Claude-SDK backends, `agent_sessions` resume.
- **`apps/web/CLAUDE.md`** — React app, hooks/event buses, font & CSS pipeline, multi-pane workspace, all UI conventions.
- **`docs/project-discovery.md`** — full stack / tooling / command inventory across all packages (read-on-demand).

Cross-app contracts (WS-frame & provider-type parity, sentinels) and everything below stay here.

### Guidance resolution order
When multiple sources conflict: `CLAUDE.md` (repo root) → `BOOCHAT.md` / `BOOCODER.md` (per-surface) → per-app `CLAUDE.md` (auto-loaded by file context) → `data/AGENTS.md` (agent preamble beats per-agent body) → session `system_prompt` → user prompt. Last-encountered wins on samplers; refusals cascade downward (you cannot do what any layer forbids).

### Data flow for chat

1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows
2. `inference.enqueue()` starts async streaming loop
3. LLM deltas published via `broker.publish(sessionId, frame)`
4. Client's `useSessionStream` WS receives frames, `applyFrame` reducer updates message list
5. Tool calls: inference executes tools server-side, publishes tool_call/tool_result frames, loops back to LLM
6. Terminal states (complete/error): DB updated with final content + token counts, `session_updated` frame published on user channel

## Database

PostgreSQL 16. DB name: `boochat` (Docker service stays `boocode_db`). Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `message_parts`, `pending_changes`, `tasks`, `available_agents`. Views: `messages_with_parts` (parts-merge read path), `tool_cost_stats` (per-tool 100-call rolling window), `human_inbox` (tasks WHERE state IN blocked/failed). Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints: `projects_status_chk`/`sessions_status_chk`/`chats_status_chk` ('open'|'archived'), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. **Two schema files, one DB:** `apps/server/src/schema.sql` owns `sessions`/`chats`/`messages`/`message_parts`; `apps/coder/src/schema.sql` (applied by the boocoder host service) owns `agent_sessions`, `worktrees`, `pending_changes`, `available_agents` and extends `tasks` — so e.g. an `agent_sessions` FK change goes in the **coder** schema. Idempotent FK-action flips (e.g. `ON DELETE CASCADE`→`SET NULL`) guard on `pg_constraint.confdeltype` so re-runs are no-ops.

Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap the new constraint ADD in a `DO $$ ... pg_constraint` guard — the only way to get `ADD CONSTRAINT IF NOT EXISTS`.

**`CREATE OR REPLACE VIEW` can't reorder/rename columns** (Postgres `42P16`): append a new `messages_with_parts` column at the END of the SELECT — a mid-list insert shifts an existing column → crash-loops boot. Add it to each explicit read SELECT too (`routes/messages.ts`/`chats.ts`/`ws.ts`).

**A `SELECT *` view pins every column** (`2BP01`): `DROP COLUMN` on the table fails while such a view exists. `human_inbox` is `SELECT * FROM tasks` — to drop a `tasks` column, `DROP VIEW IF EXISTS human_inbox` first, drop the column(s), then recreate the view (idempotent). Bites existing DBs only; a fresh DB never had the column, so fresh-DB testing misses it.

## Environment

Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only add-existing scope), `BOOTSTRAP_ROOT` (/opt/projects, writable bootstrap mkdir target — host must `mkdir -p` it before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale; the public host is behind Authelia, unusable from server context), `BOOCODE_TOOLS` (`core`|`standard`|`all`, default `all`; a ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (default `/data/mcp.json`, opencode `mcpServers` shape; missing = no MCP), `CONTEXT7_API_KEY` (the Context7 MCP key, referenced from `data/mcp.json` as `"{env:CONTEXT7_API_KEY}"`). `data/mcp.json` is **gitignored** but no longer holds secrets — string values support opencode-style `{env:VAR}` substitution (`mcp-config.ts:substituteEnvVars`, applied before Zod validation; unset var → `''` + warn), so real keys live in `.env`; template `data/mcp.example.json`. A config-only edit there needs only `docker compose restart boocode` (data/ is bind-mounted); changing a referenced secret edits `.env`. MCP loads at server startup with per-server graceful degradation; the coder does NOT load MCP (BooChat only).

BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Its env file `apps/coder/.env.host` is gitignored (`.env.*`, with `!.env.example`) — a fresh host recreates it from `.env.example` (incl. `CLAUDE_SDK_BACKEND=1` for the Claude Agent-SDK backend). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.

BooControl at port 9503: `curl http://100.114.205.53:9503/api/health`. The fleet cockpit; runs as `boocontrol.service` on the host (not Docker), same pattern as boocoder. Third schema owner on the shared `boochat` DB (control_* / bench_* / eval_* / route_* tables; startup guard waits for server-owned `sessions` before `applySchema`). Env file `apps/control/.env.host` is gitignored; `LLAMA_PROVIDERS_PATH` must point at the host path `/home/samkintop/opt/boocode/data/llama-providers.json` (NOT a container `/data` mount) or the `auto:*` gateway 503s. Deploy: `pnpm -C packages/contracts build && pnpm -C apps/control build && sudo systemctl restart boocontrol`. Reached from BooChat via `apps/server`'s `registerControlProxy` (`/api/control/*`), gated on the `BOOCONTROL_URL` env (set in `docker-compose.yml`). The `auto:*` routing gateway is a registry entry (`kind: boocontrol-gateway`) in `data/llama-providers.json`. Full first-deploy steps: `openspec/changes/boocontrol-finish/runbook.md`.

- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch.
- Arena: `POST /api/battles {project_id, battle_type, prompt, contestants}` starts a battle; `GET /api/battles/:id` returns battle + contestants + cross-examinations; `POST /api/battles/:id/stop` cancels; `POST /api/battles/:id/analyze` triggers/re-triggers two-stage digest→judge analysis; `GET /api/battles/:id/analysis` reads `analysis.md`; `POST /api/battles/:id/cross-examine {identity, model}` runs a cross-examination. All `/api/battles*` routes are served by `apps/coder` at port 9502 (proxied through `apps/server` as `/api/coder/battles*`).

## Workflow

- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Sam often has uncommitted `apps/web` work in flight — stage your own commits **explicitly by path** (never `git add -A`); `docker compose up --build -d boocode` builds the working tree, so a container rebuild also ships his uncommitted web changes.
- **Deploy by surface:** an `apps/coder` change → `sudo systemctl restart boocoder`; an `apps/web` or `apps/server` change → `docker compose up --build -d boocode` (rebuilds web+server from the working tree). The `boocode` container is `build: .`, so uncommitted changes deploy; web edits are live on the Vite dev server (HMR) but NOT on production (`:9500` / code.indifferentketchup.com) until a rebuild. Use `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue.
- Cutting a release: name the feature branch DIFFERENTLY from the tag (branch `f1-interrupt-guard`, tag `v2.6.7-interrupt-guard`) — identical names trigger `warning: refname ... is ambiguous`.
- Per-batch docs live under `openspec/changes/<slug>/{proposal,tasks,design}.md`; shipped batches are snapshots in `openspec/changes/archived/`. New batches follow the proposal+tasks shape (see `openspec/README.md`).
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`), monotonic per minor — the slug alone recalls what shipped. No letter suffixes, no pseudo-ranges, no slug-only sub-versions sharing a number (split into sequential patches).
- `CHANGELOG.md` is the per-tag release log, newest on top. New tag → add a `## <tag> — <YYYY-MM-DD>` section, one 3–6 sentence paragraph (no nested bullets) from the commit body; cross-reference related tags by name when the batch builds on / fixes / pairs with prior work.
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`. Keep both remotes synced: push `main` + the release tag to `origin` (Gitea, deploy key above) AND `backup` (`git@github.com:indifferentketchup/boocode.git`, default key).
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- DB-integration tests opt-in via env var: `DATABASE_URL="postgres://boocode:${POSTGRES_PASSWORD}@localhost:5500/boochat" pnpm -C apps/server test`. Host port 5500; password is `${POSTGRES_PASSWORD}` from `.env` (read it from there — do NOT trust any literal written here or in `.env`'s `DATABASE_URL` line; a stale literal in this doc has already caused auth-failure debugging loops). `psql` isn't on host PATH — use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` + `beforeAll` applying schema via `sql.unsafe(readFileSync(schemaPath))`. `tool_cost_stats.test.ts` is the reference.
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
- Frontend blank-screen / runtime crash: get the stack-trace column offset from the browser console, then `cut -c <start>-<end> apps/web/dist/assets/index-*.js | sed -n '<line>p'` to read the exact minified expression that threw. Watch for `=== null`/`!== null` on optional fields fed an `as unknown as` cast — those bypass tsc.
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without `Content-Type` tricks on the client.
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
- node-pty's compiled `.node` is libc-specific: proddeps and runtime Dockerfile stages must share libc (alpine↔musl or bookworm-slim↔glibc); the TS-only builder stage can stay alpine for speed.
- pnpm 10 `--frozen-lockfile` skips node-pty's postinstall — the Docker proddeps stage runs `cd node_modules/node-pty && npm run install` to force the native compile.
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
- `/opt/boolab` hosts a sibling BooCode at `boocode.indifferentketchup.com` — useful for side-by-side iPhone comparison when debugging booterm rendering. It uses Tailwind v3, boocode uses v4 — don't assume build parity.
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (in the bash prompt) does NOT resolve inside the container. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if the shell moves to a different machine.
- Boocontext MCP server integrates tree-sitter code analysis tools (callgraph, health, impact, symbols, types, wiki). Wrappers in `apps/server/src/services/tools/codecontext/` (directory name retained for import compat). Invoke boocontext tools through the tool registry — MCP tools are appended at startup via `appendMcpTools`.
- The old Go codecontext sidecar has been removed from the Docker deployment (v2.8.20). The TypeScript boocontext fork at `/opt/forks/codecontext/` (branch `boocode-ts`) still exists for reference but is no longer deployed. Build: `go build ./...` from within that directory if needed for local testing.
- Go binary (only if working with the fork): `/snap/go/current/bin/go` (not on PATH). Use `export PATH=$PATH:/snap/go/current/bin` or the full path.
- `os/exec` child supervisors must call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` never fires because the parent stays alive.

## Conventions

Cross-cutting only. Per-app conventions live in the matching `apps/*/CLAUDE.md`.

- No app-layer auth. Authelia handles auth at the reverse proxy. All `broker.publishUser`/`subscribeUser` calls use `'default'` as the user key.
- TypeScript strict mode. Both apps share `tsconfig.base.json`. Server + coder use NodeNext module resolution (`.js` extensions in imports).
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
- **Adding a new WS frame type** (cross-app): add it to `WsFrameSchema` in `packages/contracts/src/ws-frames.ts` (single source of truth; rebuild with `pnpm -C packages/contracts build`). The server's `InferenceFrame` loose union (`services/inference/turn.ts`) and the web's strict `WsFrame` discriminated union (`apps/web/src/api/types.ts`) still exist separately and also need updating. Server publish is permissive; the frontend type is the wire-format gate — missing the web side silently drops the frame at JSON-parse.
- **Sentinels** (cross-app) are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. `MessageMetadata` is single-sourced in `@boocode/contracts` (`packages/contracts/src/message-metadata.ts`). A new kind requires updating that file and rebuilding the package, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
- **Provider snapshot types** (`ProviderSnapshotEntry`, `ProviderModel`, `ProviderMode`, `ThinkingOption`, `AgentCommand`, `ProviderSnapshotStatus`) are single-sourced in `@boocode/contracts` (`packages/contracts/src/provider-snapshot.ts`); `apps/coder/src/services/provider-types.ts` re-exports them. Edit the package source; there is no hand-synced web copy to update.
- **`@boocode/contracts`** single-sources cross-app wire contracts via per-subpath built-dist exports, consumed by all four apps (incl. `apps/coder/web`): `./ws-frames`, `./provider-snapshot`, `./provider-config` (Zod schemas), `./message-metadata` (`MessageMetadata`/`ErrorReason`/`AgentSessionConfig`), `./worktree-risk`. It builds BEFORE every consumer (root build, Dockerfile, coder deploy). Its `WsFrame` is the loose `z.infer` of `WsFrameSchema` (payloads `unknown`); the web's richer strict `WsFrame` union is **deliberately web-local** (`apps/web/src/api/types.ts`), bridged to the validated frame by a cast — don't move it into the package. Consume built `dist` via the exports map; never add the package to a tsconfig `references` array.
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of an object/array). Pattern in `parts.ts`, `settings.ts`.
- Skills live in `data/skills/<vendor>/`; Sam's own namespace is `boocode/` (`committing-changes`, `using-worktrees`, `improving-boocode-guidance`, `systematic-debugging`) — `SKILL.md` + optional `eval.yaml` (gerund names; eval = `skill:` + `tasks:` of `prompt`+`grader`, incl. a negative-trigger task). `data/skills/` is canonical; a divergent mirror at `/opt/skills/` exists.

### Coding standards

Coding standards live in `docs/coding-standards/` (canonical, human-readable). They are exposed to Claude Code through per-file-type/subsystem index files under `.claude/rules/coding-standards/`. Each index is a path-scoped rule that lists the standards relevant to its `paths:` glob with a one-line description of each. When Claude reads a file matching an index's `paths:`, it loads only that small index and then decides which (if any) standards to open with Read — the full text of a standard is never loaded automatically, and standards do not appear in the skills picker. Browse `docs/coding-standards/` for the readable form.
