Files
boocode/CLAUDE.md
indifferentketchup 78914466d1 v1.12 track B.3: agent whitelists + .codecontextignore template + CLAUDE.md updates
Removed /opt/boocode/AGENTS.md (per-project override) — the project's
agents now resolve from the global /data/AGENTS.md only. Eliminates the
two-files-must-stay-in-sync footgun that surfaced during B.3
verification.

Fix: agents.ts ALL_TOOL_NAMES was a hardcoded 9-item whitelist that
silently filtered any unknown tool name from agent.tools arrays. This
caused web_search/web_fetch (v1.11.8) and the 8 codecontext tools to be
dropped at parse time. Replaced with ALL_TOOLS.map(t => t.name) for
single source of truth. Pre-existing exposure was dormant since no
builtin agent listed web_search; surfaced by adding codecontext.
2026-05-21 15:09:11 +00:00

14 KiB
Raw Blame History

CLAUDE.md

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

What is BooCode

Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a 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)
npx tsc --noEmit                              # project references (root)
npx tsc -p apps/web/tsconfig.app.json --noEmit  # web app specifically

# IMPORTANT: root tsc --noEmit uses project references and can miss errors
# that the per-app tsconfig catches. Always verify with the per-app command
# when editing web code. The server build (pnpm -C apps/server build) is
# authoritative for server code.

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

Tests: pnpm -C apps/server test runs 23 vitest tests. No test harness on apps/web (adding it requires installing vitest as a new devDep). Vitest pinned to ^3 because Vite 5 / vitest 4 are incompatible. No linters configured.

Architecture

Monorepo: pnpm workspaces with apps/server (Fastify + postgres), apps/web (React + Vite), and apps/booterm (Fastify + node-pty + tmux).

Server (apps/server/src/)

  • Fastify with @fastify/websocket and @fastify/static (serves built frontend)
  • postgres (porsager/postgres) with tagged-template SQL — no ORM. Schema in schema.sql, applied on startup. LSP may false-positive on sql<Type[]>\...`generics; CLItsc/pnpm build` is authoritative.
  • Zod for request validation and config parsing.

Key services:

  • services/inference.ts — Streams LLM responses, executes tool loops (max depth 15, see MAX_TOOL_LOOP_DEPTH), flushes to DB every 500ms. Publishes InferenceFrame events through the broker.
  • services/broker.ts — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
  • services/tools.ts — Four read-only file tools exposed as OpenAI function-calling schemas. All file access goes through path_guard.ts which resolves against project root.
  • services/file_ops.ts — Shared file operation implementations used by both inference tools and HTTP routes.
  • services/auto_name.ts — Non-streaming LLM call to generate 4-word session titles after first assistant reply.

