Files
boocode/CLAUDE.md
indifferentketchup f4a97808ad v1.14.0-outer-loop: explicit while loop replaces inference recursion
Converts the ad-hoc executeToolPhase → runAssistantTurn recursion into an
explicit while (stepNumber < effectiveCap) loop. A step is one stream-and-
tool-execute iteration; the loop terminates on non-tool finish, step-cap hit,
doom-loop, budget exhaustion, abort, or synthesis success.

MAX_STEPS = 200 hard ceiling (4x old effective limit from budget). Per-agent
steps: field in AGENTS.md frontmatter sets tighter caps (Refactorer: 5,
Architect: 20, others: unset = bounded only by MAX_STEPS). Resolution:
effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS).

executeToolPhase no longer recurses — returns ToolPhaseResult struct
(action: 'continue' | 'paused' | 'synthesis_done') so the caller decides
whether to continue or break. steps: 0 handled as "no tool calls allowed"
via runTextOnlyTurn (one text-only stream phase, tool calls ignored with
warn log).

Step-cap hits produce a sentinel summary (reuses cap_hit kind so
CapHitSentinel.tsx renders without frontend changes; text distinguishes
"Step limit reached" from "Tool budget exhausted"). Doom-loop check migrated
to top of loop body — same predicate, same threshold (3), break instead of
return.

step_start parts are in the schema CHECK but not emitted as message_parts —
writing before the stream phase creates a sequence-0 collision with
partsFromAssistantMessage. Structured log line emitted instead. Adversarial
review caught the collision pre-deploy.

332/332 server tests passing. No frontend changes. No schema changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:29:21 +00:00

