Adds to CLAUDE.md: stale boocoder-restart symptom after build (new routes 404 / old routes 200); boocode container build: . deploys the working tree, web dev≠prod until container rebuild; PATCH provider-config replaces override wholesale (send full override) + coder-providers.json is live config (don't commit drift); external agents one-shot with no ctx tracking + OpenCode-as-server is unshipped v2.6; ui/ primitive inventory + button-role=switch / Dialog fallbacks; mobile Dialog scroll containment. Also backfills uncommitted doc bullets for the v2.5.7–v2.5.11 coder work. CHANGELOG v2.5.14 entry. Docs only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
45 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Cursor agents: start with AGENTS.md (navigation) and docs/ARCHITECTURE.md (diagram). This file is the deep engineering reference.
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/— Public surface re-exported viainference/index.ts; callers import from./services/inference/index.jsexplicitly (NodeNext doesn't honor directory-index resolution). Layout:turn.ts(runAssistantTurn / runInference / createInferenceRunner; exportsInferenceFrame,InferenceContext,TurnArgs,StreamResult,MAX_STEPS),stream-phase.ts(streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase),provider.ts(upstreamModel(baseURL, modelId)wrappingcreateOpenAICompatibleagainst llama-swap),tool-phase.ts(executeToolPhase → returnsToolPhaseResult; no longer recurses into runAssistantTurn — v1.14.0 converted the recursion to an explicit while loop in turn.ts),sentinel-summaries.ts(runCapHitSummary + runDoomLoopSummary + runStepCapSummary + their sentinel inserters),error-handler.ts(handleAbortOrError, finalizeCompletion),payload.ts(buildMessagesPayload, loadContext, maybeFlagForCompaction,OpenAiMessage),sentinels.ts(detectDoomLoop,DOOM_LOOP_THRESHOLD, sentinel predicates),budget.ts(resolveToolBudget),xml-parser.ts(qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls),parts.ts(parts-table write helpers:partsFromAssistantMessage,partsFromToolMessage,insertParts— v1.13.20 made parts the sole source of truth),prune.ts(v1.13.4 two-tier compaction;selectPruneTargetsis the pure decision helper),types.ts(StreamPhaseState,DB_FLUSH_INTERVAL_MS).TurnArgsis the per-turn state envelope populated from loop locals each iteration; reset inrunInferenceat user-message boundary. The outer loop inrunAssistantTurn(v1.14.0) runswhile (stepNumber < effectiveCap)whereeffectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS=200). Per-agentsteps:field in AGENTS.md frontmatter.steps: 0means text-only (no tool execution). Step-cap hit writes acap_hitsentinel soCapHitSentinel.tsxrenders it.- AI SDK v6 streamCompletion adapter (v1.13.1-A;
services/inference/stream-phase.ts).streamTextis the underlying call; the BooCode layer above (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Five gotchas the LSP/test suite won't catch:- Abort signals are swallowed.
streamText'sfullStreamiterator exits cleanly whenabortSignalfires — no throw. Post-iterationif (signal?.aborted) throw <AbortError>is required; without it the row finalizes ascompleteinstead ofcancelled. Comment in stream-phase.ts pins this; don't refactor it away. - Usage lands only at stream end via
await result.usage(inputTokens/outputTokensv6 names → mapped topromptTokens/completionTokensfor the existing onUsage callback). Mid-stream live tok/s is gone vs v1.12.2; ChatThroughput shows a single value at stream end. - Tools have NO
executefield. BooCode dispatches tools in tool-phase.ts, not the AI SDK loop. Onlydescription+inputSchema: jsonSchema(parameters)— surfacing tool-call parts viafullStreamand stopping is what we want. includeUsage: trueMUST be set oncreateOpenAICompatibleinservices/inference/provider.ts. The adapter defaults it false, omittingstream_options.include_usagefrom the request body; llama-swap then never emits the usage block andresult.usage.inputTokens/outputTokensresolve toundefined. Latent regression from v1.13.1-A through v1.13.7 — every assistant row in that window hastokens_used/ctx_usedNULL. Don't remove this flag during refactor.- Tool-call-only turns may emit a leading
\ntext-delta as the assistant content.MessageList.flatten'shasTextandMessageBubble'shasContentboth.trim()before the length check — otherwise whitespace-only content renders an empty bubble + ActionRow between every tool call (v1.13.7 fix).payload.ts:buildMessagesPayloadalso skipsstatus='failed'AND complete-but-empty (no content, no tool_calls) assistant rows to avoid "Cannot have 2 or more assistant messages at the end of the list" upstream rejections after cap-hit + Continue.
- Abort signals are swallowed.
- AI SDK ModelMessage conversion (
toModelMessagesin stream-phase.ts). Tool messages need atoolNameforToolResultPart— BooCode's OpenAI-shape history doesn't carry it, so a forward-scan builds atool_call_id → toolNamemap from prior assistanttool_calls. Tool outputs wrapped as{ type: 'json' | 'text', value }matching the v6ToolResultOutputunion. Assistant messages with reasoning emit aReasoningPartfirst in the content array (v1.13.1-C). experimental_repairToolCall(v1.13.3) wired intostreamTextto keep the stream alive when qwen3.6 emits malformed tool args. Pass-through implementation — logs the bad call and returns it unmodified;executeToolPhase's existing zod-reject error path routes it to the model on the next turn.chat_statusframe shape (published viabroker.publishUser) —status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'(widened fromworking|idle|errorin v1.12.1). FrontenduseChatStatusderivesidle_warm(<30s since idle) vsidle_cold.ChatThroughputrenders inline besideStatusDotonly when streaming or tool_running, fed by 500ms-throttled'usage'WS frames (completion_tokens+ctx_used+ctx_max). ThePOST /api/chats/:id/discard_staleendpoint exists to mark a stuck-streaming row asfailedwhen the frontend's 60s no-token-activity timer (ChatPanecontent-length watcher) gives up.- Boot-time stale-streaming sweep in
apps/server/src/index.tsafterapplySchema(): anymessages.status='streaming'older than 5 minutes flips to'failed'. Logs only on non-zero count. Recovers from container restart while inference was mid-stream (v1.12.1). - Periodic 60s sweeper in
apps/server/src/index.ts(v1.13.3 + v1.13.5). SamesetIntervalrunssweepStaleStreaming(marksmessages.status='streaming'older than 5 min asfailed, publisheschat_status='idle'so the UI dot drops) andcleanupTruncations(TTL + orphan reap of tmpfs truncation files).app.addHook('onClose')clears the timer. No-op when nothing to reap. 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. v1.13.11: every WS publish goes throughbroker.publishFrame(sessionId, frame)orbroker.publishUserFrame(user, frame)— both Zod-validate againstWsFrameSchema(types/ws-frames.ts) and fail-closed (log + drop).ctx.publish/ctx.publishUserin inference + auto_name route through the index.ts adapter that calls publishFrame internally. The schema is duplicated byte-identical atapps/web/src/api/ws-frames.ts; aws-frames.test.tscase enforces parity. Don't add new rawbroker.publish()/publishUser()calls.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. v1.13.5 truncation: when a tool slice cuts content,services/truncate.tsstashes the full text on tmpfs atBOOCODE_TRUNCATION_DIR(default/tmp/boocode-truncations, 0o700) keyed by an opaquetr_<12 base32 chars>id, and theview_truncated_output(id)tool retrieves it. 5MB cap (matchesview_file'sMAX_FILE_BYTES), 7-day TTL, reaped by the periodic sweeper. Tmpfs path means container restart loses retrieval — acceptable, the model usually has moved on.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) = floor(0.85 × ctx_max)(v1.13.9 opencode-pattern early trigger; wasctx_max - 20kpre-v1.13.9, which gave only 7.6% headroom at 262k and 0 budget for ≤20k contexts).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). First inferences after a boocode boot may havectx_max=NULLif llama-swap hasn't loaded the model yet; negative cache TTL is 60s, recovers on next turn. v1.13.6:buildHeadPayloadembedsreasoning_partsas a<reasoning>...</reasoning>prose prefix on the assistantcontent(OpenAI wire shape has no structured reasoning field; the summarizer reads text). Standalone tag when content is empty (tool-call-only turn).buildHeadPayload+OpenAiMessageexported for test access — keep them exported.services/system-prompt.ts—buildSystemPromptis the string-returning shim;buildSystemPromptWithFingerprintis the canonical impl returning{prompt, fingerprint, drift}. v1.13.8 instrumentation: SHA-256 of the assembled prefix is logged perbuildMessagesPayloadcall (msgprefix-fingerprint, level=info); aMap<sessionId, lastHash>observer firesprefix-drift(level=warn) on hash change with a field-levelchanged_inputsdiff. Smoke proved the prefix is byte-stable across turns in steady-state — the originally-plannedsystem_prompt_cacheDB table was dropped as redundant against the v1.12.0 input-layer mtime caches (BOOCHAT.md here + AGENTS.md global+per-project inagents.ts:safeStat).services/inference/budget.ts— tool-call budgets:BUDGET_READ_ONLY = 30,BUDGET_NON_READ_ONLY = 10(forward-looking; no write tools yet),BUDGET_NO_AGENT = 30(v1.13.7; was 15 — every tool inALL_TOOLSis read-only today, so no-agent mode shares the read-only-agent cap). Per-agentmax_tool_callsfrom AGENTS.md frontmatter overrides.messages_with_partsview (v1.13.1-B;schema.sql). Read sites that needtool_calls/tool_results/reasoning_partsSELECT from this view, NOTmessagesdirectly. v1.13.20 dropped the legacymessages.tool_calls/messages.tool_resultsJSON columns; the view now reads parts-only subselects. Writes targetmessage_partsexclusively viainsertParts(or via the helperspartsFromAssistantMessage/partsFromToolMessage). TheMessagewire type still carriestool_calls?/tool_results?because the view synthesizes them from parts — frontend reads are unchanged. Shapes:tool_calls jsonb[],tool_results jsonbsingle object,reasoning_parts jsonb[]of{text}. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returningidfollowed by SELECT from the view — RETURNING off the baremessagestable no longer carries the tool fields.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.apps/coder/src/services/provider-registry.ts(BooCoder, NOT apps/server) — Static registry of provider metadata (label, transport, model source).PROVIDERSarray,PROVIDERS_BY_NAMEmap. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).apps/coder/src/services/agent-probe.ts(BooCoder) — Startup probe using directexec()(not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from~/.qwen/settings.json. Claude models are static from the registry. Results persisted toavailable_agentstable.apps/coder/src/routes/providers.ts(BooCoder) —GET /api/providersreturns installed providers with models. Transport field reflects actual capability (checkssupports_acpfrom DB, not just registry preference). The apps/server side of this flow is the "Provider picker dispatch" bullet below.- Provider picker dispatch: when
provider !== 'boocode', the message route creates atasksrow (withsession_idset) instead of callinginference.enqueue. The dispatcher picks it up and dispatches via ACP or PTY using the agent'sinstall_path.
Route registration: all routes registered in index.ts via register*Routes(app, sql, ...) functions. Routes are in routes/*.ts.
BooCoder (apps/coder/src/)
- Write-capable coding agent. Runs as a systemd service on the host (
boocoder.service), NOT in Docker. Fastify server at port 9502, connects to postgres at127.0.0.1:5500. - Workspace dependency on
@boocode/server: importscreateInferenceRunner,createBroker,ALL_TOOLS,appendMcpToolsfrom the server's compileddist/. apps/server'spackage.jsonhas anexportsmap withtypesconditions for NodeNext resolution. apps/server must build FIRST. - Build + deploy:
pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder. Env file atapps/coder/.env.host. Service file at/etc/systemd/system/boocoder.service. - After
pnpm -C apps/coder buildthe hostboocoder.servicekeeps running the OLD process untilsudo systemctl restart boocoder— a stale process shows new routes 404 with{error:'not found'}while old routes still 200 (the/apinot-found handler returns that shape). Restart, don't re-debug. - Agent dispatch spawns binaries directly using
install_pathfromavailable_agents— nospawn('sh', ['-c', ...])(fails under systemd). Follows Paseo's pattern:spawn(fullBinaryPath, argsArray, { cwd }). - systemd hardening: only
NoNewPrivileges=trueis safe.ProtectSystem,ProtectHome,PrivateTmpall break agent dispatch (agents need full filesystem access to read configs, write to worktrees). apps/server/tsconfig.jsonhasdeclaration: trueso.d.tsfiles exist for workspace consumers.- Write tools (
edit_file,create_file,delete_file,apply_pending,rewind) queue inpending_changestable. Nothing hits disk untilapply_pendingis called.write_guard.tsvalidates paths (resolve + prefix-check, no realpath since files may not exist for creates). - Frontend: NOT a separate SPA. BooCoder is a
'coder'pane type within BooChat's SPA (apps/web/).CoderPane.tsxinapps/web/src/components/panes/. API requests go through/api/coder/*proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (BOOCODER_URLenv var, defaulthttp://100.114.205.53:9502). WS connects directly to:9502. apps/coder/web/is a STANDALONE fallback SPA served at:9502directly. The PRIMARY BooCoder frontend is theCoderPanein BooChat's SPA (apps/web/src/components/panes/CoderPane.tsx), accessible via the "Coder" pane in the workspace atcode.indifferentketchup.com. Both exist; the pane is what Sam uses.- Provider snapshot lifecycle (
apps/coder/src/services/):provider-config.ts(Zod config, never-throws on bad input) →provider-config-registry.ts(buildResolvedRegistry, singleton) →provider-snapshot.ts(two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stalePROVIDER_PROBE_TTL_MS24h / dbEmpty; cached). Verify live:curl http://100.114.205.53:9502/api/providers/snapshot— returns providers + models + commands, the exact shapeAgentComposerBarrenders. PATCH /api/providers/configreplaces a provider id's override object wholesale (per-id shallow merge) — to flip one field send{...existing, enabled}, or a custom ACP entry'scommand/labelis wiped and it drops out of the resolved registry.data/coder-providers.jsonis the live config (tracked via a.gitignoreexception, bind-mounted); UI toggles mutate it on disk → working-tree drift, don't commit it as a code change.- External agents dispatch one-shot (
opencode acp/goose acp/qwen --acp) and report no context-window/token usage; only nativeboocode(llama-swap engine) tracks ctx. OpenCode-as-HTTP-server (warm process +@opencode-ai/sdk, the source of a real context bar) is the planned, unshippedopenspec/changes/v2-6-persistent-agent-sessionsbatch; Paseo's per-provider native clients (design §12) were deliberately not ported.
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). v1.12.1 moved pane state from per-device localStorage to sessions.workspace_panes jsonb for cross-device sync. PATCH /api/sessions/:id/workspace persists; session_workspace_updated user-channel frame broadcasts to every device watching the session. useWorkspacePanes debounces saves 300ms and dedups echoes by JSON string. Legacy localStorage key boocode.workspace.panes.<sessionId> is read once on first hydrate (one-time seed-and-delete migration when server is empty but localStorage has data); no longer written. The deprecated session_panes table was dropped. validatePanes(validChatIds) prunes panes referencing chat IDs that no longer exist (called by useSessionChats after the chat list fetch lands). Each chat lives in at most one pane; tab strip is per-pane and tracks chatIds[] + activeChatIdx. Tab reorder via native HTML5 drag events.
Database
PostgreSQL 16. Database name: boochat (renamed from boocode in v2.0.0-alpha; Docker service name stays boocode_db). Tables: projects, sessions, chats, messages, settings, message_parts (v1.13.0), pending_changes (v2.0.0), tasks (v2.0.0), available_agents (v2.0.0). Views: messages_with_parts (v1.13.1-B parts-merge read path), tool_cost_stats (v1.13.10 per-tool 100-call rolling window), human_inbox (v2.0.0 — tasks WHERE state IN blocked/failed). (session_panes was dropped in v1.12.1; workspace pane state lives in sessions.workspace_panes jsonb.) 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. The older anonymous messages_status_check (without 'cancelled') and messages_role_check (without 'system') were dropped in v1.12.1; only the _chk variants remain.
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.
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), BOOCODE_TOOLS (core | standard | all, default all; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), MCP_CONFIG_PATH (optional; default /data/mcp.json — JSON config for MCP servers matching opencode's mcpServers shape; file missing = no MCP).
BooCoder at port 9502: curl http://100.114.205.53:9502/api/health. Runs as boocoder.service on the host (not Docker). Deploy: 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 when unset. Set to a small model on llama-swap (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 required on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No--yoloflag — non-interactive mode (-p) runs autonomously without approval prompts. ACP bridge is HTTP daemon (not stdio); use PTY dispatch. - Arena (v2.0.5):
POST /api/arena {project_id, input, contestants: [{agent?, model?}]}dispatches the same task to N models/agents in parallel. Each contestant gets its own task + worktree.GET /api/arena/:idfor results.POST /api/arena/:id/select/:task_idpicks winner.
Workflow
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Per-batch docs live under
openspec/changes/<slug>/{proposal,tasks,design}.md. Already-shipped batches are snapshots inopenspec/changes/archived/. New batches follow the proposal+tasks shape; seeopenspec/README.mdfor the convention. - Tag naming:
vMAJOR.MINOR.PATCH-slug(e.g.v1.13.13-ws-publish). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (-a/-b), no pseudo-ranges (v1.11.x), no slug-only sub-versions sharing a number (v1.13.15-tools+-openspec+-agentlint— split into sequential patches instead). CHANGELOG.mdis the per-tag release log, most-recent on top. When a new tag is created, add a## <tag> — <YYYY-MM-DD>section with a 3–6 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs withv1.13.12-ws-schemas", "fixed inv1.13.5-stability-bundle"). No nested bullets — one paragraph.- 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). - The
boocodecontainer isbuild: .— it builds web+server from the working tree, so uncommitted changes deploy. Web edits are live on the Vite dev server (HMR) but NOT on production (:9500/ code.indifferentketchup.com) untildocker compose up --build -d boocode. - 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. - DB-integration tests opt-in via env var:
DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test. Host port is 5500 (mapped fromboocode_db:5432); password is${POSTGRES_PASSWORD}from.env(devpass), NOT the literal in.env'sDATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...line.psqlis not on the host PATH — for an interactive query usedocker exec boocode_db psql -U boocode -d boochat -c "...". Pattern:describe.runIf(!!process.env.DATABASE_URL)(...)with abeforeAllthat applies the schema viasql.unsafe(readFileSync(schemaPath)). Tests skip cleanly when var is unset.tool_cost_stats.test.tsis the reference. - Host-side smoke endpoint:
curl http://100.114.205.53:9500/api/.... The boocode container's port mapping binds to the Tailscale IP, not0.0.0.0, solocalhost:9500doesn'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. Faster than bisecting source. Watch for=== null/!== nullon optional fields fed anas unknown ascast — those bypass tsc. - 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.codecontextignoreat project root is honored when--respect-gitignoreis passed (enabled in the shim). - codecontext fork at
/opt/forks/codecontext/— separate git repo (branchboocode-ts), pushed via the same boocode_gitea SSH key toindifferentketchup/codecontext. Build:go build ./.... Test:go test ./.... Docker rebuild requires staging the fork source first:tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext --exclude=.git --exclude=bin .thendocker compose build --no-cache codecontext. The Dockerfile COPYsfork.tar.gzinto the builder stage (Gitea is behind Authelia, no HTTP clone).fork.tar.gzis gitignored. - Go binary:
/snap/go/current/bin/go(not on PATH by default). Useexport PATH=$PATH:/snap/go/current/binor full path for Go commands. 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). - Adding a new WS frame type requires updating BOTH the server's
InferenceFrame(loosetype:union + optional fields inservices/inference/turn.ts) AND the webWsFrame(strict discriminated union inapps/web/src/api/types.ts). Server publish is permissive; the frontend type is the wire-format gate. The'usage'frame added in v1.12.2 needed both sides; missing the web side silently drops the frame at JSON-parse. - shadcn primitives live in
components/ui/. Don't modify them unless adding a new primitive. ui/primitives present: button, card, context-menu, dialog, dropdown-menu, input, label, radio-group, sonner, textarea. No switch/sheet/drawer/badge/checkbox — use a<button role="switch" aria-checked>toggle (a hand-rolledSwitchalready lives inSettingsPane.tsx) and a Dialog-based panel for "drawers".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. - A scrollable list inside a Dialog on mobile: cap
DialogContent(max-h-[85vh]+grid-rows-[auto_minmax(0,1fr)_auto]) and make the list the single scroll region withoverscroll-contain— otherwise touch-scroll drags the whole fixed modal / chains to the page. - 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). - Workspace dependency pattern (
apps/coder→@boocode/server): the consuming package adds"@boocode/server": "workspace:*"inpackage.json. The provider'spackage.jsonneedsexportswithtypes+defaultconditions per subpath:"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }. Without thetypescondition, NodeNext resolution can't find.d.tsfiles and tsc fails with "Cannot find module" in the consumer. - JSONB columns: use
sql.json(value as never)— NOT${JSON.stringify(value)}::jsonbwhich double-serializes (stores a JSON string instead of a JSON object/array). Pattern established inparts.ts,settings.ts. payload.ts:loadContextSELECT: must include everySessionfield that downstream code reads. The tool phase readssession.allowed_read_paths; if the SELECT omits it, cross-repo read grants silently fail. TheSessionTypeScript type doesn't catch this becausesql<Session[]>doesn't enforce column coverage.- Sidecar routing (
services/inference/provider.ts):upstreamModel(config, modelId, agent)routes toLLAMA_SIDECAR_URLwhen agent hasllama_extra_args, otherwiseLLAMA_SWAP_URL.resolveRoute(agent)returns{route: 'swap'|'sidecar', flags}. Sidecar provider created fresh per call (not cached) becauseX-Agent-Flagsheader varies per agent. Boot-time guard inindex.tsrefuses to start if any agent hasllama_extra_argsbutLLAMA_SIDECAR_URLis unset. - Secret guard safe patterns (
services/secret_guard.ts):.env.example,.env.sample,.env.template,.env.defaultsare allowlisted viaSAFE_PATTERNSset. Do NOT add.env.production/.env.development/.env.test— those can hold real secrets. - CoderPane uses ChatInput (
components/panes/CoderPane.tsx): shares the sameChatInputcomponent as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane'ssendOneMessageis the send callback; queued messages drain viauseEffectwhensendinggoes false. - Adding a new
SessionEventtype: add the interface, add it to theSessionEventunion, add acaseinuseSidebar.tsapplyEventswitch (no-opreturn previs fine), and subscribe in any hook that needs it (e.g.useSessionStreamforrefetch_messages). - BooCoder provider registry (
apps/coder/src/services/provider-registry.ts): static list of provider defs (boocode, opencode, goose, claude, qwen).PROBED_AGENT_NAMESderives from it. Adding/removing providers means editing this file, not the frontend. - AgentComposerBar filters
e.installed: provider snapshot entries withinstalled:false(loading/unavailable) are dropped from the dropdown.getProviderSnapshotmust await the full build — returning synchronousloadingplaceholders makes every provider vanish (the v2.5.7 "no providers showing up" regression); surfacing loading states needs a client poll. - Coder↔web provider-type parity (
apps/coder/src/services/provider-types.ts↔apps/web/src/api/types.ts): enforced by runtimeprovider-types-parity.test.ts(compile-time cross-import is blocked by TS6307 on web's composite tsconfig). Mirror of the ws-frames parity pattern — edit both copies together or the test fails. - ACP command discovery is async:
acp-probe.tsmust poll afternewSessionforavailable_commands_update(commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) instead discover from disk viaclaude-command-discovery.ts(~/.claude/commands+enabledPluginsskills/+commands/, bare names, deduped).AgentCommand.kindtags'command'vs'skill';CoderPane'sslashGroupssplits them into icon'd groups.SlashCommandPicker'sgroups?prop is opt-in — BooChat passes flatitems(unchanged). - Pane header architecture (mobile vs desktop): Desktop coder pane header (BooCode label + [+] [×]) lives in
Workspace.tsxgated byisCoder && !isMobile. Mobile coder controls (● ×) live inSession.tsxheader row next toMobileTabSwitcher/NewPaneMenu.AgentComposerBar(provider/mode/model pickers) renders insideCoderPane.tsxon both. The ● status dot is passed viaconnectedprop from CoderPane to AgentComposerBar. - MessageBubble shared between BooChat and BooCoder (
components/MessageBubble.tsx): accepts optionalactions?: MessageActionscallbacks (onRegenerate, onResend, onFork, onDelete) andhideActions?: ('fork'|'delete'|'openInPane')[]. Defaults use BooChat API; CoderPane overrides viaCoderMessageListprops.CoderTextBubblewas removed.CoderMessageListpassesCoderMessageWire as unknown as Message— the coder wire shape lacksmetadata/kind/summary, so those fields areundefined(notnull) on coder messages. Null-guards on anyMessagefield MUST use loose!= null, not strict!== null(undefined !== nullistrue→.kindthrows → blank-screen crash). Theas unknown ascast hides this from tsc; build + typecheck pass while runtime crashes. - llama-sidecar (
/opt/forks/llama-sidecar/): Go daemon for per-agent llama-server process pool. Cross-compile:GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar. Gitea:indifferentketchup/llama-sidecar. Windows child process gotchas: usecontext.Background()for child lifetime (not request ctx),os.Open(os.DevNull)for stdin,os.Pipe()for stdout with drain goroutine,DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUPcreation flags. SSH to sam-desktop:ssh samki@100.101.41.16; useschtasksfor persistent process spawning (SSHstart /Bdoesn't survive session close).