18 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).
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 the vitest suite. 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. Vitest include glob is src/**/__tests__/**/*.test.ts (see apps/server/vitest.config.ts) — tests outside src/**/__tests__/ silently won't run; match the per-domain convention (apps/server/src/services/__tests__/foo.test.ts).
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/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 depth 15, seeMAX_TOOL_LOOP_DEPTH), flushes to DB every 500ms. PublishesInferenceFrameevents through the broker.TurnArgsis the per-turn state envelope threaded through theexecuteToolPhase → runAssistantTurnrecursion (toolsUsed,recentToolCalls,assistantMessageId,signal); reset to defaults inrunInferenceat the user-message boundary. Cap-hit (toolsUsed >= budget) and doom-loop (detectDoomLoop(recentToolCalls)) checks both read from this envelope. Add new per-turn state here, not in module-level closures.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— Tool registry (ALL_TOOLS,READ_ONLY_TOOL_NAMES,TOOLS_BY_NAME). Filesystem tools (view_file/list_dir/grep/find_files) go through three guard layers:path_guard.ts(workspace scope),secret_guard.ts(filename deny list),url_guard.ts(SSRF/private-IP block for web_fetch). v1.11.8+ web tools (web_search,web_fetch) are opt-in per chat viasession.web_search_enabled(resolved withproject.default_web_search_enabledfallback) and filtered out of the LLM's tool schema when false.services/compaction.ts+services/model-context.ts— v1.11.0 anchored rolling summary (singlesummary=trueassistant row per chat, supersedes itself on each compaction). Triggered whenchats.needs_compactionis set after an inference turn exceedsusable(ctx_max) = ctx_max - 20k.ctx_maxcomes frommodel-context.getModelContext()which fetches${LLAMA_SWAP_URL}/upstream/<model>/props— NOT fromparsed.timings.n_ctx(the stream completion'stimingsdoesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out).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/. - Mobile interaction primitives (post-v1.6):
useViewport(matchMedia, breakpoints mobile <768 / tablet 768–1023 / desktop ≥1024),useSidebarDrawer/useRightRailDrawer(Context + auto-close onuseLocation().pathnamechange),useLongPress(500ms timer, dispatches syntheticcontextmenuon[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 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.
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 inapps/web/src/main.tsx, not via@importinglobals.css. Otherwise the woff2 files never make it todist/. - Lightning CSS (inside
@tailwindcss/postcssv4) collapses contiguous unicode-ranges to wildcard shorthand (U+0000-FFFF→U+????), which iOS Safari/Vivaldi mishandles (silently drops the font from those codepoints). Use explicit non-wildcard-collapsible subranges (e.g.U+2500-259FnotU+2500-25FF). Theapps/webbuild script grepsdist/assets/*.cssforU+2500-259Fand fails the build if missing — preserve that guard. @font-faceblocks must live AFTER all@importstatements (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-monoships subsetted woff2s that don't coverU+2500-259F(box drawing + block elements, used by opencode's banner). "NL" = No Ligatures (matchesfont-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 ondocument.fonts.load(<font-name>)resolving before callingterm.open()(seefontsReadyuseState inTerminalPane.tsx). iOS Safari/Vivaldi also reclaims WebGL contexts from backgrounded tabs: keepwebgl.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
- 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 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, SEARXNG_URL (default http://100.114.205.53:8888 — internal Tailscale Fathom; the public search.indifferentketchup.com is behind Authelia and unusable from server context).
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). - 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. - 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). 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 working sibling BooCode terminal atboocode.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 hostnameubuntu-homelab(shown in the bash prompt after login) does NOT resolve from inside the container — only the host's/etc/hostsknows it. Override viaBOOTERM_SSH_HOST/BOOTERM_SSH_USERenv vars in docker-compose if you ever move the shell to a different machine. - codecontext sidecar lives at
/opt/boocode/codecontext/. Sidecar HTTP API athttp://codecontext:8080/v1/<tool_name>over theboocode_netbridge (no host port). BooCode wrappers inapps/server/src/services/tools/codecontext/. The.codecontextignore.templatedocuments recommended ignore patterns; users copy and adapt to project root manually. os/execchild supervisors must explicitly 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-stoppedpolicy never fires because the parent stays alive. Thecodecontext/shim.goimplementation is the reference pattern.
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.- Two UI event buses:
hooks/sessionEvents.tsfor DB-state events (chat_created, session_updated);lib/events.tsfor ephemeral UI (sendToTerminal,terminalsRegistry). Don't merge — different subscriber lifecycles. vite.config.tsproxy 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 resetsactivePaneIdxwheneverpaneschanges. New-pane creation on mobile must push?pane=atomically —addPaneAndSwitchis the wrapper that does this.addSplitPanereturns 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. - New tools live in their own
services/<name>.tsfile (seeweb_search.ts,web_fetch.ts) — exports a pureexecuteFoo(input, ...deps)for direct test access plus aToolDefwrapper thatloadConfig()s its real dependencies. Register the ToolDef intools.tsALL_TOOLS(andREAD_ONLY_TOOL_NAMESif applicable). Injectfetcher: typeof fetch = fetchrather thanvi.spyOn(globalThis, 'fetch')— cleanup is simpler and the production call site stays unchanged. - Sentinels are
role='system'rows with structuredmetadata.kind(cap_hit,doom_loop). UI-only —buildMessagesPayloadstrips them viaisAnySentinelso the LLM never sees them. A new kind requires arms inMessageMetadatain BOTHapps/server/src/types/api.tsANDapps/web/src/api/types.ts, plus a render branch inapps/web/src/components/MessageBubble.tsx. - ReadableStream test stubs use
pull()(notstart()) so chunks are produced lazily —start()enqueues everything and callscontroller.close()before the consumer reads, so a subsequentreader.cancel()finds the stream already closed and thecancel()callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10). - Tool-name whitelists must derive from
ALL_TOOLSinservices/tools.ts, never hardcoded.services/agents.tsALL_TOOL_NAMEShad this drift class until v1.12 — same pattern applies to any future tool-aware code. - Agent registry lives at
data/AGENTS.md(global, bind-mounted at/data/AGENTS.md). No per-projectAGENTS.mdin this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. ThegetAgentsForProjectper-project override mechanism remains for other projects. - MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style
Content-Lengthheaders. Thecodecontext/shim.goframing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).