Document the in-app Orchestrator engine and its load-bearing read-only invariant in apps/coder/CLAUDE.md, and note that apps/coder/.env.host is now gitignored (recreated from .env.example with CLAUDE_SDK_BACKEND=1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
CLAUDE.md
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
# 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, themessages_with_partsview, sidecar routing, secret guard, thedata/AGENTS.mdregistry.apps/coder/CLAUDE.md— BooCoder dispatch, provider registry/probe/snapshot, opencode/ACP/PTY/Claude-SDK backends,agent_sessionsresume.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.
Data flow for chat
- User sends message → POST
/api/sessions/:id/messagescreates user + assistant (status=streaming) rows inference.enqueue()starts async streaming loop- LLM deltas published via
broker.publish(sessionId, frame) - Client's
useSessionStreamWS receives frames,applyFramereducer updates message list - Tool calls: inference executes tools server-side, publishes tool_call/tool_result frames, loops back to LLM
- Terminal states (complete/error): DB updated with final content + token counts,
session_updatedframe 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}.
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--yoloflag —-pruns autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch. - Arena:
POST /api/arena {project_id, input, contestants: [{agent?, model?}]}dispatches the same task to N models/agents in parallel; each contestant gets its own task + worktree.GET /api/arena/:idfor results;POST /api/arena/:id/select/:task_idpicks a winner.
Workflow
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Sam often has uncommitted
apps/webwork in flight — stage your own commits explicitly by path (nevergit add -A);docker compose up --build -d boocodebuilds the working tree, so a container rebuild also ships his uncommitted web changes. - Deploy by surface: an
apps/coderchange →sudo systemctl restart boocoder; anapps/weborapps/serverchange →docker compose up --build -d boocode(rebuilds web+server from the working tree). Theboocodecontainer isbuild: ., 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. Usedocker compose build --no-cache boocode && docker compose up -dif you suspect a layer-cache issue. - Cutting a release: name the feature branch DIFFERENTLY from the tag (branch
f1-interrupt-guard, tagv2.6.7-interrupt-guard) — identical names triggerwarning: refname ... is ambiguous. - Per-batch docs live under
openspec/changes/<slug>/{proposal,tasks,design}.md; shipped batches are snapshots inopenspec/changes/archived/. New batches follow the proposal+tasks shape (seeopenspec/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.mdis 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. TransientConnection reset by peerretries cleanly aftersleep 5. Keep both remotes synced: pushmain+ the release tag toorigin(Gitea, deploy key above) ANDbackup(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:devpass@localhost:5500/boochat' pnpm -C apps/server test. Host port 5500; password is${POSTGRES_PASSWORD}from.env(devpass), NOT the literal in.env'sDATABASE_URLline.psqlisn't on host PATH — usedocker exec boocode_db psql -U boocode -d boochat -c "...". Pattern:describe.runIf(!!process.env.DATABASE_URL)(...)+beforeAllapplying schema viasql.unsafe(readFileSync(schemaPath)).tool_cost_stats.test.tsis the reference. - Host-side smoke endpoint:
curl http://100.114.205.53:9500/api/.... The container's port mapping binds to the Tailscale IP, not0.0.0.0, solocalhost:9500doesn'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/!== nullon optional fields fed anas unknown ascast — those bypass tsc. - Fastify global JSON parser tolerates empty bodies (overridden in
index.ts); bodyless POSTs (archive, unarchive, stop) work withoutContent-Typetricks on the client. - Event dedup discipline: for any mutation the server publishes via
broker.publishUser, do NOT add a localsessionEvents.emit(...)after the API call —useUserEventsforwards 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 anodeuser at uid/gid 1000 — delete it (userdel/groupdelon debian,deluser/delgroupon alpine) before adding samkintop at 1000.- node-pty's compiled
.nodeis 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-lockfileskips node-pty's postinstall — the Docker proddeps stage runscd node_modules/node-pty && npm run installto force the native compile. - A local PreToolUse hook (
security_reminder_hook.py) regex-flags Node's olderchild_processspawn helpers as unsafe (false positive even on the File-suffixed variant). Usespawn— it's accepted. /opt/boolabhosts a sibling BooCode atboocode.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 hostnameubuntu-homelab(in the bash prompt) does NOT resolve inside the container. Override viaBOOTERM_SSH_HOST/BOOTERM_SSH_USERenv vars in docker-compose if the shell moves to a different machine. - codecontext sidecar lives at
/opt/boocode/codecontext/. HTTP API athttp://codecontext:8080/v1/<tool_name>over theboocode_netbridge (no host port). BooCode wrappers inapps/server/src/services/tools/codecontext/. The.codecontextignoreat project root is honored when--respect-gitignoreis passed (enabled in the shim). - codecontext fork at
/opt/forks/codecontext/— separate git repo (branchboocode-ts), pushed via the boocode_gitea SSH key toindifferentketchup/codecontext. Buildgo build ./...; testgo test ./.... Docker rebuild requires staging the fork first:tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext --exclude=.git --exclude=bin .thendocker compose build --no-cache codecontext(the Dockerfile COPYsfork.tar.gzinto the builder stage; Gitea is behind Authelia, no HTTP clone).fork.tar.gzis gitignored. - Go binary:
/snap/go/current/bin/go(not on PATH). Useexport PATH=$PATH:/snap/go/current/binor the full path. os/execchild supervisors must callchild.Wait()in a goroutine andos.Exiton child death.Signal(0)returns nil on zombies and is NOT a liveness check. WithoutWait(), docker'srestart: unless-stoppednever fires because the parent stays alive.codecontext/shim.gois the reference.
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/subscribeUsercalls use'default'as the user key. - TypeScript strict mode. Both apps share
tsconfig.base.json. Server + coder use NodeNext module resolution (.jsextensions in imports). - Discriminated unions for type narrowing:
Pane(bykind),SessionEvent(bytype),InferenceFrame(bytype). - Adding a new WS frame type (cross-app): add it to
WsFrameSchemainpackages/contracts/src/ws-frames.ts(single source of truth; rebuild withpnpm -C packages/contracts build). The server'sInferenceFrameloose union (services/inference/turn.ts) and the web's strictWsFramediscriminated 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 structuredmetadata.kind(cap_hit,doom_loop). UI-only —buildMessagesPayloadstrips them viaisAnySentinelso the LLM never sees them.MessageMetadatais 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 inapps/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.tsre-exports them. Edit the package source; there is no hand-synced web copy to update. @boocode/contractssingle-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). ItsWsFrameis the loosez.inferofWsFrameSchema(payloadsunknown); the web's richer strictWsFrameunion 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 builtdistvia the exports map; never add the package to a tsconfigreferencesarray.- JSONB columns: use
sql.json(value as never)— NOT${JSON.stringify(value)}::jsonbwhich double-serializes (stores a JSON string instead of an object/array). Pattern inparts.ts,settings.ts. - Skills live in
data/skills/<vendor>/; Sam's own namespace isboocode/(committing-changes,using-worktrees,improving-boocode-guidance,systematic-debugging) —SKILL.md+ optionaleval.yaml(gerund names; eval =skill:+tasks:ofprompt+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.