Files
boocode/CLAUDE.md
indifferentketchup 7096ae4ddc feat: remove Go codecontext sidecar, wire all boocontext MCP tools
Deletes all 17 native codecontext tool wrappers (~2,400 lines). Code analysis now provided entirely by boocontext MCP server (discovered at startup via appendMcpTools()). Adds 9 previously missing MCP tools (get_summary, scan, get_coverage, get_schema, get_env, get_events, get_knowledge, get_wiki_index, lint_wiki) to all relevant agent tool lists. Updates AGENTS.md, guidance files.
2026-06-08 04:18:04 +00:00

20 KiB
Raw Blame History

CLAUDE.md

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

# 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 CASCADESET 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 --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 36 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:devpass@localhost:5500/boochat' pnpm -C apps/server test. Host port 5500; password is ${POSTGRES_PASSWORD} from .env (devpass), NOT the literal in .env's DATABASE_URL line. 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.