Removed /opt/boocode/AGENTS.md (per-project override) — the project's
agents now resolve from the global /data/AGENTS.md only. Eliminates the
two-files-must-stay-in-sync footgun that surfaced during B.3
verification.
Fix: agents.ts ALL_TOOL_NAMES was a hardcoded 9-item whitelist that
silently filtered any unknown tool name from agent.tools arrays. This
caused web_search/web_fetch (v1.11.8) and the 8 codecontext tools to be
dropped at parse time. Replaced with ALL_TOOLS.map(t => t.name) for
single source of truth. Pre-existing exposure was dormant since no
builtin agent listed web_search; surfaced by adding codecontext.
Adds two new tools registered through the existing ALL_TOOLS registry:
- web_search hits SearXNG's JSON API (Fathom, internal Tailscale URL,
no auth) and returns top results
- web_fetch retrieves a URL's text content, gated by isPublicUrl
(url_guard.ts) which blocks loopback / RFC1918 / Tailscale CGNAT /
link-local / .local / .internal / non-http schemes
Both tools are opt-in via the existing session.web_search_enabled flag
(plumbed in v1.9, activated here). Default off. UI labels updated to
"Enable web search and fetch" / "Web search and fetch" since fetch joins
the same store. Counts against the v1.8.2 per-turn budget; covered by
the v1.11.6 doom-loop guard.
Native Node 20 fetch — no new prod dep. HTML stripping via regex (script
and style content elided wholesale). 5MB body cap, 15s fetch timeout,
8000-char default output, 32000-char cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports continue.dev's DEFAULT_SECURITY_IGNORE_FILETYPES + ignored-dir lists
into apps/server/src/services/secret_guard.ts plus a small BooCode
additions block (id_rsa*, *credentials*, .netrc, *.kdbx). Tiny glob-to-
regex matcher; no new prod dep.
view_file hard-refuses via SecretBlockedError. list_dir / grep /
find_files filter their results and surface a pathguard_note string
field with the hidden count — never list the offending paths back.
Named secret_guard.ts (not safety/pathGuard.ts) to avoid collision with
the existing path_guard.ts which already exports a pathGuard() function.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was picking the alphabetically-first agent from AGENTS.md ("Code
Reviewer") which felt presumptuous. New sessions now create with
agent_id=null; user picks from the AgentPicker if they want one.
Removes resolveDefaultAgent helper + the getAgentsForProject import
since this was the only caller. The project SELECT no longer needs
the path column either.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ContextBar relocated from a dedicated row above MessageList to inline with
the agent-picker row, filling the space to the right of the picker + plus
button. Always-visible (zero-state when no assistant message has run yet)
via chat.model_context_limit, which GET /api/sessions/:id/chats now
populates from a single getModelContext lookup per session.
ChatContextPopover above the input is removed entirely along with its
useChatContextStats hook (no remaining callers). Color tiers and the
auto-compaction threshold tooltip unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- llama-server does not emit n_ctx in timings (confirmed empirically);
dead code at inference.ts:479 and compaction.ts:300 never fired
- New model-context.ts: cached fetch of /upstream/<model>/props
with positive-cache (no TTL) and 60s negative-cache
- Wired into all 4 ctx_max write sites: 3 in inference.ts
(executeToolPhase, finalizeCompletion, runCapHitSummary) and
1 in compaction.ts (summary row INSERT)
- AbortController 3s timeout, lenient parsing with sensible defaults
- 12 new vitest cases for the cache module (59 total)
- 7 historical assistant rows backfilled manually (see notes)
Some local models (qwen3-coder via llama-swap) emit tool calls as inline XML
inside delta.content rather than structured delta.tool_calls. streamCompletion
now buffers delta.content, extracts complete <tool_call>...</tool_call> blocks
via parseXmlToolCall, and pushes synthetic entries (id prefix xml_call_) into
the existing toolCallsBuffer. Native JSON path unchanged — both coexist.
Partial openers are held back so a tool tag never leaks to the chat mid-tag.
Unclosed XML at end-of-stream is flushed as plain content (no silent drops).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /data/skills mount (host: /opt/skills)
- skill_find, skill_use, skill_resource added to default read-only
tool set; opt-in for agents with explicit tools: whitelist
- AGENTS.md builtin agents drop explicit tools: arrays to inherit
the new default (now includes skill tools)
- POST /api/chats/:id/skill_invoke for slash-command flow
- 19 SKILL.md files seeded at /opt/skills/ across 6 source groups
Adds a singleton, ephemeral 'settings' pane kind to the workspace.
Opened via a new bottom-pinned button in ProjectSidebar (emits an
open_settings_pane event when a session is mounted; navigates to
/settings otherwise). Pane has three sections — Session, Project,
Theme — and a maximize toggle that hides sibling pane columns via
display:none on desktop only. Settings panes don't count toward
MAX_PANES and are filtered out of the localStorage persistence layer
so reload always restores a clean workspace.
Schema (additive):
- projects.default_system_prompt TEXT NOT NULL DEFAULT ''
- projects.default_web_search_enabled BOOLEAN NOT NULL DEFAULT false
- sessions.web_search_enabled BOOLEAN (nullable; null = inherit)
Inference resolves user_prompt = session.system_prompt.trim() ||
project.default_system_prompt.trim() — empty/whitespace at either
layer means "no override". Keeps the columns NOT NULL and matches
the existing inherit semantics.
Server routes:
- GET /api/projects/:id (new; settings pane refetches on
project_updated)
- PATCH /api/projects/:id accepts default_system_prompt,
default_web_search_enabled
- PATCH /api/sessions/:id accepts web_search_enabled (tri-state)
- POST /api/projects/:id/sessions/archive-all + GET
/api/projects/:id/sessions/open-count
- POST /api/sessions/:id/chats/archive-all + GET
/api/sessions/:id/chats/open-count
- PATCH /api/sessions/:id now broadcasts session_updated on every
successful PATCH (was rename-only). Lets SettingsPane open in
another tab pick up edits without a refetch.
Bulk-archive publishes one session_archived / chat_archived frame
per affected id so useSidebar's existing reducer cases handle them
incrementally — no new frame type, no payload widening.
ModelPicker refactored: shared ModelList inside a responsive shell.
Desktop = labeled trigger + DropdownMenu, mobile = icon-only Cpu
button + BottomSheet. Header in Session.tsx drops the pill wrap on
mobile since the new trigger is the visual.
ChatInput gains an icon-only '+' DropdownMenu next to AgentPicker
when sessionId + webSearchEnabled props are provided. One item for
now — Web search — with a checkmark reflecting the stored value
(true), not the effective one. Click PATCHes the override; to
restore inherit-from-project the user opens SettingsPane.
ThemePicker lifted out of pages/Settings.tsx into a reusable
component. The standalone /settings route is now a thin wrapper
that mounts <ThemePicker /> with a Back button on top
(navigate(-1) with fallback to '/'); the SettingsPane Theme tab
renders the same picker bare.
Project section delete-flow removed (button + confirm dialog +
handler). Replaced with "Archive all sessions" using the same
two-step count → confirm → fire pattern as "Archive all chats" in
the Session section. api.projects.remove() stays in the client
because useProjects.ts still uses it.
Hand-rolled Switch primitive in SettingsPane (no shadcn switch in
the project; spec said no new deps). Section nav is plain buttons
(no shadcn Tabs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 18 preset themes (16 dual-mode + 2 light-only) selectable from
a new /settings route. Persists per-user via the existing key-value
settings table — no schema refactor. Default on first load is
obsidian dark.
Storage: two new seeded keys (theme_id, theme_mode) inserted
idempotently from schema.sql. PATCH /api/settings tightens validation
with a discriminated branch — theme_id must be one of the 18
whitelisted ids, theme_mode ∈ {dark,light,system}, anything else
rejects 400. Other keys pass through the loose record schema.
CSS layer: 18 files in apps/web/src/styles/themes/, each declaring
.theme-<id> (light) and .theme-<id>.dark (dark) — except ivory and
chalk which are light-only. Anchor-to-token mapping per spec §3.
--destructive stays red across all themes. --radius unchanged at
0.625rem (spec parenthetical was about "not per-theme", not a
specific value swap).
Frontend: lib/theme.ts owns THEMES, applyTheme(), setTheme(), and
useTheme() — module-singleton with optimistic PATCH + revert on
failure (mirrors useChatStatus / useSidebar pattern). Settings.tsx
renders a 3-col (md) / 2-col (mobile) grid of shadcn Card swatches
with a Dark/Light/System radio group on top. App.tsx mounts
useTheme() at AppShell top and wires the /settings route.
index.html ships a pre-React FOUC script that reads localStorage
'boocode.theme' and stamps the className on <html> before any
paint. Stripped two pre-existing dark-mode lock-ins (AppShell's
hardcoded 'dark' className and body's neutral-950/100 tailwind
utilities) that would have fought theme tokens.
Light-only + dark request → falls back to obsidian dark in three
places: lib/theme.ts effectiveThemeId(), the FOUC script, and the
picker's "Light only" badge. No inline message; matches spec §8
decision 1.
shadcn primitives card and radio-group installed via shadcn CLI
(no hand-rolling). card.tsx and radio-group.tsx are the only ui/
additions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old hardcoded MAX_TOOL_LOOP_DEPTH=15 replaced by per-agent
max_tool_calls (1-100, AGENTS.md frontmatter) with defaults: 30 for
read-only-only agents, 10 for agents that include any non-read-only
tool, 15 for raw chat. When the loop hits cap, fire one final summary
call with tools disabled, stream the wrap-up into the in-flight
assistant message, then insert a system sentinel with
metadata.kind='cap_hit'. The sentinel renders an amber bubble with a
Continue button (latest sentinel only) that POSTs to a new
/api/chats/:id/continue route to extend. Hard ceiling: 3 cap-hits per
chat (2 continues max) — third sentinel reports can_continue=false.
Error frames carry a machine-readable reason code alongside human
error text. Failed messages persist the reason via
metadata.kind='error' so the bubble renders specifics on reload (WS
error frame is one-shot).
Tool call UI rewired: ToolCallLine renders inline (↳ name args
spinner/check/✗, expand-on-tap for args+result); ToolCallGroup
collapses 3+ consecutive same-tool runs into a compact card.
MessageList owns a three-pass pre-render (flatten + fold tool
results onto matching runs by id + group same-tool runs + number
sentinels). MessageBubble drops tool rendering and adds the
sentinel / error-reason branches. ToolCallCard deleted.
Roadmap follow-up logged: add explicit max_tool_calls: 30 to the 6
agents in /data/AGENTS.md and /opt/boocode/AGENTS.md post-ship for
discoverability (defaults handle behavior identically).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builtins move out of code into /data/AGENTS.md (always-on, mounted ro
into the container); per-project AGENTS.md is now an optional override.
agents.ts merges global + project entries with project-wins-by-name and
caches per-source mtimes (60s TTL). Parser switches to per-block
try/catch and returns AgentsResponse { agents, errors[] } so one
malformed block no longer fails the file. AgentPicker shows a
non-blocking amber chip listing skipped blocks and only fires a gray
toast when zero agents loaded.
WS reconnect UX (useUserEvents + useSessionStream) now silent on the
first disconnect; createWsReconnectToast escalates to gray after 3
failures or 15 s, then to red with a Retry Now action after 60 s.
useSessionStream also gained the exponential-backoff reconnect it was
missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mobile header is now two rows. Row 1: hamburger | project · branch
indicator (live via GET /api/projects/:id/git, 30s poll) | ModelPicker |
FolderTree. Row 2: pane-switcher pill (hand-rolled BottomSheet) +
NewPaneMenu. Chat-within-pane navigation hidden on mobile; users switch
panes via the sheet. Cross-tab status sync via chat_status frames
published from inference.ts at working/idle/error transitions; StatusDot
component renders amber-pulse/green/red/gray on each pane row and on
desktop ChatTabBar tabs. Level 1 git awareness exposes a read-only
git_status tool to the model, backed by services/git_meta.ts (execFile
+ 2s timeout + 30s cache). Workspace.tsx now receives panes/chats hooks
as props (hoisted into Session.tsx) so the header pill shares state
with the pane grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six builtin defaults (Code Reviewer, Debugger, Refactorer, Architect,
Security Auditor, Prompt Builder) with no model field so session.model
wins. Project root AGENTS.md parsed on demand with mtime cache; when
present, only its agents are shown. sessions.agent_id resolves per turn
into effective system prompt, temperature, and a tool whitelist applied
in inference. AgentPicker mounts in the ChatInput toolbar; SettingsDrawer
agent surface deferred to Batch 7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a chat is auto-named, also rename the parent session if it is
still on its default 'New session' label. UPDATE is gated by an
atomic WHERE clause so user renames and prior propagations are not
clobbered. Publishes session_renamed via broker.publishUser; useSidebar
already listens.
Closes the gap where sessions auto-created from the sidebar would
stay 'New session' forever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4 publisher fired whenever the PATCH body included `name`,
including no-op rename calls (PATCH { name } where name ===
currentName). Read the prior name with a fast SELECT before the
UPDATE and only publish session_renamed when the post-update name
actually differs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Allows assistant turns up to 15 tool calls in a single chain before
the loop-depth guard trips. Real chats commonly need 6-10 tool calls
(grep -> view_file -> view_file -> grep -> view_file -> answer); the
old cap of 5 was firing on legitimate investigation patterns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The scope check at routes/projects.ts:56 short-circuited when
real === whitelistReal, allowing the whitelist directory itself to
resolve as a valid project root. Dropped the `real !== whitelistReal`
half of the && so the predicate becomes the strict prefix check.
Flipped the unit test from a "BEHAVIOR GAP" assertion (documenting
the bug) to a strict-rejection assertion. 23/23 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dockerfile: install git + openssh-client in runtime image; pre-populate
/root/.ssh/known_hosts with the Tailscale ssh-keyscan for
100.114.205.53:2222 (Gitea SSH). Without these, the bootstrap push
step from inside the container fails with "command not found" or
host-key prompts.
- docker-compose.yml: mount ./secrets/boocode_gitea as
/root/.ssh/id_ed25519:ro so the container can authenticate to Gitea
over SSH for the initial push.
- .gitignore: add secrets/ so the keypair never lands in the repo.
- project_bootstrap.ts: rewrite the Gitea-returned ssh_url's hostname
from git.indifferentketchup.com to 100.114.205.53 before adding it
as origin, so the push hits the Tailscale interface that the
known_hosts entry covers.
- CreateProjectModal.tsx: preview label now reads "Folder:
/opt/projects/<name>" to match the new BOOTSTRAP_ROOT (was /opt/).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits the previous /opt:/opt:rw bind into two mounts to narrow the
writable scope of the container:
- /opt:/opt:ro — read-only mount for legacy/existing project
add-existing flow. resolveProjectPath still uses
PROJECT_ROOT_WHITELIST (/opt by default) so existing projects under
/opt/<name> (analytics, boolab, boocode itself) continue to resolve
and serve their file-tree via the read-only tools.
- /opt/projects:/opt/projects:rw — writable mount targeted at the
create-new-project bootstrap path.
Picked Option B from the spec (simpler than two scan roots):
PROJECT_ROOT_WHITELIST stays /opt, new BOOTSTRAP_ROOT env var defaults
to /opt/projects and is used by project_bootstrap.ts as the mkdir
target. Bootstrap path-escape check now compares against
BOOTSTRAP_ROOT.
Prereq: host must `mkdir -p /opt/projects` before next container
restart. Documented in CLAUDE.md and .env.example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swallowed-error logging (audit Feature 3):
- file_index.ts:36-37 (git mtime probes): comment — best-effort, project
may not be a git repo.
- useUserEvents.ts:44 / 53 (ws.close on error / unmount): comments —
best-effort, socket may already be closing.
- RightRail.tsx:38 (localStorage write): comment — best-effort, quota or
private mode.
- App.tsx:21 (api.sessions.get for RightRail projectId): replaced silent
catch with console.warn.
- Session.tsx:38, 41 (session fetch + project list for breadcrumb):
replaced silent catches with console.warn.
H1: ProjectSidebar.tsx:189 — dropped the local sessionEvents.emit
({type:'session_renamed'}) after PATCH. Server publishes via
broker.publishUser since v1.4; useUserEvents forwards.
H2: useSessionStream.ts session_renamed case removed (dead — no
server code path publishes session_renamed on the per-session WS
channel; only user channel via broker.publishUser). Also dropped the
session_renamed variant from WsFrame (in apps/web/src/api/types.ts)
to keep the discriminated-union switch exhaustive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds vitest 3.x (pinned to ^3 because vitest 4 requires Vite 6, while the
web app pins Vite 5). Tests live under src/**/__tests__/**.
Three target functions:
- sanitizeFolderName (project_bootstrap.ts): 8 cases covering happy path,
path-traversal stripping, empty-after-sanitize, control chars, truncation
at 64, null bytes, leading/trailing dot/slash stripping.
- resolveProjectPath (projects.ts): 7 cases including symlink-escape via
realpath, outside-whitelist rejection, nonexistent path, AND a flagged
BEHAVIOR GAP: passing the whitelist path itself currently returns success
rather than erroring out (function early-exits the scope check when
real === whitelistReal). Test asserts current behavior with explicit
comment flagging the spec violation — function NOT silently patched.
Function made exportable for testing (single keyword change).
- buildMessagesPayload (inference.ts): 8 cases for compact-marker logic
(no marker, marker present, multiple compacts, tool-message position).
tsconfig.json excludes __tests__ + *.test.ts from emit so dist/ stays clean.
pnpm -C apps/server test => 23 passed in ~340ms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fork: POST /api/chats/:id/fork creates a new chat in the same session,
copies messages up to target (status=complete) with row-offset
clock_timestamp() for stable ordering. Client emits open_chat_in_active_pane
event; Workspace opens it in the active pane. No maybeAutoNameChat on forks.
- Delete: DELETE /api/chats/:id/messages/:message_id with 409 if the chat is
currently streaming. Cascading-forward delete (created_at >= target).
MessageBubble Trash button + confirm Dialog.
- Header: Projects -> Project -> Session breadcrumb, model badge pill,
inline session rename, active file path via new useActivePane() hook.
Server now publishes session_renamed on PATCH /api/sessions/:id;
client-side dup emit removed from Session.tsx.
- Housekeeping: NOW() -> clock_timestamp() in schema.sql defaults, dead
PaneTab.tsx and panes/PaneShell.tsx removed, session_panes backfill
INSERT removed (CREATE TABLE retained), Tailnet trust comment near
app.listen().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feature 1 — Tab close menu (pure local pane state, no API):
- ChatTabBar context menu: Rename / sep / Close / Close others / Close to right / Close all
- Workspace bulk-tab primitives: closeOtherTabs, closeTabsToRight, closeAllTabs (manipulate panes[].chatIds, no fetch)
- Drop in-bar Delete; landing card's name-typed Delete is the canonical destructive path
Feature 2 — Chat archive + delete:
- chats.status vocabulary aligned with projects ('open' | 'archived'); DROP old inline CHECK, UPDATE 'closed' → 'archived', ADD new named chats_status_chk
- POST /api/chats/:id/archive (204) + POST /api/chats/:id/unarchive (200) + GET /api/sessions/:id/chats?status=archived; DELETE publishes chat_deleted; PATCH simplified to name-only
- 3 new WS frames: chat_archived, chat_unarchived, chat_deleted (renamed from chat_closed)
- Same dedup discipline: server-only publish, no local sessionEvents.emit in client
- SessionLandingPage: right-click ContextMenu (Open / Rename / Archive / sep / Delete-destructive), inline rename, archive confirm dialog, delete dialog with name-typed Input gated until typed text === chat.name, Archived chats collapsible section with Restore
- Card-level Archive + Delete icon buttons reusing the same dialog state setters; stopPropagation on both so card click still opens the chat; archived cards keep only Restore
UX — chat content width cap:
- ChatPane content (MessageList, queue chips, stop button, ChatInput) wrapped in inner max-w-[1000px] mx-auto w-full so messages center; outer border-t / scroll containers stay full-width so pane chrome and backgrounds remain edge-to-edge
- No new deps, no media queries (narrow viewports collapse to width naturally)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server:
- projects.status + projects.gitea_remote (additive) with CHECK ('open','archived')
- GET /api/projects?status=archived; PATCH /api/projects/:id (rename);
POST /api/projects/:id/archive | unarchive; POST /api/projects/create
- POST /api/projects ON CONFLICT (path) DO UPDATE SET status='open': re-add
of archived path restores existing row (preserves id + FKs); already-open
path returns 409. Detected-repos picker now excludes only status='open'.
- New gitea.ts (createGiteaRepo + GiteaRepoExistsError) and
project_bootstrap.ts (sanitize name, mkdir under PROJECT_ROOT_WHITELIST,
git init -b main + first commit with -c user.name/email per-command, optional
Gitea repo create + remote add + push; all via execFile, no shell).
- 3 new user-stream frames: project_archived, project_unarchived, project_updated.
- sidebar.ts now selects path + gitea_remote and filters status='open'.
- Gitea env added to config.ts (GITEA_BASE_URL, GITEA_USER, GITEA_TOKEN,
GITEA_SSH_HOST).
- docker-compose.yml /opt mount flipped to rw so create-project can mkdir.
- auto_name.ts gate relaxed from `!== 1` to `< 1` (fires on every turn while
chat name is empty, not only the first).
Web:
- ProjectSidebar: project rows use proper Radix ContextMenu; items Rename /
Archive / Open in Gitea. Inline rename, archive confirm dialog.
Removed obsolete handleRemove + DropdownMenu hack.
- Home: Add-existing + Create-new buttons; collapsible Archived Projects
section with Restore.
- New CreateProjectModal: name + live folder preview, commit msg, Private/
Public radio, create-Gitea-remote checkbox, toast on success/warnings.
- New projectUrls.ts giteaUrlFor() — uses gitea_remote when present,
falls back to convention URL.
- 3 new event types in sessionEvents.ts with idempotent useSidebar handlers.
- SidebarProject extended with path + gitea_remote so Open-in-Gitea can
resolve without a separate fetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fastify global empty-JSON-body parser fixes archive/unarchive/stop 400s
- Removed redundant local sessionEvents.emit at all 5+2 sites with server-side WS publishers; added dedupe guards in useSidebar/Workspace/Project handlers
- Sidebar session right-click adds Delete (destructive) with confirm Dialog
- Session.tsx navigates away on session_deleted/session_archived for the active session
- SessionLandingPage chat rows show message_count, effective_context_tokens, last_message_preview via LATERAL joins on GET /api/sessions/:id/chats
- Workspace.tsx pane drag-to-reorder using native HTML5 events (no new deps)
- CompactCard: Copy toast, Send-to-chat with target chat name, empty-state in share popover, Re-run button
- auto_name.ts: filter count gate and assistant-fetch by content <> '' so tool-call assistant rows don't trip the once-and-only-once guard
- Adds CLAUDE.md and apps/web/src/lib/format.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Session 1:N Chat data model with backfill. Workspace switches to client-side
multi-tab pane management. Right-rail file browser with float-over viewer and
click-drag line selection replaces FileBrowserPane. Adds /compact streaming
summarizer (respects compact markers in context builder), force-send (cancels
in-flight, persists partial as 'cancelled', awaits cancellation completion via
deferred Promise + 5s timeout), message queue, stop generation, chat
auto-rename, session archive/unarchive with Closed Sessions section on repo
landing page. CHECK constraints on sessions.status, messages.role,
messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES /
MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the
api.panes.* client block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sessions created before Batch 3 have no rows in session_panes, so the
Workspace renders "No panes" on first open. Idempotent INSERT inserts
a default chat pane at position 0 for any session without one. NOT
EXISTS guard makes the statement a no-op after the first run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop unused Stats type import and its no-op suppression expression
- Comment getProjectFiles concurrent-miss race (benign, accepted)
- Comment findFiles deliberate post-limit counting (differs from grep)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- file_ops.MAX_FIND_RESULTS: 1000 -> 200 to match existing tool cap and
preserve LLM behavior
- tools.find_files now delegates to file_ops.findFiles (parallels how
grep already delegates); drops ~50 LOC of duplicated path resolution
and rg subprocess
- Drop unused basename import in file_ops
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces six non-null assertions on req.user with the requireUser helper
from auth.ts, which throws a descriptive error if the auth hook didn't
populate req.user. Adds an inline comment in /api/ws/user explaining the
manual auth check is defensive (the global hook already enforces auth).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- broker.subscribeUser/publishUser via separate user topics map
- /api/ws/user WS route subscribes to the user channel
- projects/sessions POST/DELETE handlers emit lifecycle frames
- inference 3 terminal-state sites emit session_updated with RETURNING
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A concurrent DELETE between the count read and the transaction could allow
an invalid position value to slip in. Mirror the POST fix by validating
count + bounds inside the transaction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move sentinel from -1 to -100 (outside the negate range) so moves from
position 0 no longer collide with negated row at -1
- Pull count check + position validation inside sql.begin in POST so two
concurrent inserts can't both pass the max-5 guard
- Wrap movePane + state UPDATE in a single transaction in PATCH so partial
failures roll back consistently
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /api/sessions/:id/panes (GET, POST), /api/panes/:id (PATCH, DELETE)
with transactional position-shift logic (negate-and-restore pattern to
avoid UNIQUE collisions). Max 5 panes per session enforced.
Sessions.POST now creates the session and a default Chat pane at position
0 atomically via sql.begin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restructure Pane as a tagged union over kind so checking kind narrows state
- Rename session_panes_session_idx -> idx_session_panes_session for naming
consistency with idx_sessions_project, idx_messages_session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the session_panes table, Pane/PaneState/PaneCreate/PaneUpdate types,
UserStreamFrame discriminated union, and extends SidebarSession with
project_id (also added to the sidebar SELECT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema (idempotent):
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp();
The column already exists from v1 (DEFAULT NOW()); ALTER is a no-op kept for
self-documentation. Explicit clock_timestamp() bumps now run wherever the
column actually matters — see services/inference.ts and routes/sessions.ts.
Backend updated_at maintenance:
- services/inference.ts: after each terminal status UPDATE on the assistant
message (failure / tool-call complete / clean complete), also bump
sessions.updated_at = clock_timestamp() so the parent session jumps to
the top of recency ordering on every assistant turn.
- routes/sessions.ts PATCH: NOW() → clock_timestamp() for consistency.
New endpoint GET /api/sidebar (routes/sidebar.ts):
{ projects: [{ id, name, recent_sessions[≤6], total_sessions }] }
One outer query for projects ordered added_at DESC; per-project Promise.all
over (recent_sessions LIMIT 6 ORDER BY updated_at DESC) and COUNT(*)::int.
Outer Promise.all parallelizes across projects. Two queries per project; the
composite idx_sessions_project(project_id, updated_at DESC) serves the inner
query. Auth via the global Remote-User hook. types/api.ts gains
SidebarSession / SidebarProject / SidebarResponse; index.ts wires the route.
Frontend foundations:
- api/types.ts mirrors the three sidebar interfaces.
- api/client.ts: api.sidebar.get() → Promise<SidebarResponse>.
- hooks/sessionEvents.ts: five-variant union — added project_created,
project_deleted, session_created, session_deleted. session_renamed
unchanged from Batch 1. Bus internals untouched (still a dumb
Set<Listener>, no validation).
New hooks/useSidebar.ts (module-singleton):
- Module-scope sharedData/sharedError/sharedLoading/initialized/fetchInFlight/
subscribers; a single sessionEvents.subscribe at module-top-level mutates
sharedData via an exhaustive switch over the five events. load() dedupes
parallel calls via fetchInFlight. Hook is a thin subscription layer: any
number of mount points share state and the very first one triggers the
single GET /api/sidebar. Subsequent mounts read cached state synchronously
(no skeleton flash). Public shape: { data, error, loading, retry }.
- Lift to module-scope was driven by the "ONE sidebar request on mount"
spec promise — both ProjectSidebar AND Home consume the hook now, and
they share the singleton.
Frontend UI:
- components/ProjectSidebar.tsx (rewrite, 234 lines): per-project chevron +
folder + name; chevron toggles expand, name navigates /project/:id.
Expanded → ≤5 sessions with MessageSquare + name + muted relTime()
timestamp. "View all (N)" link when total_sessions > 5, routing to
/project/:id. Active session row uses bg-sidebar-accent. Active project
always renders expanded (URL-derived: direct /project/:id or scan of
recent_sessions for /session/:id). Expanded ids persisted in
localStorage['boocode.sidebar.expanded'] with try/catch on both read and
write. Loading shows 4 muted-pulse skeleton blocks; empty + error +
retry button; error toast guarded by ref so it fires once per distinct
message and resets on recovery. Remove path calls api.projects.remove
directly + explicit project_deleted emit (replaced the prior
useProjects() dependency which fired a redundant /api/projects on
mount, violating the one-fetch promise).
- components/AddProjectModal.tsx: captures returned Project and emits
project_created before onAdded() / onOpenChange(false).
- pages/Project.tsx: emits session_created after create(); trash button is
now async with try/catch — emits session_deleted on success,
toast.error on failure.
- pages/Home.tsx: switched from useProjects to useSidebar so loading /
fires exactly one /api/sidebar, with no parallel /api/projects.
- pages/Session.tsx: manual inline rename now emits session_renamed on
the success path so the sidebar updates live without a refresh (also
fixes the regression made visible by Batch 2 — the sidebar caches
session names where the project page used to re-fetch on every visit).
useProjects.ts retains a project_deleted emit inside remove for any future
caller; no live consumer uses it (ProjectSidebar calls api.projects.remove
directly). Acknowledged dead code, to be removed in the next cleanup pass
along with three remaining NOW() → clock_timestamp() consistency flips at
routes/messages.ts:70, routes/messages.ts:127, and services/auto_name.ts:144.
Cross-tab parity for session_created/session_deleted/project_created/
project_deleted is deferred — those events are tab-local in Batch 2 per
spec. session_renamed continues to propagate cross-tab via the existing
WS frame from Batch 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four features land together on this branch:
1. Markdown rendering — assistant messages go through react-markdown +
remark-gfm. Fenced code blocks render via existing CodeBlock (with copy
button); inline `code` is styled inline. User messages stay plain text.
No raw HTML (no rehype-raw).
2. Per-message Copy + Regenerate. New endpoint
POST /api/sessions/:id/messages/:message_id/regenerate validates the
target (404/400/409), atomically deletes the target plus any later
messages in the session, inserts a fresh streaming assistant row, and
enqueues a normal inference run. The DELETE bound uses a SQL subquery
(`created_at >= (SELECT created_at FROM messages WHERE id = $1)`)
instead of a JS round-trip so postgres TIMESTAMPTZ µs precision is
preserved — otherwise sub-ms clock_timestamp() differences between the
user row and the assistant row collapsed to the same JS Date, pulling
the triggering user message into the >= bound. New `messages_deleted`
WS frame so already-connected clients prune the stale tail without
needing a full snapshot resend.
3. tok/s + ctx counter. Five new nullable message columns: tokens_used,
ctx_used, ctx_max, started_at, finished_at. started_at is set right
before the OpenAI call in services/inference.ts (not in the route, not
in the frame handler); finished_at + tokens_used + ctx_used + ctx_max
are committed in the same UPDATE that flips status to 'complete'. The
inference request now opts into stream_options.include_usage so the
final chunk carries usage; defensive parsing also picks up timings.n_ctx
when llama.cpp emits it (currently absent for our llama-swap models, so
ctx_max stays NULL and the UI just shows `<used> ctx`). message_complete
frame extended with tokens_used / ctx_used / ctx_max / started_at /
finished_at / model. Frontend StatsLine in MessageBubble computes tok/s
client-side from the timestamps and renders muted mono text below the
body of completed assistant messages.
4. AI chat naming after the first turn. Backend services/auto_name.ts
runs via setImmediate after the top-level inference resolves; it
checks that there is exactly one completed assistant message and that
the session has not been user-renamed (`name IS NULL OR name = '' OR
name = 'New session'`), then fires a single non-streaming chat
completion with the spec prompt. Qwen3 chat templates emit chain-of-
thought into reasoning_content and burn the entire max_tokens budget
without producing visible output, so the request includes
`chat_template_kwargs: { enable_thinking: false }` and max_tokens=30.
Title is trimmed, quote-stripped, "Title:" prefix dropped, and
truncated to 60 chars before a guarded UPDATE on sessions.name. New
`session_renamed` WS frame propagates to the open session view
directly and to the project's session list via a tiny module-scope
event bus (apps/web/src/hooks/sessionEvents.ts) — kept dumb: one event
type, two methods, no library.
Cleanups: dropped the now-unused splitCodeBlocks export from CodeBlock.tsx
(react-markdown supersedes it), and added a long-form NOTE in auto_name.ts
documenting the enable_thinking + max_tokens pattern for any future Qwen-
family non-streaming utility calls (planned: fork-message, agent-routing,
web-search summarization).
Schema bootstrap remains idempotent (ADD COLUMN IF NOT EXISTS). Auth,
broker, clock_timestamp() conventions, and zod validation all unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>