Route registration: all routes registered in index.ts via register*Routes(app, sql, ...) functions. Routes are in routes/*.ts.

Frontend (apps/web/src/)

  • React 18 + React Router v6 + Tailwind v4 + shadcn/radix-ui primitives.
  • Shiki for syntax highlighting (async codeToHtml in CodeBlock.tsx and FileViewer in FileBrowserPane.tsx).
  • Path alias: @/ maps to src/.
  • Mobile interaction primitives (post-v1.6): useViewport (matchMedia, breakpoints mobile <768 / tablet 7681023 / desktop ≥1024), useSidebarDrawer / useRightRailDrawer (Context + auto-close on useLocation().pathname change), useLongPress (500ms timer, dispatches synthetic contextmenu on [data-tab-id]), usePullToRefresh (80px threshold, 600ms hold), SwipeablePaneTab (60px close, 30px vertical bail). Tap-target convention: max-md:min-h-[44px] max-md:min-w-[44px]. Mobile headers: border-b px-3 sm:px-4 py-2 + style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}. Hamburger left, FolderTree right.

Key patterns:

  • hooks/sessionEvents.ts — Module-singleton event bus (Set of listeners). Used for cross-component communication: session renames, file-open events, attachment dispatch. 9 event types in the discriminated union. When adding a new event type to the SessionEvent union, you must also add a case to the applyEvent switch in useSidebar.ts (even if it's a no-op return prev).
  • hooks/useSessionStream.ts — WebSocket per session, applyFrame reducer builds message list from streaming frames.
  • hooks/useUserEvents.ts — Single app-level WS to /api/ws/user with exponential backoff reconnect. Forwards frames onto the sessionEvents bus.
  • hooks/useSidebar.ts — Module-singleton with Set subscriber pattern; one bus subscription guarded by globalThis.__boocode_sidebar_subscribed for HMR safety. Every new SessionEvent type needs a case in the applyEvent switch (no-op return prev is fine).
  • api/client.ts — Centralized typed fetch wrapper. All endpoints under api.* namespace.

Font / CSS pipeline (apps/web):

  • Tailwind v4's @import "tailwindcss" directive strips font URLs from subsequent CSS @imports — @fontsource* packages must be imported as JS side-effect modules in apps/web/src/main.tsx, not via @import in globals.css. Otherwise the woff2 files never make it to dist/.
  • Lightning CSS (inside @tailwindcss/postcss v4) collapses contiguous unicode-ranges to wildcard shorthand (U+0000-FFFFU+????), which iOS Safari/Vivaldi mishandles (silently drops the font from those codepoints). Use explicit non-wildcard-collapsible subranges (e.g. U+2500-259F not U+2500-25FF). The apps/web build script greps dist/assets/*.css for U+2500-259F and fails the build if missing — preserve that guard.
  • @font-face blocks must live AFTER all @import statements (CSS spec). Earlier placement silently breaks every subsequent @import (this broke the 18 theme palette imports in globals.css for one session).
  • JetBrainsMono Nerd Font self-hosted in apps/web/src/fonts/ (TTF from ryanoasis/nerd-fonts release) — needed because @fontsource-variable/jetbrains-mono ships subsetted woff2s that don't cover U+2500-259F (box drawing + block elements, used by opencode's banner). "NL" = No Ligatures (matches font-feature-settings: "liga" 0); "Mono" = single-cell icon width so TUI layouts don't desync.
  • xterm-addon-webgl rasterizes glyphs via Canvas2D into a GPU texture atlas. Canvas2D does NOT honor font-display: block — it uses whatever font is currently registered. Gate xterm initialization on document.fonts.load(<font-name>) resolving before calling term.open() (see fontsReady useState in TerminalPane.tsx). iOS Safari/Vivaldi also reclaims WebGL contexts from backgrounded tabs: keep webgl.onContextLoss(() => webgl.dispose()) + recreate via visibilitychange. Do NOT manually dispose+recreate the addon after font load — iOS silently fails the second GL context creation and the terminal drops to DOM renderer with stale metrics.

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

Multi-pane workspace

Sessions hold 15 panes (chat / empty / placeholder terminal+agent). Workspace pane state is client-side only (localStorage key boocode.workspace.panes.<sessionId>); the legacy session_panes table and its REST endpoints are deprecated — no /api/panes/* routes exist. Each chat lives in at most one pane; tab strip is per-pane and tracks chatIds[] + activeChatIdx. Sessions 1:N chats; chats own messages. Tab reorder via native HTML5 drag events.

Database

PostgreSQL 16. Tables: projects, sessions, chats, messages, settings, session_panes (deprecated). Schema applied idempotently on startup via applySchema(). Use clock_timestamp() (not NOW()) inside transactions. CHECK constraints in place: projects_status_chk ('open'|'archived'), sessions_status_chk (same), chats_status_chk (same), messages_role_chk, messages_status_chk — keep in sync with the *_STATUSES const arrays in apps/server/src/types/api.ts.

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 new constraint ADD in DO $$ ... pg_constraint guard — that block is the only way to get ADD CONSTRAINT IF NOT EXISTS.

Position-shift pattern for panes (legacy session_panes table): negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.

Environment

Required: DATABASE_URL, LLAMA_SWAP_URL. Optional: PORT (3000), HOST (0.0.0.0), PROJECT_ROOT_WHITELIST (/opt, read-only scope for add-existing path resolution), BOOTSTRAP_ROOT (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must mkdir -p /opt/projects before container start), DEFAULT_MODEL, LOG_LEVEL.

Workflow

  • Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
  • Deploy: cd /opt/boocode && docker compose up --build -d (or docker compose build --no-cache boocode && docker compose up -d if you suspect a layer-cache issue).
  • 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.
  • Don't accumulate .bak-* files. Clean them up in the same batch or immediately after merge.
  • Fastify global JSON parser tolerates empty bodies (overridden in index.ts); bodyless POSTs (archive, unarchive, stop) work without setting 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 working sibling BooCode terminal at boocode.indifferentketchup.com. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (@tailwind base); boocode uses v4 — many subtle build differences. Don't assume parity.
  • booterm SSHs to the host as samkintop@100.114.205.53 (the Tailscale IP). The hostname ubuntu-homelab (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's /etc/hosts knows it. Override via BOOTERM_SSH_HOST / BOOTERM_SSH_USER env vars in docker-compose if you ever move the shell to a different machine.
  • codecontext sidecar lives at /opt/boocode/codecontext/. Sidecar HTTP API at http://codecontext:8080/v1/<tool_name> over the boocode_net bridge (no host port). BooCode wrappers in apps/server/src/services/tools/codecontext/. The .codecontextignore.template documents recommended ignore patterns; users copy and adapt to project root manually.
  • os/exec child supervisors must explicitly 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 policy never fires because the parent stays alive. The codecontext/shim.go implementation is the reference pattern.

Conventions

  • overflowWrap not wordWrap — TypeScript's CSSStyleDeclaration marks wordWrap as deprecated (error 6385).
  • 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 uses NodeNext module resolution (.js extensions in imports).
  • Discriminated unions for type narrowing: Pane (by kind), SessionEvent (by type), InferenceFrame (by type).
  • shadcn primitives live in components/ui/. Don't modify them unless adding a new primitive.
  • inferLanguage() from lib/attachments.ts is the canonical file-extension-to-language map. CodeBlock.tsx keeps its own LANG_MAP because it also resolves markdown fence names.
  • Two UI event buses: hooks/sessionEvents.ts for DB-state events (chat_created, session_updated); lib/events.ts for ephemeral UI (sendToTerminal, terminalsRegistry). Don't merge — different subscriber lifecycles.
  • vite.config.ts proxy entries are order-sensitive: more-specific prefixes (/api/term, /ws/term) must come BEFORE /api.
  • Mobile pane URL sync (Session.tsx): the ?pane=<id> effect resets activePaneIdx whenever panes changes. New-pane creation on mobile must push ?pane= atomically — addPaneAndSwitch is the wrapper that does this. addSplitPane returns the new pane id for callers.
  • xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (Cmd/Ctrl-C, Cmd/Ctrl-Shift-C) are the path.