Commit Graph

105 Commits

Author SHA1 Message Date
a08d809b73 v1.13.3: cleanup bundle — statement timeout + alpha ordering + stuck-row sweeper + repairToolCall
Four independent items, all owed from prior dispatches.

- statement_timeout at the database level via:
    ALTER DATABASE boocode SET statement_timeout = '30s';
  Applied operationally; documented as a comment at the top of schema.sql
  (ALTER DATABASE can't run inside a DO block, so it's not idempotent
  inside applySchema). Re-apply after a volume reset.

- Tool registry alpha-sorted at module load. llama.cpp's prompt cache
  hits on byte-identical prefixes; any reordering of the tool list near
  the top of the system prompt would invalidate every cached turn.
  Single-source sort at the ALL_TOOLS export so toolJsonSchemas() and
  TOOLS_BY_NAME inherit the order automatically. New tools.test.ts
  asserts the invariant; total tests 173 (was 172).

- Periodic in-process stuck-row sweeper. Runs every 60s, marks
  'streaming' rows older than 5 minutes as 'failed', and publishes
  chat_status='idle' on the user channel so the UI dot drops without a
  refresh. Closes the mid-session crash UX gap; the v1.12.1 boot sweep
  only fires once at startup, so sessions used to stay stuck until next
  reboot. setInterval cleaned up via app.addHook('onClose'). Mirrors
  handleAbortOrError's publish pattern.

- experimental_repairToolCall wired through AI SDK v6 streamText. Pass-
  through implementation: log + return the original toolCall so the
  stream keeps going. executeToolPhase's existing error paths (unknown
  tool name → 'unknown tool: X' result; zod-reject → 'tool X rejected
  — field: required') already surface bad calls to the model; the value
  here is preventing the AI SDK from THROWING on parse errors and
  killing the whole stream. Owed since v1.13.1-A.

Smoke verified:
- statement_timeout = '30s' confirmed via SHOW.
- Tool path normal flow intact (list_dir prompt → tool_call → result
  → final assistant). No malformed tool calls in the test run; repair
  log will surface them when qwen3.6 actually emits one.
- Alpha order verified at runtime via the dist bundle: match: true.
- Sweeper logic not traffic-tested (no stuck rows to find), but the
  SQL UPDATE + broker.publishUser pattern is identical to handleAbort
  and the boot sweep — synthesis-only verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:46:03 +00:00
ac1a71f583 v1.13.1-C: port ask_user_input correlation to parts + wire reasoning_parts end-to-end
Pass 1 — ask_user_input correlation port (messages.ts:478, :549):

- The two correlation queries that backed the elicitation flow used to scan
  messages.tool_calls and messages.tool_results JSON columns directly. They
  now JOIN message_parts on payload->>'id' (for the caller assistant) and
  payload->>'tool_call_id' (for the pending tool row). Semantics preserved:
  ORDER BY m.created_at DESC LIMIT 1 still picks the latest issuance, the
  already-answered 409 guard now reads payload.output, and the UPDATE +
  parts replace inside sql.begin is unchanged from v1.13.0.
- Pre-v1.13.0 history has no parts rows and is unreachable to this lookup
  path (404). Acceptable per dispatch decision — no pending elicitation
  from before v1.13.0 will still be open. JSON-column fallback can land as
  a hotfix if it ever surfaces.

Pass 2 — reasoning_parts wired end-to-end:

- types.ts/StreamResult gains `reasoning: string`. stream-phase.ts accumulates
  reasoning-delta text per stream (replacing the v1.13.1-A counter-only
  diagnostic) and returns it on the result.
- parts.ts/partsFromAssistantMessage gains an optional `reasoning` param.
  When present it emits a kind='reasoning' part at sequence 0, ahead of
  the text and tool_call parts.
- error-handler.ts/finalizeCompletion and tool-phase.ts/executeToolPhase
  both thread result.reasoning into the dual-write call so reasoning-channel
  models (qwen3.6) get persistent reasoning rows.
