# 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-`, per-pane window `term-`. 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) 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\`...\`` generics; CLI `tsc` / `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 768–1023 / 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. ### 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 1–5 panes (chat / empty / placeholder terminal+agent). Workspace pane state is **client-side only** (localStorage key `boocode.workspace.panes.`); 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 ` (inline `CREATE TABLE` checks get `__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 `. 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. ## 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=` 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.