30 KiB
Raw Permalink 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 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/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/ — Public surface re-exported via inference/index.ts; callers import from ./services/inference/index.js explicitly (NodeNext doesn't honor directory-index resolution). Layout: turn.ts (runAssistantTurn / runInference / createInferenceRunner; exports InferenceFrame, InferenceContext, TurnArgs, StreamResult, MAX_STEPS), stream-phase.ts (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), provider.ts (upstreamModel(baseURL, modelId) wrapping createOpenAICompatible against llama-swap), tool-phase.ts (executeToolPhase → returns ToolPhaseResult; 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; selectPruneTargets is the pure decision helper), types.ts (StreamPhaseState, DB_FLUSH_INTERVAL_MS). TurnArgs is the per-turn state envelope populated from loop locals each iteration; reset in runInference at user-message boundary. The outer loop in runAssistantTurn (v1.14.0) runs while (stepNumber < effectiveCap) where effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS=200). Per-agent steps: field in AGENTS.md frontmatter. steps: 0 means text-only (no tool execution). Step-cap hit writes a cap_hit sentinel so CapHitSentinel.tsx renders it.
  • AI SDK v6 streamCompletion adapter (v1.13.1-A; services/inference/stream-phase.ts). streamText is 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's fullStream iterator exits cleanly when abortSignal fires — no throw. Post-iteration if (signal?.aborted) throw <AbortError> is required; without it the row finalizes as complete instead of cancelled. Comment in stream-phase.ts pins this; don't refactor it away.
    • Usage lands only at stream end via await result.usage (inputTokens / outputTokens v6 names → mapped to promptTokens / completionTokens for 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 execute field. BooCode dispatches tools in tool-phase.ts, not the AI SDK loop. Only description + inputSchema: jsonSchema(parameters) — surfacing tool-call parts via fullStream and stopping is what we want.
    • includeUsage: true MUST be set on createOpenAICompatible in services/inference/provider.ts. The adapter defaults it false, omitting stream_options.include_usage from the request body; llama-swap then never emits the usage block and result.usage.inputTokens/outputTokens resolve to undefined. Latent regression from v1.13.1-A through v1.13.7 — every assistant row in that window has tokens_used/ctx_used NULL. Don't remove this flag during refactor.
    • Tool-call-only turns may emit a leading \n text-delta as the assistant content. MessageList.flatten's hasText and MessageBubble's hasContent both .trim() before the length check — otherwise whitespace-only content renders an empty bubble + ActionRow between every tool call (v1.13.7 fix). payload.ts:buildMessagesPayload also skips status='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.
  • AI SDK ModelMessage conversion (toModelMessages in stream-phase.ts). Tool messages need a toolName for ToolResultPart — BooCode's OpenAI-shape history doesn't carry it, so a forward-scan builds a tool_call_id → toolName map from prior assistant tool_calls. Tool outputs wrapped as { type: 'json' | 'text', value } matching the v6 ToolResultOutput union. Assistant messages with reasoning emit a ReasoningPart first in the content array (v1.13.1-C).
  • experimental_repairToolCall (v1.13.3) wired into streamText to 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_status frame shape (published via broker.publishUser) — status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error' (widened from working|idle|error in v1.12.1). Frontend useChatStatus derives idle_warm (<30s since idle) vs idle_cold. ChatThroughput renders inline beside StatusDot only when streaming or tool_running, fed by 500ms-throttled 'usage' WS frames (completion_tokens + ctx_used + ctx_max). The POST /api/chats/:id/discard_stale endpoint exists to mark a stuck-streaming row as failed when the frontend's 60s no-token-activity timer (ChatPane content-length watcher) gives up.
  • Boot-time stale-streaming sweep in apps/server/src/index.ts after applySchema(): any messages.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). Same setInterval runs sweepStaleStreaming (marks messages.status='streaming' older than 5 min as failed, publishes chat_status='idle' so the UI dot drops) and cleanupTruncations (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 through broker.publishFrame(sessionId, frame) or broker.publishUserFrame(user, frame) — both Zod-validate against WsFrameSchema (types/ws-frames.ts) and fail-closed (log + drop). ctx.publish / ctx.publishUser in inference + auto_name route through the index.ts adapter that calls publishFrame internally. The schema is duplicated byte-identical at apps/web/src/api/ws-frames.ts; a ws-frames.test.ts case enforces parity. Don't add new raw broker.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 via session.web_search_enabled (resolved with project.default_web_search_enabled fallback) and filtered out of the LLM's tool schema when false. v1.13.5 truncation: when a tool slice cuts content, services/truncate.ts stashes the full text on tmpfs at BOOCODE_TRUNCATION_DIR (default /tmp/boocode-truncations, 0o700) keyed by an opaque tr_<12 base32 chars> id, and the view_truncated_output(id) tool retrieves it. 5MB cap (matches view_file's MAX_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 (single summary=true assistant row per chat, supersedes itself on each compaction). Triggered when chats.needs_compaction is set after an inference turn exceeds usable(ctx_max) = floor(0.85 × ctx_max) (v1.13.9 opencode-pattern early trigger; was ctx_max - 20k pre-v1.13.9, which gave only 7.6% headroom at 262k and 0 budget for ≤20k contexts). ctx_max comes from model-context.getModelContext() which fetches ${LLAMA_SWAP_URL}/upstream/<model>/props — NOT from parsed.timings.n_ctx (the stream completion's timings doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out). First inferences after a boocode boot may have ctx_max=NULL if llama-swap hasn't loaded the model yet; negative cache TTL is 60s, recovers on next turn. v1.13.6: buildHeadPayload embeds reasoning_parts as a <reasoning>...</reasoning> prose prefix on the assistant content (OpenAI wire shape has no structured reasoning field; the summarizer reads text). Standalone tag when content is empty (tool-call-only turn). buildHeadPayload + OpenAiMessage exported for test access — keep them exported.
  • services/system-prompt.tsbuildSystemPrompt is the string-returning shim; buildSystemPromptWithFingerprint is the canonical impl returning {prompt, fingerprint, drift}. v1.13.8 instrumentation: SHA-256 of the assembled prefix is logged per buildMessagesPayload call (msg prefix-fingerprint, level=info); a Map<sessionId, lastHash> observer fires prefix-drift (level=warn) on hash change with a field-level changed_inputs diff. Smoke proved the prefix is byte-stable across turns in steady-state — the originally-planned system_prompt_cache DB table was dropped as redundant against the v1.12.0 input-layer mtime caches (BOOCHAT.md here + AGENTS.md global+per-project in agents.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 in ALL_TOOLS is read-only today, so no-agent mode shares the read-only-agent cap). Per-agent max_tool_calls from AGENTS.md frontmatter overrides.
  • messages_with_parts view (v1.13.1-B; schema.sql). Read sites that need tool_calls / tool_results / reasoning_parts SELECT from this view, NOT messages directly. v1.13.20 dropped the legacy messages.tool_calls / messages.tool_results JSON columns; the view now reads parts-only subselects. Writes target message_parts exclusively via insertParts (or via the helpers partsFromAssistantMessage / partsFromToolMessage). The Message wire type still carries tool_calls? / tool_results? because the view synthesizes them from parts — frontend reads are unchanged. Shapes: tool_calls jsonb[], tool_results jsonb single 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 returning id followed by SELECT from the view — RETURNING off the bare messages table 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.

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). 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. Tables: projects, sessions, chats, messages, settings, message_parts (v1.13.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). (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).

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 in openspec/changes/archived/. New batches follow the proposal+tasks shape; see openspec/README.md for 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.md is the per-tag release log, most-recent on top. When a new tag is created, add a ## <tag> — <YYYY-MM-DD> section with a 36 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 with v1.13.12-ws-schemas", "fixed in v1.13.5-stability-bundle"). No nested bullets — one paragraph.
  • 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.
  • DB-integration tests opt-in via env var: DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/server test. Host port is 5500 (mapped from boocode_db:5432); password is ${POSTGRES_PASSWORD} from .env (devpass), NOT the literal in .env's DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/... line. Pattern: describe.runIf(!!process.env.DATABASE_URL)(...) with a beforeAll that applies the schema via sql.unsafe(readFileSync(schemaPath)). Tests skip cleanly when var is unset. tool_cost_stats.test.ts is 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, not 0.0.0.0, so localhost:9500 doesn't work from the host shell. Same for booterm at :9501.
  • 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).
  • Adding a new WS frame type requires updating BOTH the server's InferenceFrame (loose type: union + optional fields in services/inference/turn.ts) AND the web WsFrame (strict discriminated union in apps/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.
  • 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.
  • New tools live in their own services/<name>.ts file (see web_search.ts, web_fetch.ts) — exports a pure executeFoo(input, ...deps) for direct test access plus a ToolDef wrapper that loadConfig()s its real dependencies. Register the ToolDef in tools.ts ALL_TOOLS (and READ_ONLY_TOOL_NAMES if applicable). Inject fetcher: typeof fetch = fetch rather than vi.spyOn(globalThis, 'fetch') — cleanup is simpler and the production call site stays unchanged.
  • Sentinels are role='system' rows with structured metadata.kind (cap_hit, doom_loop). UI-only — buildMessagesPayload strips them via isAnySentinel so the LLM never sees them. A new kind requires arms in MessageMetadata in BOTH apps/server/src/types/api.ts AND apps/web/src/api/types.ts, plus a render branch in apps/web/src/components/MessageBubble.tsx.
  • ReadableStream test stubs use pull() (not start()) so chunks are produced lazily — start() enqueues everything and calls controller.close() before the consumer reads, so a subsequent reader.cancel() finds the stream already closed and the cancel() 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_TOOLS in services/tools.ts, never hardcoded. services/agents.ts ALL_TOOL_NAMES had 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-project AGENTS.md in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The getAgentsForProject per-project override mechanism remains for other projects.
  • MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style Content-Length headers. The codecontext/shim.go framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).