- payload.ts: loadContext SELECT pulls reasoning_parts from the v1.13.1-B
  view; OpenAiMessage gains an optional `reasoning` field; buildMessagesPayload
  collapses reasoning_parts into a single string per assistant message.
- stream-phase.ts/toModelMessages converts assistant messages with reasoning
  into an AI SDK ModelMessage content array starting with a ReasoningPart,
  matching the @ai-sdk/provider-utils AssistantContent union. Reasoning
  models can now replay prior reasoning context across tool-call boundaries.
- types/api.ts and apps/web/src/api/types.ts Message interface gain
  reasoning_parts (optional, nullable). Frontend doesn't render this yet —
  field reserved for a v1.14 UI surface.

Tests: 2 new in parts.test.ts cover reasoning-at-sequence-0 with and
without text content. 172 tests pass (170 prior + 2 new).

Smoke verified against the live container:
- A reasoning-prompt ("walk through 17 × 23 step by step") produced one
  message with kind='reasoning' (361 chars) at sequence 0 and kind='text'
  (429 chars) at sequence 1. Adapter log confirmed reasoning capture.
- The new correlation SQL was validated against existing tool_call /
  tool_result parts: returns the expected message_id + payload shape with
  pending state correctly identified via payload.output IS NULL.
- ask_user_input end-to-end through the UI is Sam's smoke — the Prompt
  Builder agent does not always trigger ask_user_input for these prompts,
  so synthetic verification via SQL substituted for traffic-driven cover.

