7.8 KiB
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).
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
There are no tests or linters configured.
Architecture
Monorepo: pnpm workspaces with apps/server (Fastify + postgres) and apps/web (React + Vite).
Server (apps/server/src/)
- Fastify with
@fastify/websocketand@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 onsql<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 5 depth), flushes to DB every 500ms. PublishesInferenceFrameevents 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 throughpath_guard.tswhich 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
codeToHtmlinCodeBlock.tsxandFileViewerinFileBrowserPane.tsx). - Path alias:
@/maps tosrc/.
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 theSessionEventunion, you must also add a case to theapplyEventswitch inuseSidebar.ts(even if it's a no-opreturn prev).hooks/useSessionStream.ts— WebSocket per session,applyFramereducer builds message list from streaming frames.hooks/useUserEvents.ts— Single app-level WS to/api/ws/userwith exponential backoff reconnect. Forwards frames onto the sessionEvents bus.hooks/useSidebar.ts— Module-singleton with Set subscriber pattern; one bus subscription guarded byglobalThis.__boocode_sidebar_subscribedfor HMR safety. Every newSessionEventtype needs acasein theapplyEventswitch (no-opreturn previs fine).api/client.ts— Centralized typed fetch wrapper. All endpoints underapi.*namespace.
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
Multi-pane workspace
Sessions hold 1–5 panes (chat / empty / placeholder terminal+agent). Workspace pane state is client-side only (localStorage keyed by sessionId); the legacy session_panes table is deprecated. 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), 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(ordocker compose build --no-cache boocode && docker compose up -dif you suspect a layer-cache issue). - 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 settingContent-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).
Conventions
overflowWrapnotwordWrap— TypeScript's CSSStyleDeclaration markswordWrapas deprecated (error 6385).- 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 uses NodeNext module resolution (
.jsextensions in imports). - Discriminated unions for type narrowing:
Pane(bykind),SessionEvent(bytype),InferenceFrame(bytype). - shadcn primitives live in
components/ui/. Don't modify them unless adding a new primitive. inferLanguage()fromlib/attachments.tsis the canonical file-extension-to-language map.CodeBlock.tsxkeeps its ownLANG_MAPbecause it also resolves markdown fence names.