Annotation: the v1.13.1-A abort-throw site in stream-phase.ts got a
one-liner comment ("AI SDK v6 fullStream returns normally on abort; check
signal explicitly.") to prevent a future refactor removing it.

v1.13.2 drops the dual-write + the JSON columns + collapses the view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:34:10 +00:00
13c3aa5b4e v1.13.1-B: read-path flip from tool_calls/tool_results JSON columns to message_parts
- schema.sql: new messages_with_parts view. tool_calls aggregates parts
  with kind='tool_call' as a jsonb array of {id, name, args}; tool_results
  picks the single sequence=0 part with kind='tool_result' as a jsonb
  {tool_call_id, output, truncated, error?}. COALESCE against the legacy
  jsonb columns means pre-v1.13.0 history (no parts rows) still reads
  correctly via the fallback, and fresh inserts (where parts dual-write
  follows the row INSERT) hit the legacy columns until the parts land.
- reasoning_parts column added to the view but not selected by any caller
  yet — v1.13.1-C extends the Message type and pulls it into the model
  payload alongside the type extension.
- Read sites switched to FROM messages_with_parts:
  - routes/chats.ts:427 (chat history GET)
  - routes/messages.ts:95 (session history GET)
  - routes/ws.ts:27 (WS snapshot on session connect, resume path)
  - services/inference/payload.ts (loadContext for model assembly)
  - services/compaction.ts (compaction's payload assembly)
- chats.ts:394 (discard_stale UPDATE RETURNING) unchanged — UPDATEs target
  messages directly and the returned shape is for a freshly-modified row
  where the legacy column is dual-written and correct.
- messages.ts:478/549 (ask_user_input correlation) intentionally not
  migrated — those query a different shape, ported in v1.13.1-C.
- Writes still target `messages` directly; the view is read-only.

Smoke verified against the live container:
- Equivalence: 5/5 messages with both legacy column and parts row return
  identical tool_calls jsonb between FROM messages and FROM messages_with_parts.
- Perf: EXPLAIN ANALYZE on the 42-message stress chat returns in ~1ms
  (50ms threshold). Bitmap Index Scan on message_parts_msg_seq_idx
  carries the parts lookups.
- API contract: GET /api/chats/:id/messages returns identical
  {id, name, args} tool_calls and {tool_call_id, output, truncated, error}
  tool_results shapes to frontend consumers — no UI changes needed.
- Inference path: sent a view_file prompt; assistant turn 1 emitted the
  tool_call, tool message captured the result, follow-up assistant turn
  read the result back via loadContext (now view-backed) and answered
  correctly. End-to-end loop intact.

v1.13.2 drops the dual-write + the JSON columns + simplifies the view
to just SELECT FROM message_parts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:22:47 +00:00
c2c4f78a26 v1.13.1-A: install AI SDK v6 + swap streamText into stream-phase.ts adapter
- Add ai@^6 and @ai-sdk/openai-compatible@^2 to apps/server.
- New services/inference/provider.ts: createOpenAICompatible against
  llama-swap (baseURL threaded from config.LLAMA_SWAP_URL, cached per
  baseURL). No apiKey — Authelia + Tailscale gate llama-swap, not keys.
- streamCompletion rewritten as an adapter over streamText. AI SDK
  fullStream parts (text-delta, tool-call, finish, error) map back to
  the legacy {content?, tool_calls?, finishReason} StreamResult shape
  that executeStreamPhase already consumes. No layer above
  streamCompletion changes.
- toModelMessages converts BooCode's OpenAI-shaped history to AI SDK
  ModelMessage[]; tool messages need toolName which we look up by
  scanning earlier assistant tool_calls for the matching id.
- buildAiTools wraps BooCode's JSON-schema tool defs via
  tool({ inputSchema: jsonSchema(parameters) }) with NO execute —
  BooCode dispatches tools in tool-phase.ts, not the AI SDK loop.
- XML fallback parser preserved as-is — qwen3.6 still emits XML tool
  calls in text content that the structured tool-call layer misses.
- reasoning-delta parts dropped with a debug-level counter — captured
  properly in v1.13.1-C.
- Abort path: streamText({ abortSignal }) wires ctx.signal through, but
  AI SDK v6 swallows the abort (fullStream iterator exits cleanly
  rather than throwing). Post-iteration `if (signal?.aborted) throw` so
  handleAbortOrError owns the row and writes status='cancelled'. Caught
  by smoke D; would have shipped as status='complete' on stop otherwise.
- Usage frame reads result.usage (inputTokens / outputTokens v6 names)
  AFTER stream drain. Single trailing publish through the existing 500ms
  throttle. Known regression: ChatThroughput's live mid-stream tick
  (v1.12.2) is gone — it now shows a single value at stream end.
  TODO(v1.13.1-followup): interpolate outputTokens during streaming
  via a delta-cadence counter (e.g. part.text.length/4 token proxy)
  and publish every 500ms; reconcile against result.usage at finish.
- Write-path dual-write from v1.13.0 unaffected.

Read path stays on JSON columns. v1.13.1-B flips reads to message_parts.

Smoke verified end-to-end against running container:
- A. Plain text: status='complete', 1 text part.
- B. Single tool prompt → multi-tool chain (4 calls): every assistant
     with tool_calls has 2 parts (text+tool_call), every tool row has
     1 part (tool_result).
- C. Multi-step covered by B's chain.
- D. Stop mid-stream: status='cancelled' written via handleAbortOrError
     after the post-iteration abort throw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:17:56 +00:00
1cb6eee24c v1.13.0: message_parts table + dual-write at every tool_calls/tool_results site
Adds a granular message_parts table (one row per text/tool_call/tool_result
chunk) without changing any read path. Old messages.content / tool_calls /
tool_results columns remain authoritative for v1.13.0; this dispatch is
write-only mirroring so the AI SDK migration in v1.13.1 can flip read
authority without a backfill window.

Schema:
  CREATE TABLE message_parts (id, message_id FK ON DELETE CASCADE,
    sequence int, kind text CHECK (text|tool_call|tool_result|reasoning|step_start),
    payload jsonb, created_at, UNIQUE (message_id, sequence))

New module services/inference/parts.ts with two pure derive helpers
(partsFromAssistantMessage, partsFromToolMessage) and insertParts that
fan-outs a multi-row INSERT via postgres-js.

Wired dual-write at every site that writes tool_calls or tool_results:
- tool-phase.ts: assistant finalize UPDATE, executed-tool UPDATE,
  ask_user_input sentinel UPDATE
- messages.ts answer flow: DELETE pending tool_result part + INSERT
  answered one inside the existing sql.begin
- skills.ts: synthetic assistant + tool INSERTs both inside existing tx
- chats.ts fork: CTE clones parts via ROW_NUMBER pairing (source→dest
  message id mapping in one statement, no N+1)
- error-handler.ts finalizeCompletion: text part for plain text-only
  assistant turns

Deviation: tool-phase.ts finalize UPDATEs and finalizeCompletion text-part
write are not wrapped in fresh sql.begin transactions. Safe in v1.13.0
because JSON columns are authoritative for reads. v1.13.1 must wrap these
sites before flipping read authority — TODO comments added at each
unwrapped site referencing v1.13.1.

Tests: 8 new unit tests for the derive helpers in
services/__tests__/parts.test.ts. Existing 162 tests untouched. 170 total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 05:46:29 +00:00
9ef00c0268 v1.12.4: complete inference.ts split into services/inference/
- sentinel-summaries.ts: runCapHitSummary, insertCapHitSentinel,
  runDoomLoopSummary, insertDoomLoopSentinel
- inference.ts → inference/turn.ts: residue is runAssistantTurn,
  runInference, createInferenceRunner orchestration only
- inference/index.ts: re-export shim preserves the public surface
  (createInferenceRunner, runInference, runAssistantTurn,
  detectDoomLoop, DOOM_LOOP_THRESHOLD, buildMessagesPayload, plus
  type-side InferenceContext/InferenceFrame/StreamResult/TurnArgs/
  FramePublisher)
- src/index.ts + auto_name.ts + the two vitest test files updated to
  import from ./services/inference/index.js explicitly (NodeNext ESM
  doesn't honor directory-index resolution)

Final tally: 11 files under services/inference/, the largest being
sentinel-summaries.ts at 523 LoC (two near-clone summary paths kept
side-by-side until a third sentinel justifies factoring out a shared
runWrapUpSummary). turn.ts is now 326 LoC, the next-largest is
stream-phase.ts at 380. Public import surface unchanged.

tool-phase.ts → turn.ts back-edge for runAssistantTurn remains
(cycle is safe; resolved at call time).

Prepares the file structure for v1.13 AI SDK migration — streamText
swap targets stream-phase.ts only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:36:35 +00:00
c87df6981a v1.12.4-rc3: extract stream-phase + tool-phase from inference.ts
- stream-phase.ts: streamCompletion, executeStreamPhase (plus sseLines,
  StreamOptions, ChatCompletionDelta/Chunk as private helpers)
- tool-phase.ts: executeToolPhase + private executeToolCall
- types.ts: shared StreamPhaseState + DB_FLUSH_INTERVAL_MS so the
  summary functions still in inference.ts can reference them without
  pulling from a phase file

Cycle: executeToolPhase recurses into runAssistantTurn, which stays in
inference.ts. Resolved by direct value back-edge — tool-phase.ts does
`import { runAssistantTurn } from '../inference.js'` and runAssistantTurn
is now exported. Safe because the dereference happens inside an async
function body, after both modules have fully evaluated. No
callback-through-args fallback needed.

inference.ts shrinks from ~1401 to ~828 LoC. Final Dispatch D moves the
sentinel summaries out and renames the residue to inference/turn.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:28:23 +00:00
8fa7b7fce9 v1.12.4-rc2: extract payload + error-handler from inference.ts
- payload.ts: buildMessagesPayload (re-exported), loadContext,
  maybeFlagForCompaction
- error-handler.ts: handleAbortOrError, finalizeCompletion

Both new files type-import InferenceContext/StreamResult/TurnArgs from
inference.ts; ESM elides type imports so there's no runtime cycle.
handleAbortOrError turned out not to call the summary functions, so
no back-edge needed.

inference.ts shrinks from ~1676 to ~1401 LoC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:09:50 +00:00
ea468ca7fb v1.12.4-rc1: extract budget, sentinels, xml-parser from inference.ts
Pure file moves. No behavior change. inference.ts retains createInferenceRunner
public surface; new files are internal to services/inference/.

- budget.ts: resolveToolBudget
- sentinels.ts: detectDoomLoop (re-exported through inference.ts),
  isCapHitSentinel, isDoomLoopSentinel, isAnySentinel
- xml-parser.ts: parseXmlToolCall, partialXmlOpenerStart

First of four refactor batches preparing inference.ts for the v1.13
AI SDK migration. inference.ts goes from 1780 LoC to ~1620.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:42:41 +00:00
eef4782383 v1.12.3: stale-stream banner with Retry/Discard
When an assistant message sits status='streaming' with no token activity
for 60+ seconds, the chat shows a banner above the input offering Retry
or Discard. Both clear the stale row via a new backend endpoint
POST /api/chats/:id/discard_stale that updates status='failed' and
publishes chat_status='idle'.

Closes the UX gap that caused the 2026-05-21 debugging spiral —
slow streams and dead streams now look different to the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:48:22 +00:00
a7104691aa v1.12.2: live tok/s + ctx display next to status indicator
ChatThroughput renders inline beside StatusDot while streaming or
tool_running. Subscribes to existing usage frames via sessionEvents.
Hides when status drops to idle/error or data is older than 10s.

Addresses the 2026-05-21 spike's UX gap where slow streams looked
identical to dead streams — now there's a live token velocity readout
that immediately distinguishes the two.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:45:53 +00:00
1a0a3b1673 v1.12.1: stop-handler writes terminal status + constraint cleanup + dead code removal
- handleAbortOrError now writes status='cancelled' on user stop; rows
  no longer stuck 'streaming' forever
- Drop stale messages_status_check constraint (only messages_status_chk
  remains, allowing 'cancelled' via TS MESSAGE_STATUSES)
- Remove detectSameNameLoop and DOOM_LOOP_SAME_NAME_THRESHOLD (added
  during 2026-05-21 debugging spike, never fired in any real run,
  existing detectDoomLoop covers actual failure modes)
- Remove 12 ctx.log.info diagnostic markers added during the same
  spike (verbose for production)
- Bundles workspace pane sync + status indicator overhaul +
  startup hung-row sweep landed earlier in v1.12.1 work

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:34:40 +00:00
48ee63a286 v1.12.1: rich status indicator + server-side workspace pane sync
Status indicator (StatusDot): drops the flat amber pulse for a richer set
of states — orbiting amber for streaming, spinning sky ring for tool_running,
static violet for waiting_for_input, plus the existing idle/error. Backend
chat_status frame widens from 'working|idle|error' to discriminate streaming
vs tool execution vs paused for user input.

Workspace pane sync: pane layout moves from per-device localStorage to
server-side sessions.workspace_panes jsonb. PATCH /api/sessions/:id/workspace
broadcasts session_workspace_updated on the user channel for cross-device live
sync. Echo dedup via JSON comparison so the round-trip frame doesn't loop.
Legacy localStorage seeds the server on first hydrate, then is deleted.
Deprecated session_panes table dropped.

Resilience: startup sweep marks any stale 'streaming' message older than
5 minutes as 'failed' so v1.12.0-style hung rows clear on container restart.
useWorkspacePanes gains validatePanes() to prune dead chatId references from
saved pane state when the chat list lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:32:02 +00:00
d58d553503 v1.12.1: same-name doom-loop guard + runAssistantTurn trace logging
Add detectSameNameLoop (threshold 5) to catch over-verification hangs
where tool args vary but the model is stuck on one tool. Add 12 structured
log points across the inference state machine (runAssistantTurn,
executeToolPhase, runDoomLoopSummary) to diagnose the deterministic hang
surfaced in v1.12.0 smoke testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:15:02 +00:00
fce8c06932 Merge v1.11.10 + doc refinements onto v1.12.0 main
# Conflicts:
#	CLAUDE.md
2026-05-21 15:22:46 +00:00
16c69a38a1 Merge v1.12 track B: codecontext sidecar
# Conflicts:
#	apps/web/src/components/ToolCallLine.tsx
#	docker-compose.yml
2026-05-21 15:12:30 +00:00
be3c38ff2f Merge v1.12 track A: container guidance + skills 2026-05-21 15:11:12 +00:00
a2e2481ef9 v1.12 track A: container guidance + skills 2026-05-21 15:11:04 +00:00
78914466d1 v1.12 track B.3: agent whitelists + .codecontextignore template + CLAUDE.md updates
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.
2026-05-21 15:09:11 +00:00
136e9538aa v1.12 track B.2: codecontext tool wrappers + tests 2026-05-21 13:35:44 +00:00
5cd3f63df5 mobile: add explicit close button to nav drawer 2026-05-21 04:06:35 +00:00
3e1e17ecf6 v1.11.10: stream-cap response body at 5MB, abort on overflow 2026-05-21 02:27:31 +00:00
ab01e04d77 v1.11.9: manual redirect handling — re-run URL guard on each hop 2026-05-21 00:37:35 +00:00
4e67a265ac v1.11.8: address review — inject fetcher, byte-count limit, redirect TODO 2026-05-20 21:40:11 +00:00
2fdbb05477 v1.11.8: web_search + web_fetch tools via SearXNG
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>
2026-05-20 21:38:02 +00:00
863452ae07 v1.11.7: secret-file deny list for codebase tools
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>
2026-05-20 20:55:50 +00:00
f92b0810c3 v1.11.6: doom-loop guard (3 identical tool calls aborts recursion) 2026-05-20 20:28:45 +00:00
4ec196273b sessions: default new sessions to no agent (raw chat)
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>
2026-05-20 20:11:57 +00:00
1ffcf67c47 v1.11.5: ContextBar inline next to agent picker; remove ChatContextPopover
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>
2026-05-20 20:11:49 +00:00
89dcfb95dc v1.11.3: fix ctx_max capture via /props endpoint
- 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)
2026-05-20 19:29:26 +00:00
8cd270a5da ContextBar: persistent context-usage indicator above MessageList
Walks chat messages newest-first for the latest ctx_used/ctx_max pair.
Color tiers fire against (max - 20k compaction reserve) so the bar warns
amber/orange/red at the same boundaries auto-compaction triggers.
"Context" → "Ctx" at <640px, (NN%) drops at <380px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:18:27 +00:00
dc43dd44f9 v1.11: opencode-style compaction port
- compaction.ts: usable/isOverflow/estimate/turns/select/buildPrompt/process
- compaction-prompt.ts: SUMMARY_TEMPLATE verbatim from opencode
- schema: messages.{compacted_at,summary,tail_start_id} + chats.needs_compaction
- inference: auto-trigger on overflow, pre-fetch compaction before next turn
- /compact slash command rewired to new path
- WS: chat_status working/idle around compaction + compacted frame
- frontend: SummaryCard + sonner toast on compacted
- 24 unit tests for pure functions
2026-05-20 19:05:35 +00:00
6aab4f7d2a ChatTabBar: + button dropdown to add chat / terminal / agent pane
Replaces single onNewChat handler with onAddPane(kind). Terminal pane
header gets matching + dropdown. Context menu "New chat" stays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:13:55 +00:00
2d841ee0b4 handoff 2026-05-20 14:56:02 +00:00
8cea4a899c v1.10.5: inference XML tool-call fallback parser
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>
2026-05-19 17:32:42 +00:00
3fceea064a booterm: fitFull() bypasses FitAddon scrollbar subtraction; push initial PTY size
FitAddon's proposeDimensions() always subtracts a phantom scrollbar width even
when CSS hides the scrollbar — losing one column of usable width. fitFull()
divides host clientWidth/clientHeight by the renderer's reported cell size
directly. Also POSTs the resized cols/rows back to /api/term/.../resize on
initial mount and after fonts.ready so bash/opencode get the correct PTY
size before the user types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:32:42 +00:00
ea9d261f0f v1.10.4: booterm mobile UX — copy/paste, swipe-close, send-to-chat, search
- Long-press selection + floating menu (mobile + desktop right-click): Copy,
  Paste, Select All, Search, Send to chat. Tap-outside / Esc dismiss.
- Pane-header Paste button (📋) for iOS user-gesture clipboard read.
- Swipe-left-to-close on mobile pane pill with red "Close" overlay and
  translateX visual hint; spring-back below 80px threshold.
- Send-to-chat reverse path: chatInputsRegistry + sendToChat event mirror
  the existing terminalsRegistry pattern. ChatInput appends with newline
  separator on receive and focuses (no auto-send).
- Scrollback search via xterm-addon-search@^0.13.0: SearchBar overlay with
  N-of-M match counter (onDidChangeResults), Enter/Shift-Enter cycling.
- Cmd/Ctrl+F intercept in Session.tsx when active pane is terminal; xterm
  also intercepts when focused. Browser native find passes through elsewhere.
- terminalsRegistry signature extended with openSearch + paste callbacks.

Includes deferred CLAUDE.md updates documenting v1.10/v1.10.1/v1.10.2/v1.10.3
learnings (uid 1000 collision, libc match, two event buses, vite proxy order,
mobile pane URL sync, xterm canvas selection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:16:47 +00:00
875db86e31 v1.10.3: booterm mobile/UX fixes + global keyboard shortcuts
Five issues + keyboard shortcuts across booterm and the workspace shell.

Auto-switch on create (mobile): addSplitPane now returns the new pane id;
Session.tsx wraps it with addPaneAndSwitch which pushes ?pane=<newId> on
mobile so the URL-sync effect doesn't fight the just-set activePaneIdx.
NewPaneMenu uses the wrapper; desktop Split dropdown is unaffected.

Tab-away reconnect: TerminalPane has a connect()/manualReconnect() state
machine. ws.onclose backs off 500ms/1s/2s × 3 attempts, then surfaces a
[Disconnected] banner with a Reconnect button. visibilitychange listener
calls manualReconnect when the tab returns and the WS isn't OPEN. tmux
session persists server-side so scrollback is intact on resume.

Copy/paste: attachCustomKeyEventHandler binds Cmd/Ctrl-C (copy if
selection, else send ^C), Cmd/Ctrl-Shift-C (always swallow — copy if any,
no-op otherwise — never sends ^C), Cmd/Ctrl-V and Cmd/Ctrl-Shift-V
(navigator.clipboard.readText → ws.send). No custom right-click menu —
browser's native menu is preserved.

Scroll: removed `set -g mouse on` from tmux.conf so xterm.js sees wheel
and touch events natively. scrollback: 10_000, fastScrollModifier: 'shift',
altClickMovesCursor: false. Container has touch-action: pan-y for mobile.

Right-edge gap: inline <style> overrides xterm's defaults to width:100%
height:100% and hides the scrollbar chrome. Host container is
flex-1 min-w-0 self-stretch w-full. Three refit triggers: ResizeObserver
(rAF-wrapped), document.fonts.ready, and useEffect on the new active prop.
Background color matched between outer div, inner div, and xterm theme.

Keyboard shortcuts in Session.tsx (window-level keydown):
  Cmd/Ctrl+`              focus active terminal, else jump to last
  Cmd/Ctrl+Shift+T        new terminal pane
  Cmd/Ctrl+Shift+C        new chat pane (defers to xterm copy if focused)
  Cmd/Ctrl+W              close active pane
  Cmd/Ctrl+Tab/Shift+Tab  cycle next / prev pane
  Cmd/Ctrl+1..9           jump to pane N
terminalsRegistry gains a focus() callback per registration so Cmd+`
can call term.focus() on the active terminal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:52:44 +00:00
5d52b79a07 v1.10.2: booterm runtime on bookworm-slim (glibc), su-exec → gosu
Switched the booterm runtime + proddeps stages from node:20-alpine (musl)
to node:20-bookworm-slim (glibc) so host-installed glibc binaries (Claude
Code, opencode, nvm node) run inside the container when invoked from the
terminal pane. node-pty's native .node has to be compiled in the same
libc env as the runtime, so both stages flip together; the TypeScript-only
builder stage stays on alpine.

su-exec is alpine-only; Debian replacement is gosu — swapped in both the
runtime apt install and the tmux default-command. uid/gid 1000 collision
with the bookworm `node` user handled via userdel/groupdel before
groupadd/useradd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:14:21 +00:00
d04b30687f v1.10.1: booterm runs shells as samkintop with login bash 2026-05-19 13:07:59 +00:00
7486e7d3e0 v1.10: booterm container — xterm.js + tmux + node-pty 2026-05-18 14:06:46 +00:00
d85b17081e v1.9.7: ask_user_input elicitation tool 2026-05-18 02:15:18 +00:00
80fd3d9fa9 feat(web): /skill slash command with autocomplete
Trigger /<name>, dropdown lists all skills filtered by name prefix,
arg passthrough sends the rest as the user message. Synthetic
skill_use tool_use renders identically to model-invoked skills.
2026-05-18 01:10:51 +00:00
eaacd432e8 feat(web): skills API types + client methods 2026-05-18 01:10:51 +00:00
529a77c959 feat(server): skills v1 — parser, tools, /api/skills, mount
- /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
2026-05-18 01:10:51 +00:00
98b432ebce refactor: drop type-to-confirm gate on chat delete
The chat-delete dialog required typing the chat name to confirm
deletion. Single-user app — typing friction is annoying, not safety.
Match the archive dialog pattern in SettingsPane.tsx: title +
description naming the chat in mono font, plain Cancel + destructive
Delete button.

Removes the deleteInput state, deleteExpected / deleteEnabled
deriveds, the <Input> field, and its lone <Input> import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:30 +00:00
1ecccc112f fix: settings pane close affordance + sidebar toggle
The v1.9 settings pane had no way to dismiss once opened. ChatTabBar
(which owns the per-pane close X for chat panes) is skipped for
settings panes, and the pane header itself only rendered the maximize
toggle (desktop-only). Mobile users had zero controls beyond the
section tabs.

Add three close paths:

- X button in SettingsPane header, visible on mobile + desktop, sits
  next to the maximize toggle. Tap-target sized per the v1.6 mobile
  convention (max-md:min-h-[44px]).
- Esc when the settings pane is the active pane and no input/textarea/
  dialog has focus. Maximize-restore still wins when maximized.
- Sidebar Settings button is now a strict toggle: opens on first click,
  closes on second. Renamed openOrFocusSettingsPane →
  toggleSettingsPane in the panes hook.

Edge case: removing the settings pane when it's the only pane left
falls back to an empty pane to preserve the "always one pane"
invariant. In normal flow this is unreachable (the toggle only
appends), but defensive against future entry points.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:25 +00:00
09aecc4ee9 v1.9: settings pane + per-project defaults + bulk archive + themes lift
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>
2026-05-17 17:37:29 +00:00
9b174cdb5e themes-v1: 18 preset palettes + Settings picker
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>
2026-05-17 16:25:15 +00:00
5c61cc7281 v1.8.2: tool loop cap-hit summary + tool call UI compaction
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>
2026-05-17 10:31:32 +00:00