Compare commits

..

63 Commits

Author SHA1 Message Date
140ff26204 feat(coder): v2.6 Phase 0 — AgentBackend foundations (no behavior change)
Schema, interface, and service scaffold for v2.6 persistent agent sessions.
Nothing in this batch alters runtime behavior.

- schema.sql: add session_worktrees (one shared worktree per session, FK
  sessions(id)) and agent_sessions (one backend session per (session, agent),
  with backend/status CHECKs); add pending_changes.agent column for DiffPanel
  attribution. All three statements idempotent (IF NOT EXISTS).
- services/agent-backend.ts: AgentBackend interface + AgentSessionHandle,
  EnsureSessionOpts, PromptCtx, TurnResult, and the normalized transport-agnostic
  AgentEvent union (text/reasoning/tool_call/tool_update/commands). Types only.
- services/agent-pool.ts: lazy get-or-create AgentPool keyed by
  `${sessionId}:${agent}` + shared `agentPool` singleton. Empty in Phase 0.
- index.ts: widen onClose to await dispatcher.stop() then agentPool.dispose()
  (pool empty, so dispose() is inert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:50:17 +00:00
a97293b5d9 Merge coder-hardening: acp-client-fs path-guard fix + untrack live provider config 2026-05-29 22:23:20 +00:00
63adb218e6 chore(coder): untrack live coder-providers.json, ship example
The live config is read AND written by the coder (UI provider toggles PATCH it),
so tracking it churned `git status`. Untrack it (now gitignored under data/*),
add a tracked data/coder-providers.example.json reference, and update the
.gitignore exception + CLAUDE.md/BOOCODER.md docs. Loader already falls back to
{providers:{}} (built-ins only) when the live file is absent. + CHANGELOG v2.5.15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:23:13 +00:00
d0334ca544 fix(coder): separator-bounded worktree path guard in acp-client-fs
The ACP fs bridge's worktree guard used an unbounded `startsWith(resolve(
worktreePath))`, so a sibling path sharing the worktree as a string prefix
(`<worktree>-evil/...`) escaped the scope. Since writeWorktreeTextFile hits disk
directly (no pending_changes gate), a confused/buggy ACP agent could write
outside its worktree. Now uses a separator-bounded check matching write_guard.ts
(resolve() + `startsWith(root + sep)` / `=== root`) via a shared resolveInWorktree,
with a regression test (../ traversal + the sibling-prefix bug). Symlink-swap
hardening intentionally skipped — consistent with write_guard's no-realpath
stance; the agent runs with host FS access so this is a containment guard, not a
trust boundary. Flagged by the automated push security review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:22:51 +00:00
024ffc0b92 Merge claude-md-learnings: session learnings + CHANGELOG v2.5.14 2026-05-29 21:24:18 +00:00
691eef1b30 docs(claude): session learnings — provider lifecycle, deploy + mobile gotchas
Adds to CLAUDE.md: stale boocoder-restart symptom after build (new routes 404 /
old routes 200); boocode container build: . deploys the working tree, web
dev≠prod until container rebuild; PATCH provider-config replaces override
wholesale (send full override) + coder-providers.json is live config (don't
commit drift); external agents one-shot with no ctx tracking + OpenCode-as-server
is unshipped v2.6; ui/ primitive inventory + button-role=switch / Dialog
fallbacks; mobile Dialog scroll containment. Also backfills uncommitted doc
bullets for the v2.5.7–v2.5.11 coder work. CHANGELOG v2.5.14 entry. Docs only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:24:10 +00:00
e92c51578d Merge v2.3-provider-lifecycle-phase5: provider settings UI + closeout
Phase 5 (Settings → Providers tab, picker filter, ACP catalog) + mobile settings
fix + Phase 6 docs. Completes the v2.3 provider-lifecycle batch
(phases 1–4: v2.5.4 / v2.5.5 / v2.5.6 / v2.5.12).
2026-05-29 20:20:38 +00:00
6d03690a65 docs: v2.3 provider-lifecycle closeout (Phase 6)
BOOCODER.md gains a Provider lifecycle section (config file + schema,
gitignored-with-exception, the 24h PROVIDER_PROBE_TTL_MS refresh contract,
enable/disable via Settings → Providers, custom-ACP add, native boocode
always-on, the honest subset-refresh known limitation, deploy + smoke).
docs/DEFERRED-WORK.md §2 (cold-probe skip) marked ADDRESSED with the still-
deferred Tier-2 follow-ups listed. CHANGELOG gets the v2.5.13 batch-closeout
entry. Docs only — no code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:20:31 +00:00
21384cce5b web: fix Settings pane unreachable on mobile (push ?pane= atomically)
Opening the settings pane on mobile set activePaneIdx, but the ?pane= URL-sync
effect snapped it back to the chat pane on the panes change, so the pane never
showed. toggleSettingsPane now returns the new pane id (id generated outside the
updater, strict-mode safe); Session's toggleSettingsAndSync pushes ?pane=<id> on
mobile when opening (and drops it on close) so the sync effect keeps it active —
mirrors the existing addPaneAndSwitch pattern. Desktop unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:20:24 +00:00
920f8b75a6 web(coder): provider settings UI — Settings → Providers tab, picker filter, ACP catalog
v2.3 Phase 5. Provider management lives in Settings → Providers: lists every
registered provider with a status badge, enable/disable toggle (sends the full
override so a custom ACP entry's command survives the wholesale-replace PATCH),
per-provider refresh, and a plaintext diagnostic. The composer provider picker
now filters to enabled && (status==='ready' || 'loading') — disabled/unavailable
providers leave the picker and are managed only in settings; native boocode
always shows. Adds a curated ACP catalog + AddProviderModal (PATCH config then
subset refresh; the modal caps to the viewport with a single overscroll-contain
scroll region). Loading state uses a capped client poll (no WS frame).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:20:18 +00:00
e83d9b7d5b docs(changelog): v2.5.12-provider-lifecycle-phase4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:48:28 +00:00
f302969c71 coder(providers): v2.3 provider-lifecycle phase 4 — config HTTP API (diagnostic returns JSON)
GET/PATCH /api/providers/config, subset POST /refresh, and
GET /api/providers/:id/diagnostic (JSON { diagnostic }, §6.4). PATCH order
is validate→save→reload→clear; a malformed body or invalid merged config
returns 422 without writing, and a save failure returns 500 without
reloading (no file/registry divergence). Web client + types extended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:46:56 +00:00
2d997ecb6c web+coder: discover Claude's enabled commands + plugin skills; icon-split commands vs skills
claude is PTY (no ACP discovery), so claude-command-discovery.ts reads its enabled set from disk (user-global): ~/.claude/commands/*.md + every enabled plugin's skills/<name>/SKILL.md (kind=skill) and commands/*.md (kind=command), from ~/.claude/settings.json:enabledPlugins + installed_plugins.json install paths, frontmatter-parsed, bare names, deduped. The snapshot claude branch discovers these live (snapshot cache rate-limits the reads). The coder / menu now shows up to three icon'd groups: <agent> commands (Terminal), <agent> skills (Puzzle), BooCoder skills (Sparkles) via a new optional icon on SlashCommandGroup. AgentCommand gains a kind field in both coder + web copies (parity test enforces); mergeCommandsByName made generic to preserve it. Invocation unchanged (literal /name -> claude). Project-local plugins deferred. BooChat unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:21:32 +00:00
dc3859975d coder(providers): capture + persist opencode's live ACP commands (no dispatch needed)
The cold ACP probe captured available_commands but read probedCommands synchronously right after newSession, racing opencode's async available_commands_update notification -> captured nothing, only the static manifest showed. The probe now waits (poll <=3s + 300ms settle) for the notification. Captured commands persist to a new available_agents.commands column and are served (merged with the manifest) on the tier-2-skip path, so the agent's discovered commands survive once models are warm and show without a dispatch. Boot warms via the force:true startup snapshot. Caveat: relies on opencode emitting available_commands_update on session creation, not only post-prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:56:18 +00:00
23a33e893a web+coder: segmented per-agent slash menu (agent commands + skills) + cross-agent skill execution
Coder / menu now shows two groups: the active agent's commands first (manifest + live ACP available_commands), BooCoder skills second. SlashCommandPicker gains an opt-in groups prop (flat items path unchanged -> BooChat byte-identical, parity verified); ChatInput takes slashGroups; CoderPane builds the groups. Skills run under the selected agent: coder skill_invoke accepts a provider and, when external, injects the server-side skill body into a dispatched task instead of native inference. Also folds in the initial-chat skill fix (handleLandingSkill: create chat -> assign to pane -> invoke, same transition as a text send) that resolves the landing-page blank screen. BooChat slash menu + skill invocation unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:38:39 +00:00
8bf86ecb92 web(coder): keep composer refresh on the top line + icon-only Mode picker on mobile
The AgentComposerBar refresh button wrapped to a second line on mobile: the status dot had ml-auto (pinned to the far-right edge) and the refresh button followed it in DOM order, overflowing past the edge. Group the dot + refresh into one right-aligned (ml-auto) unit so the refresh stays on the top line. Also add an iconOnly option to CompactPicker and render the Mode (permission) picker icon-only on mobile (shield + chevron, no label; aria-label/title + tap-to-open list still convey the selection) to free row width. Desktop unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:46:40 +00:00
fe52250d78 coder(providers): fix empty picker (loading-state) + config model overrides + current Claude models
Fix: getProviderSnapshot returned synchronous installed:false 'loading' entries on a cache miss (v2.5.5/Phase 2), which AgentComposerBar filters out — with the Phase 5 client poll not yet built, a single fetch stranded on 'loading' and the picker showed no providers. It now awaits the build and returns terminal entries; the sync loading-return is deferred until Phase 5. Builds stay fast via the tier-2 cold-probe skip.

Feature: wire the v2.3 config schema's models/additionalModels — buildResolvedRegistry carries them onto ResolvedProviderDef (models replace, additionalModels merge) and provider-snapshot applies them to every ready model list, so /data/coder-providers.json can edit any provider's models with no code change. Claude staticModels bumped from the stale 2-entry list to opus/sonnet/haiku latest-aliases + pinned claude-opus-4-8 / claude-sonnet-4-6 / claude-haiku-4-5-20251001 (passed verbatim to claude --model). +2 tests (109 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:37:01 +00:00
4035aa2b98 coder(providers): v2.3 provider-lifecycle phase 3 — generic ACP dispatch
ACP dispatch now spawns from the resolved registry's launch spec instead of a hardcoded per-name switch. acp-spawn.ts gains resolveLaunchSpec(resolved, installPath): launchCommand (config override / custom-ACP command) wins, else the kept resolveAcpSpawnArgs switch is the built-in fallback. acp-dispatch.ts spawns spec.binary/spec.args with env { ...process.env, ...spec.env }; dispatcher.ts loads the resolved def by task.agent and passes it through. Config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (opencode/goose/qwen) is byte-identical to pre-v2.3 — proven by a regression test (opencode->['acp'], goose->['acp'], qwen->['--acp'], binary=installPath ?? id, empty env -> plain process.env). Deliberate deviation from design's !installPath->null: the installPath ?? id fallback is preserved. setSessionMode/permission/streaming and the dispatcher poll/NOTIFY/running-guard untouched. 7 new acp-spawn.test.ts cases. No routes/UI (Phase 4+).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:06:32 +00:00
35a0aba211 coder(providers): v2.3 provider-lifecycle phase 2 — snapshot lifecycle
provider-snapshot no longer returns null for uninstalled/disabled providers: it emits one entry per registered provider with a lifecycle status (loading|ready|unavailable|error), an enabled flag, and a two-tier probe. Tier-1 is a fast which-style check (command-availability.ts, execFile/no-shell); tier-2 (cold ACP probe) is skipped unless forced, last_probed_at is older than PROVIDER_PROBE_TTL_MS (24h), or DB models are empty — the snapshot-latency win. Cache miss returns status:'loading' synchronously while the build settles via the existing inflight promise. ProviderSnapshotStatus/Entry regain loading/unavailable + gain enabled/description?/fetchedAt? in both coder and web copies, guarded by a runtime parity test (provider-types-parity.test.ts; compile-time cross-project check was blocked by TS6307). Also tracks the data/coder-providers.json seed via a .gitignore exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. 13 snapshot tests (+6) + 6 parity tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:47:48 +00:00
3730dc9341 coder(providers): v2.3 provider-lifecycle phase 1 — config-backed registry
Adds a config layer merged over the hardcoded built-ins (tasks 1.1-1.6): CODER_PROVIDERS_PATH env (default /data/coder-providers.json); provider-config.ts (Zod schema + never-throw loader — missing/invalid file falls back to built-ins only — + save); provider-config-registry.ts (ResolvedProviderDef + buildResolvedRegistry merge: override built-ins, add custom extends:'acp' entries, boocode always enabled + singleton); agent-probe now iterates the resolved registry, probes custom-ACP command[0] via execFile (no shell), skips disabled providers (keeps the row), reads enabled from memory only (no DB column). No snapshot/dispatch/route/UI changes (Phase 2+). 6 new unit tests; empty config provably yields exactly the built-ins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 04:09:34 +00:00
a359a4ab8b coder(providers): remove retired cursor and copilot providers
Drop both retired providers from BooCoder's provider layer: acp-spawn argv cases, provider-manifest mode blocks + manifest keys, provider-commands maps, the provider-snapshot cursor model-CLI branch (+ orphaned exec/promisify imports), the agent-probe copilot ACP-detect branch, and the now-dead cursor-models module + its test. The PROVIDERS registry array already lacked both. Built-ins unchanged: claude, opencode, goose, qwen, native boocode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 04:07:21 +00:00
a8c84ecfe4 chore+docs: config, agent registry, codecontext, v2.6 spec, changelog
Working-tree config/doc changes (.gitignore, CLAUDE.md, AGENTS.md removal + data/AGENTS.md, codecontext Dockerfile/shim — pre-existing) plus this session's v2-6 persistent-agent-sessions openspec proposal/design/tasks (planning only; feature unimplemented, reserves the v2.6.0 tag) and the v2.5.2 CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:31 +00:00
547fd70650 server/coder: working-tree backend changes (pre-existing)
Checkpoint of in-progress backend work present in the tree, not authored this session: auto_name, inference tool-phase/turn, secret_guard, provider-registry, plus a new agent-allowlist test (7 tests, passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:16 +00:00
990a615b87 web(coder UI): ChatInput migration + Thinking render + DiffPanel route fix
Bundles in-progress working-tree UI work not authored this session (CoderPane ChatInput migration, AgentComposerBar/CoderMessageList/tab-bar/sidebar/pane refinements, provider icons) with this session's changes to the same files: MessageBubble renders a collapsible 'Thinking' block from reasoning_text/reasoning_parts (surfacing ACP agent_thought_chunk + native reasoning), and the DiffPanel approve/reject calls are repointed to the real /api/coder/pending/:id/apply and /reject routes (the old /sessions/:id/pending/:id/approve|reject paths did not exist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:06 +00:00
5352fd9942 coder(pending): new-file-from-RightRail create endpoint + modal
POST /api/sessions/:sessionId/pending/create queues a pending_changes create via queueCreate (WriteGuardError -> 422 with the guard message). RightRail gains a 'New file from pasted text' modal (path + content) wired through api.coder.createPendingFile; sessionId is threaded down from App.tsx. The staged change shows in the CoderPane DiffPanel for explicit apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:11:50 +00:00
66df410826 web: fix mobile nav stuck-open on rejoin + paste-chip code fence
useViewport re-syncs the snapshot on pageshow/visibilitychange/resize/orientationchange — iOS reported a stale width on backgrounded-tab restore, leaving isMobile=false so the sidebar rendered as a permanent column with no close affordance. flattenToMessage now inserts pasted-text chips verbatim instead of wrapping them in a triple-backtick fence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:11:42 +00:00
f89c8f3f15 coder(dispatcher): react to new tasks via LISTEN/NOTIFY, poll as fallback
AFTER INSERT trigger on tasks fires pg_notify('tasks_new'); the dispatcher listens via porsager sql.listen and triggers an immediate poll, with the setInterval poll kept at 2s as a missed-notification safety net. Per-session guard unchanged (no double-dispatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:11:34 +00:00
cbef7618b3 v2.5.1-budget-100: raise all tool call budgets to 100 + codecontextignore fix
Budget defaults raised from 50/10/50 to 100/100/100 (read-only,
non-read-only, no-agent). Per-agent max_tool_calls from AGENTS.md
still overrides.

Added .claude/worktrees/ to .codecontextignore to prevent
get_codebase_overview from parsing empty stub files in stale
worktree node_modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 02:40:26 +00:00
fcc7c5a86e v2.5.0-task-model: lightweight task model services + tasks table
Task model infrastructure for cheap LLM calls (auto-naming, search
rewrite, tags, summaries) via a dedicated llama-server instance at
TASK_MODEL_URL, falling back to LLAMA_SWAP_URL with FAST_MODEL when
unset. Replaces the inline fetch in auto_name.ts with taskModelCompletion.

Adds search query rewriting: on step 0 when web tools are enabled, the
user's message is summarized into a search intent hint appended to the
system prompt, improving web_search relevance.

Schema: tasks table for provider dispatch and arena, sessions.tags column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 21:44:39 +00:00
bcfc94fa47 v2.4.1-sidecar-routing: route per-agent flags to llama-sidecar + tool gap fix
Batch 3c: when an agent has llama_extra_args in AGENTS.md, provider.ts
routes inference through LLAMA_SIDECAR_URL instead of LLAMA_SWAP_URL.
X-Agent-Flags header built from the agent's flags. Boot-time guard
refuses to start if any agent has llama_extra_args but LLAMA_SIDECAR_URL
is unset. PrefixFingerprint gains a route field (swap/sidecar) for
per-turn visibility. 9 provider tests.

AGENTS.md tool gap: all agents (except Prompt Builder) were missing 8
tools that were added after the original tool lists were written:
request_read_access, view_truncated_output, ask_user_input, git_status,
get_blast_radius, get_hot_files, get_middleware, get_routes. The missing
request_read_access caused silent "permission denied" when reading files
outside the project root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 19:28:08 +00:00
90a6761b07 v2.4.0-unsloth-studio-lift: port 3 Unsloth Studio AGPL-3.0 modules
Batch 1 — tool-call-parser.ts: replaces xml-parser.ts with a port of
Unsloth's tool_call_parser.py. Adds balanced-brace JSON scanner,
single-param fast path, hasToolSignal/stripToolMarkup/parseToolCallsFromText
exports, and stream-finalization stripping at all three final-write sites
(error-handler, finalizeCompletion, executeToolPhase). Anthropic <invoke>
shape preserved. 75+12 tests.

Batch 2 — web/html-to-md.ts: parse5 tree-walking HTML-to-Markdown converter
ported from Unsloth's _html_to_md.py. Replaces web_fetch's regex stripHtml
with structured markdown output (headings, links, lists, tables, code blocks,
blockquotes, entity decoding). 29 tests.

Batch 3 — llama-args-validator.ts: port of llama_server_args.py deny-list
validator. Wired into AGENTS.md frontmatter parser — llama_extra_args field
validated at load time, rejects managed flags (model identity, networking,
auth/TLS, server UI). No runtime consumer yet (llama-swap boundary). 76 tests.

All three files carry SPDX-License-Identifier: AGPL-3.0-only headers.
LICENSE flipped to AGPL-3.0-only in prior commit (a938cf1).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 23:30:50 +00:00
a938cf1d42 License: AGPL-3.0-only 2026-05-26 23:29:25 +00:00
6f6b3afb5d v2.3.2-coder-answer-endpoint: fix ask_user_input submit in CoderPane
The CoderPane runs its own inference runner and broker on the boocoder
service. The AskUserInputCard was calling /api/chats/:id/answer_user_input
on the main BooChat server, which has a different inference runner — the
answer was accepted but the next turn was enqueued on the wrong runner,
so nothing happened.

Fix: register the same answer_user_input endpoint on the boocoder, and
add an apiPrefix prop to AskUserInputCard so the CoderPane routes
through /api/coder/chats/:id/answer_user_input. BooChat's MessageList
continues to use the default (no prefix) path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 21:54:08 +00:00
154ef78f7c v2.3.1-permission-questions: enrich ACP permission wire for interactive questions and elicitations
The permission_requested WS frame now carries kind ('tool'|'question'|'plan'|
'elicitation'), input (the tool's rawInput payload), and description fields.
PermissionCard detects question-type permissions (Claude Code's AskUserQuestion)
and renders an interactive radio/checkbox form instead of approve/deny buttons.
Submitting answers auto-selects the first allow option.

Also wires up ACP createElicitation (unstable/experimental) — JSON Schema-driven
forms for structured user input. The same PermissionCard renders elicitation
fields with type-appropriate inputs. Both flows use the existing permission-waiter
blocking pattern with 120s timeout.

The response path (POST /api/coder/tasks/:id/permission) now accepts optional
updated_input alongside option_id, forwarded to the ACP agent as the user's
answer payload. Elicitation responses map to accept/decline/cancel actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 21:28:14 +00:00
792bbb9da3 v2.3.0-sampling-params-ask-user: agent sampling params, ask_user_input in CoderPane, UX polish
Add top_p/top_k/min_p/presence_penalty to AGENTS.md frontmatter and thread
through inference (agents.ts parser → Agent type → stream-phase → sentinel
summaries). Null means omit from request body, preserving provider defaults.

Wire ask_user_input interactive card into both BooCoder frontends: the
CoderPane in BooChat's SPA (CoderMessageList now renders AskUserInputCard
instead of ToolCallLine for ask_user_input tool calls) and the standalone
coder SPA (MessageBubble + new AskUserInputCard + shadcn ui primitives).

Additional fixes: SessionLandingPage uses ChatInput with slash-command
support and lazy chat creation; Session.tsx hydrate-race fix for empty pane
promotion; AgentPicker wider dropdown with line-clamp; ModelPicker min-width;
Textarea converted to forwardRef; Recon agent added to AGENTS.md; codecontext
host port exposed in docker-compose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 21:02:21 +00:00
31e1b32be1 v2.2.2-xml-placeholder-reject: drop placeholder XML tool calls at parse time
Reject qwen3.6 spurious <invoke> tails with path "..." or empty args before
they enter toolCalls, preventing duplicate assistant answers. Dropped blocks
append to flushed text; four new xml-parser tests. DEFERRED-WORK §6 for
console.debug → pino cleanup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 16:22:43 +00:00
314adaae48 docs: reconcile roadmap, README, and deferred work for v2.2 ship state
Mark v2.2/v2.2.1 shipped and v2.3 planned in roadmap and README; fix
DEFERRED-WORK §2 (ACP probe skip is planned, not resolved).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 15:27:16 +00:00
93d3f86c2b v2.2-paseo-providers: Paseo provider stack + v2.2.1 pane-scoped chat fixes
Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch
rewrite with streaming/persist, permission prompts, and agent commands.
Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline,
WS user-delta replace, and inference orphan tool_call stripping.
Archive openspec v2-2; update CHANGELOG and CURRENT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 15:18:31 +00:00
04673eaf59 v2.1.1: roadmap cleanup + README update + openspec archive
- Archive all 10 shipped openspec changes to openspec/changes/archived/
- Update boocode_roadmap.md: date, shipped status for v1.14/v1.15/v2.0, add v2.1.0 section
- Update README.md: 3-app monorepo, add services table, add What's shipped section
- Remove stale active openspec folders (all work shipped)
2026-05-25 20:23:22 +00:00
d8ffee1950 v2.1.0-provider-picker: BooCoder systemd migration + provider picker
- BooCoder moves from Docker to host systemd service (boocoder.service)
- Agent dispatch (ACP + PTY) switches from SSH to direct spawn/exec
- SSH helpers marked @deprecated (kept for one release cycle)
- Provider registry (5 providers: boocode, opencode, goose, claude, qwen)
- Agent probe with direct which/exec + model discovery (qwen settings, static claude models)
- GET /api/providers route with installed status, models, transport fallback
- ProviderPicker frontend component in CoderPane header
- External provider messages route through tasks row instead of inference enqueue
- Smart scroll: MessageList only auto-scrolls when near bottom (150px threshold)
- DB: available_agents gets models, label, transport columns
- Bug fix: loadContext SELECT includes allowed_read_paths
- Bug fix: cap hit sentinel inserted before buildMessagesPayload
- docker-compose.yml: boocoder service commented out, BOOCODER_URL env var added
- CLAUDE.md: updated docs for systemd, provider registry, JSONB gotcha, loadContext
2026-05-25 19:20:53 +00:00
e423579e99 v2.0.5: FAST_MODEL routing + tool-use summaries + Qwen dispatch + Arena
Source-level recon of QwenLM/qwen-code (Apache-2.0) informed 4 lifts:

1. FAST_MODEL config: optional env var routes cheap LLM calls (titles,
   summaries, labeling) to a smaller model on llama-swap. auto_name.ts
   uses ctx.config.FAST_MODEL ?? session.model. Set FAST_MODEL=nemotron-
   nano-4b to avoid loading the 35B model for 20-token title generation.

2. Tool-use summaries (services/inference/tool-summaries.ts): utility
   that generates "git-commit-subject-style" labels for tool batches via
   a fast-model LLM call. System prompt + truncation logic ported from
   Qwen Code's toolUseSummary.ts. Exported via @boocode/server/inference
   for BooCoder's dispatcher to call after task completion.

3. Qwen as dispatchable agent: added to agent-probe.ts KNOWN_AGENTS.
   PTY dispatch builds: qwen -p "<task>" --output-format stream-json
   (NDJSON structured events over stdout). Env: OPENAI_BASE_URL +
   OPENAI_API_KEY points Qwen Code at llama-swap. execution_path CHECK
   constraint extended with 'qwen'.

4. Arena routes (routes/arena.ts): POST /api/arena dispatches the same
   task to N contestants (2-5, each with different agent/model), each
   getting its own task row linked by arena_id UUID. GET /api/arena/:id
   shows all contestants. POST /api/arena/:id/select/:task_id marks
   winner. Schema: arena_id column added to tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:05:59 +00:00
06116f31b3 v2.0.4-hardening: fuzz suite + integration tests + production readiness
Phase 8 of v2.0. Final hardening pass before production tag.

Path-guard fuzz suite (34 tests): traversal attacks (../ all depths,
encoded %2e%2e, null bytes, absolute escapes, prefix-without-separator,
backslash), secret-file deny list (.env, *.pem, id_rsa*, *.key,
credentials.json, *.kdbx, .netrc), valid-path positives, edge cases
(empty, whitespace, very long, triple-dot, multiple slashes).

write_guard.ts hardened: added null-byte rejection and whitespace-only
rejection (previously only checked empty string).

Pending-changes integration test skeleton: 4 tests covering the full
queue→apply→rewind cycle against a real DB + filesystem. Gated on
DATABASE_URL via describe.runIf (same pattern as apps/server's
tool_cost_stats.test.ts). Skips cleanly when unset.

57 tests passing (23 existing + 34 fuzz), 4 integration skipped.
All builds clean. All services healthy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:31:22 +00:00
47abbb6e3c v2.0.3: CLI client + human inbox + cost tracking + Boomerang new_task
Phase 7 of v2.0. BooCoder gains a terminal-driven UX and subagent
isolation primitive.

CLI (src/cli.ts): standalone entry point for terminal use.
- boocode run "task" [--agent x] [--model y] — create + stream output
- boocode ls [--state x] — formatted task table
- boocode attach <id> — WS stream of running task
- boocode send <id> "msg" — follow-up message to task session
Connects to BOOCODER_URL (default http://100.114.205.53:9502).

Human inbox (routes/inbox.ts): GET /api/inbox (failed/blocked tasks),
POST /api/inbox/:id/retry (reset to pending for re-dispatch).

Cost tracking: dispatcher aggregates tokens_used from all messages in
the task's session after completion, stores in tasks.cost_tokens.
GET /api/stats/costs?group_by=project|agent|day for aggregation.

Boomerang subagent isolation (3 new tools):
- new_task: creates child task with parent_task_id linkage, runs in
  fresh isolated session. Orchestrator sees only output_summary.
- list_tasks: query child tasks of current parent
- check_task_status: read task state + output_summary

The orchestrator pattern: an agent with tools: [new_task, list_tasks,
check_task_status] can ONLY dispatch — can't read files or MCP. This
is the Roo Code Boomerang Tasks capability-restriction principle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:25:18 +00:00
f53c6d6cb9 v2.0.2: BooCoder MCP server — 6 tools over stdio
Phase 6 of v2.0. BooCoder exposes its task primitives as MCP tools
so external agents (Sam's opencode in Termius) can drive the task
queue without going through the web UI.

6 MCP tools registered via McpServer + StdioServerTransport:
- boocoder.create_task — INSERT pending task
- boocoder.list_pending_changes — SELECT pending changes
- boocoder.apply — apply a specific pending change to disk
- boocoder.reject — reject a pending change
- boocoder.dispatch_external_agent — create task with agent for Path B
- boocoder.list_worktrees — list active worktrees from running tasks

Activated by --mcp CLI flag: `node dist/index.js --mcp` starts the
MCP server over stdio instead of the HTTP server. Configure in
opencode: {"mcpServers":{"boocoder":{"type":"stdio","command":"docker",
"args":["exec","-i","boocoder","node","dist/index.js","--mcp"]}}}

Uses McpServer class from @modelcontextprotocol/sdk/server/mcp.js
(high-level .tool() registration API). Zod schemas for input
validation. Process blocks on stdin close, cleanly shuts down DB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:17:28 +00:00
3d6055518b v2.0.1: ACP dispatch + PTY fallback + worktree management
Phase 5 of v2.0. External agent dispatch via SSH to host.

ACP dispatch (acp-dispatch.ts): spawns agent via SSH with JSON-RPC
stdio pipe. Wraps opencode/goose in ACP mode. Captures structured
events (file operations, tool calls) mapped to parts taxonomy.
Falls back to PTY if ACP handshake fails.

PTY dispatch (pty-dispatch.ts): raw SSH spawn for agents without ACP
support (claude, pi). Captures stdout/stderr as plain text. Simpler
but less structured than ACP.

SSH helper (ssh.ts): shared spawn wrapper for SSH commands to
samkintop@100.114.205.53 (Tailscale IP, same as booterm). Uses
openssh-client installed in the runtime Dockerfile stage.

Worktree management (worktrees.ts): createWorktree (git worktree add
via SSH), diffWorktree (git diff HEAD...task-branch), cleanupWorktree
(git worktree remove --force). One worktree per task at
/tmp/booworktrees/<taskId>.

Dispatcher updated: checks available_agents.supports_acp to pick
transport. Path B flow: create worktree → dispatch agent → diff
worktree → queue diff into pending_changes → cleanup worktree →
mark task complete.

Agent probe updated: probes via SSH to find host-installed agents
(which opencode && opencode --version over SSH).

Dockerfile: openssh-client added to runtime stage.
Config: SSH_HOST env var (default 100.114.205.53).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:10:46 +00:00
752ea74f43 v2.0.0-final: dispatcher + task queue + agent probing
Phase 4 of v2.0. BooCoder can now queue tasks and dispatch them
through the inference loop autonomously.

Dispatcher (services/dispatcher.ts): in-process setInterval(5s) polls
tasks WHERE state='pending', picks one at a time, creates an isolated
session+chat, enqueues inference with the task's input as the user
message, polls for completion, marks state completed/failed with
output_summary. Single-task-at-a-time for v2.0.0; parallel dispatch
is a Phase 5+ concern. Respects onClose hook for graceful shutdown.

Task routes (routes/tasks.ts): POST /api/tasks (create), GET /api/tasks
(list with state/project filters), GET /api/tasks/:id (detail),
POST /api/tasks/:id/cancel (marks cancelled, aborts if running).

Agent probe (services/agent-probe.ts): on startup, probes PATH for
opencode/goose/claude/pi via which + --version. UPSERTs into
available_agents table. Finds nothing inside the container (expected —
Phase 5 addresses host-agent access via ACP/PTY).

Schema: ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id (links
task to its auto-created inference session for isolation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:55:18 +00:00
73b53089b0 CLAUDE.md: v2.0.0 architecture docs — BooCoder, DB rename, MCP config, workspace deps
Session learnings applied:
- Database renamed boochat (from boocode), new tables documented
- BooCoder architecture section: workspace dep pattern, write tools,
  coder pane integration, proxy routing
- Environment: MCP_CONFIG_PATH, BooCoder health at :9502
- Workflow: Go binary at /snap/go/current/bin, codecontext fork location
- Conventions: workspace exports with types conditions, Docker build order

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:51:24 +00:00
457c59fb06 v2.0.0: BooCoder frontend — chat pane + diff pane + session picker
Integrates BooCoder as a 'coder' workspace pane within the existing
BooChat SPA at code.indifferentketchup.com. Renamed the placeholder
'agent' pane kind to 'coder' across all types, menus, hooks, and
mobile switcher (Icon: Code instead of Bot).

CoderPane.tsx: split layout with chat area (messages via WS to
boocoder:9502, input bar posting to /api/coder/sessions/:id/messages)
and diff panel (pending changes with Approve/Reject per change plus
Approve All/Reject All). Reuses MarkdownRenderer for message content.

Proxy: Vite dev config adds /api/coder → boocoder:9502 (ordered above
/api per CLAUDE.md proxy-ordering rule). Production: Fastify route in
apps/server/src/index.ts proxies /api/coder/* to http://boocoder:3000
via fetch() pass-through. WS connects directly to :9502 (same
Tailscale network, no proxy needed for WebSocket upgrade).

WorkspacePaneKind mirror updated in both apps/web and apps/server
types. useWorkspacePanes gains coderPane() factory (replaces the old
agent toast stub). Workspace.tsx switch renders CoderPane for
pane.kind === 'coder'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:24:49 +00:00
78455b7efc v2.0.0: BooCoder frontend — chat pane + diff pane + session picker
Phase 3 of v2.0. React + Vite SPA at apps/coder/web/ served by
the coder Fastify server via @fastify/static with SPA fallback.

Chat pane: message list via WS streaming (useSessionStream hook),
input bar, POST /api/sessions/:id/messages on submit, markdown
rendering via react-markdown + remark-gfm, inline tool-call display.

Diff pane: fetches GET /api/sessions/:id/pending, shows pending
changes with file path + operation badge (create/edit/delete),
before/after diff for edits, Approve/Reject per change and
Approve All/Reject All buttons.

Layout: fixed two-pane split (chat 60%, diff 40%). Dark theme
(bg-zinc-900). Desktop-first for v2.0.0.

Session picker (Home page): lists projects and sessions from the
shared DB. No CRUD — use BooChat's UI for that.

Dockerfile updated: builds web app in builder stage, copies dist
to runtime. index.ts registers fastifyStatic + SPA fallback route.

Tailwind v4, React 18, TypeScript strict. ~20 new files, ~370KB
built output. Functional developer tool UI, not polished consumer
product — Phase 7 (v2.0.3) handles polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:04:52 +00:00
d2108b2f8d verification discipline rules + chat naming from assistant response
BOOCHAT.md + BOOCODER.md: 4 verification rules added to both —
verify against running container not source files, never count dist/,
run commands before claiming success, derive counts from commands.

auto_name.ts: chat titles now derived from the assistant's first
response only (user message dropped from naming input). System prompt
updated to "summarize the topic or outcome — do NOT copy the first
few words verbatim." Produces titles like "Fastify Route Setup"
instead of echoing the assistant's opening sentence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:52:49 +00:00
ce31577d1e v2.0.0-beta: write tools, pending-changes queue, inference loop, API routes
Phase 2 of v2.0. BooCoder is now a functional write-capable chatbot.

Write-path guard: resolveWritePath() uses resolve() (no realpath — files may
not exist for creates) + prefix-check + secret-file deny list (.env, *.pem,
id_rsa*, etc.). 23 unit tests cover traversal attacks.

Pending-changes service: queueEdit/Create/Delete → applyOne/All →
rejectOne/All → rewindOne. Edit diffs stored as JSON {old, new}. All writes
queue before touching disk; apply re-validates the path guard.

5 write tools: edit_file, create_file, delete_file, apply_pending, rewind.
Registered alongside 25 read-only tools from BooChat (30 total, alpha-sorted).
Write tools use a module-level inference context for sql+sessionId injection.

Inference loop via workspace dependency: apps/coder imports
createInferenceRunner, createBroker, ALL_TOOLS from @boocode/server (dist/).
apps/server gains declaration: true + exports map with typed subpath entries.
No code duplication — one inference engine shared by both apps.

API routes: POST /api/sessions/:id/messages (user msg → inference), POST stop,
GET/POST pending-changes CRUD (5 endpoints), WebSocket session streaming.

Dockerfile updated to build apps/server first (coder depends on its .d.ts).
Health endpoint reports tool count: {"ok":true,"db":true,"tools":30}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:53:38 +00:00
006226cce5 v2.0.0-alpha: BooCoder foundation — container, schema, DB rename
Phase 1 of v2.0. BooCoder is live at port 9502 with a health endpoint.

- Database renamed: ALTER DATABASE boocode RENAME TO boochat (one-time).
  All services updated to connect to /boochat. Docker service name stays
  boocode_db (rename is internal to Postgres, not Docker).

- New apps/coder/ app skeleton: Fastify server with health endpoint,
  postgres connection, schema apply on boot. Mirrors apps/server pattern
  but minimal (no inference loop yet — Phase 2).

- Schema: pending_changes (operation queue before /apply), tasks (dispatch
  DAG with state machine), available_agents (startup-probed agent registry),
  human_inbox view (tasks WHERE state IN blocked/failed). All IF NOT EXISTS,
  idempotent on re-run. Same boochat database, different tables.

- Dockerfile: Node 20 bookworm-slim (glibc for future node-pty in Phase 5).
  Multi-stage build matching the existing boocode image pattern.

- docker-compose.yml: boocoder service on 100.114.205.53:9502, /opt:/opt:rw
  mount (write-capable, policy-gated at tool layer), depends on boocode_db.

- BOOCODER.md: container guidance declaring write-tool capability +
  pending-changes discipline.

All 4 services boot and pass health checks. 9 tables in the shared DB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:20:29 +00:00
62d818af23 v2.0 implementation plan: 8 phases from foundation to production
Detailed execution plan for all v2.0 sub-versions:

Phase 1 (v2.0.0-alpha): container skeleton, DB rename, schema migration
Phase 2 (v2.0.0-beta): write tools + pending-changes service + fuzz tests
Phase 3 (v2.0.0): frontend diff pane + chat pane + Caddy routing
Phase 4 (v2.0.0-final): dispatcher worker + task queue + agent probing
Phase 5 (v2.0.1): ACP client + PTY fallback + worktree management
Phase 6 (v2.0.2): MCP server (6 tools, stdio, 10-question eval)
Phase 7 (v2.0.3): CLI + human inbox + cost tracking + observation hooks + Boomerang
Phase 8 (v2.0.x): path-guard fuzz, integration tests, docs, production deploy

~2050 LoC total. Phases 1-4 sequential, 5-7 parallelizable after 4.
Risk register covers path-guard bypass, ACP instability, worktree cleanup,
DB rename, MCP eval, Boomerang context leak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:09:05 +00:00
531d39ace9 v2.0 proposal update: add AGENTS.md extensions, Boomerang pattern, observation hooks, follow-up batches
Additions from second pass of boocode_code_review.md:

- AGENTS.md extensions: output_schema, exit_expression, execution_strategy
  (qodo-ai/agents MIT), expert_model escape hatch (RA.Aid Apache-2.0)
- Subagent isolation via Boomerang Tasks pattern: orchestrator-only-dispatches,
  down-pass/up-pass context discipline, fresh session per subtask
- Observation hooks: 5-event taxonomy from budi (SessionStart, UserPromptSubmit,
  PostToolUse, SubagentStart, Stop) mapped to WS frames
- Follow-up batches table: PR-resolver, HMAC audit log, blind-validation gate,
  majority-vote ensembler, drift detection, anti-slop, globstar gate, Docker
  sandbox, multi-provider LLM
- Additional repo to clone: qodo-ai/agents for agent.toml schema reference

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:22:57 +00:00
f2974d6887 v2.0 proposal: BooCoder — write tools, pending changes, ACP dispatch, MCP server
Comprehensive roadmap for the v2.0 major version bump. Covers:
- Schema: pending_changes, tasks, available_agents tables + human_inbox view
- Path A: native write tools (edit_file, create_file, delete_file) queuing
  through pending_changes before /apply flushes to disk
- Path B: external agent dispatch via ACP (opencode, goose) or PTY fallback
  (claude, pi) with per-task git worktrees and automatic diff-on-completion
- BooCoder MCP server: 6 tools exposing task primitives over stdio
- Code lifts: agent-hub (Apache-2.0, task DAG), plandex (MIT, diff UX),
  ACP SDK (Apache-2.0, subprocess protocol), Paseo (AGPL, design-only)
- Sub-versions: v2.0.0 (Path A), v2.0.1 (Path B), v2.0.2 (MCP server),
  v2.0.3 (CLI + polish)
- Estimate: ~2200 LoC total

All v1.x dependencies shipped (v1.13 parts, v1.14 outer loop, v1.15 MCP
client, v1.16 codesight). v2.0 is unblocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:11:16 +00:00
29c7d051b6 v1.16.0-codesight-merge: 4 new codecontext tools — blast radius, hot files, routes, middleware
BooCode wrapper tools for the 4 new MCP tools added to the codecontext
sidecar (Go side committed separately at /opt/forks/codecontext).

- get_blast_radius: reverse-edge BFS — "what breaks if I change this?"
- get_hot_files: most-imported files by incoming edge count
- get_routes: Fastify/Express route extraction via tree-sitter AST
- get_middleware: middleware detection via import + registration patterns

Wrappers follow the existing codecontext pattern: Zod input → callCodecontext
→ ToolDef export. Registered in ALL_TOOLS (alpha-sorted). All 4 are read-only.

codecontext sidecar rebuilt from commit b19e646 with the 4 new Go handlers
(2130 lines, 29 tests). Reviewer fixes applied: defer RUnlock on Tier 2
handlers, extractObjectProperty delegates to extractStringValue for
template-literal route paths.

363/363 server tests passing. No schema changes, no frontend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 05:19:52 +00:00
d27a977d59 v1.15.0-mcp-multi: multi-server MCP client + stdio transport + config file + tool globs
Generalizes the v1.14.1 single-server Context7 PoC into a multi-server MCP
client registry with per-server graceful degradation. JSON config at
/data/mcp.json (bind-mounted alongside AGENTS.md) matches opencode's
mcpServers schema shape. Config file missing = no MCP (opt-in by presence).

Two transports: Streamable HTTP (remote servers like Context7) and stdio
(local subprocess servers like codecontext). Stdio spawns a persistent child
via the SDK's StdioClientTransport; shutdown hook closes all transports.

Tool prefix generalized from context7_<name> to <serverName>_<toolName> with
a toolToServer reverse map for dispatch routing. AGENTS.md tools: field now
supports glob patterns (context7_*, !web_*) via matchToolGlob — last-match-
wins with ! deny prefix. Replaces exact-match .includes() in stream-phase.ts.

refreshToolNames() in agents.ts rebuilds the DEFAULT_TOOLS snapshot after
appendMcpTools so agents without explicit tools: lists see MCP tools —
reviewer caught that the module-load-time snapshot would permanently exclude
late-registered tools.

Read-only invariant: readOnlyHint === false rejected at discovery. Result
size capped at 5MB. v1.14.1 env vars removed — superseded by config file.
Default data/mcp.json ships with Context7 disabled.

363/363 server tests passing. No schema changes, no frontend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:08:42 +00:00
5692e99a5d v1.14.1-mcp-poc: single-server MCP client against Context7
Validates the MCP-client loop end-to-end against one real MCP server before
the full v1.15 port. New services/mcp-client.ts wraps @modelcontextprotocol/sdk
v1.29.0 with Streamable HTTP transport. On startup (when MCP_CONTEXT7_URL is
set), connects to Context7, discovers tools via tools/list, wraps each as a
ToolDef prefixed context7_<name>, and appends to ALL_TOOLS via appendMcpTools.

Read-only invariant guard rejects any tool with readOnlyHint: false. Tool
dispatch is transparent — executeToolCall routes MCP calls through the ToolDef
execute wrapper, which strips the prefix before calling the MCP server. Result
size capped at 5MB with truncation. Graceful degradation: server down at
startup → zero tools; server down mid-session → error result, model
self-corrects.

Adversarial review caught that a Zod .default() on the URL config made MCP
always-on instead of opt-in — fixed by removing the default. MCP_CONTEXT7_URL
must be explicitly set to enable.

ALL_TOOLS changed from ReadonlyArray to mutable to support late-registration.
appendMcpTools re-sorts and rebuilds TOOLS_BY_NAME after append.

348/348 server tests passing (16 new mcp-client tests). No schema changes,
no frontend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:58:09 +00:00
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
211e903620 v1.13.20-drop-legacy-cols: final phase of v1.13.0 strangler-fig
Removes the dual-write into messages.tool_calls / messages.tool_results JSON
columns and drops the columns. message_parts is now the only source of truth
for tool calls and tool results.

10 dual-write sites stripped (5 in tool-phase.ts, 2 in routes/skills.ts, 2 in
routes/messages.ts, 1 in routes/chats.ts fork-clone). The recon-driven grep
caught 2 sites beyond the original v1.13.2 roadmap inventory and an extra
fixture file (tool_cost_stats.test.ts) with a direct legacy-column INSERT.

messages_with_parts view rewritten to parts-only subselects (COALESCE
fallbacks gone). View runs via CREATE OR REPLACE so it lands before the
column DROPs in startup DDL — Postgres rejects column-drop on view-referenced
cols. v1.12.1 cleanup DO block (DROP CONSTRAINT messages_status_check /
messages_role_check) removed; those one-shots have done their work.

Adversarial review caught a runtime bug the green test suite missed: the
discard_stale endpoint (chats.ts) had a RETURNING ... tool_calls, tool_results
clause that would have crashed on every 60s-no-token-activity recovery in
production. Fixed by switching to two-step UPDATE returning id, then SELECT
from messages_with_parts so parts-synthesized fields keep flowing on the wire.

Message API type retains tool_calls? / tool_results? — the view synthesizes
those keys from parts so the wire shape is unchanged; frontend reads need no
update. Override on the original v1.13.2 plan, captured in the openspec
proposal.

339/339 server tests passing (including 7 DB-integration tests that applied
the schema migration to a live DB and ran the parts-only view end-to-end).
tsc + web build clean.

Pairs with v1.13.0-ai-sdk-v6 (introduced the dual-write) and v1.13.1-B (moved
the read path to messages_with_parts). Umbrella v1.13 tag ships on this same
commit, marking the strangler-fig closed.

CLAUDE.md picks up Sam's pre-existing edits documenting tag-naming and
CHANGELOG conventions — both already in use by v1.13.19 / v1.13.20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:03:51 +00:00
ad45b28250 v1.13.19-html-artifact-panes: pane-based artifact viewer with on-request HTML
Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.

Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.

Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.

31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:43:13 +00:00
1a889dcde3 v1.13.18-codecontext-file-path: resolve file_path against project root in codecontext wrappers
Four codecontext sidecar wrappers — get_file_analysis (required
file_path), get_symbol_info, get_dependencies, and get_semantic_neighborhoods
(optional) — forwarded file_path to the HTTP sidecar unchanged. The
sidecar's internal file index is keyed on absolute paths, so any
relative path from the model returned "File not found in graph".
Three back-to-back failures observed in one chat on 2026-05-22
17:56 UTC, ~48 s of wasted tool budget.

## Resolver

Add resolveProjectPath(projectRoot, rawPath) in codecontext_client.ts:
trim check → absolute/relative branch (both go through resolve() so
dot-segments normalise) → realpath with ENOENT fallthrough → escape
check using the realpathed value. Error shape mirrors the existing
target_dir escape error byte-for-byte; only the field name differs.

Wired into callCodecontext at the args-spread site, guarded on
file_path presence + non-empty. All four wrappers benefit from one
call site; wrappers without file_path (overview, framework, watch,
search) are unaffected.

## Schema trim

.trim() added to all four file_path Zod schemas:

  get_file_analysis:                  z.string().trim().min(1)
  get_symbol_info:                    z.string().trim().optional()
  get_dependencies:                   z.string().trim().optional()
  get_semantic_neighborhoods:         z.string().trim().optional()

Absorbs trailing newlines / whitespace from model output before the
resolver sees the value.

## Adversarial review fixes

Adversarial pass surfaced two P2 findings:

1. Absolute path with `..` resolving outside the project root (e.g.
   `<projectRoot>/../etc/passwd`) that ENOENTs at realpath would slip
   through the literal prefix-check: the raw string starts with
   `<projectRoot>/`. Fix: resolve() the absolute branch's candidate
   too, so dot-segments normalise before the prefix check.

2. No symlink-escape test coverage. Realpath's stated purpose
   (catching in-project symlinks pointing outside the project) was
   never tested. Added: create a tmpdir outside projectRoot,
   symlink projectRoot/evil-link → outside file, assert rejection.

## Tests

codecontext_client.test.ts: 19 tests (10 baseline + 9 new file_path
resolution cases). Cases cover: relative→absolute, absolute-inside,
relative-escape, absolute-outside, ENOENT-fallthrough, empty-string,
wrapper-without-file_path, absolute-with-`..`-ENOENT,
symlink-leaving-root.

codecontext_tools.test.ts: one assertion updated to expect the
resolved-absolute file_path on the wire (previously asserted the raw
relative path passed through, which is exactly the bug being fixed).

Full suite: 301 passed, 7 skipped.

## Affected / unaffected

- get_codebase_overview, get_framework_analysis, watch_changes,
  search_symbols: no file_path arg → resolver guard skips them. No
  behavior change.
- get_semantic_neighborhoods IS in SYNTHESIS_TOOLS — previously-failing
  relative-path calls will now successfully synthesize. Desirable, not
  a regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:54:16 +00:00
b52c5df705 v1.13.17-cross-repo-reads: on-demand read access to paths outside the project root
When the agent needed context from another repo, pathGuard rejected every read
with no recovery path. This batch adds a reactive request_read_access flow:
pathGuard's error now hints at the tool, the model emits a structured request,
the inference loop pauses (same mechanism as ask_user_input), the user picks
Allow/Deny via inline chips, and subsequent reads under the granted root succeed
for the rest of the session.

Schema: sessions.allowed_read_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]
(idempotent ADD COLUMN IF NOT EXISTS).

Grant unit (design D1): nearest registered projects.path ancestor →
nearest repo-shaped ancestor (.git/ / package.json / go.mod / Cargo.toml)
under PROJECT_ROOT_WHITELIST → else refuse. grant_resolver.ts walks
ancestors with a per-iteration whitelist invariant check so symlinked
input can't escape the whitelist mid-walk (Sam's checkpoint-1 ask).

Path-guard: optional extraRoots arg threaded from session.allowed_read_paths
through executeToolCall to view_file / list_dir / grep / find_files. The
ToolDef.execute signature gets an optional third param; non-FS tools
ignore it. view_file re-anchors the secret-guard check on basename(real)
whenever a relative path starts with "../" so .env / id_rsa* etc. still
deny across grant roots.

Endpoint: POST /api/chats/:id/grant_read_access mirrors /answer_user_input.
On 'allow' it re-resolves the grant root (state may have changed since
prompt — auto-falls to denial reason text on failure, not 500), array_appends
to sessions.allowed_read_paths with in-memory dedup, then publishes
tool_result + session_updated frames and enqueues the next assistant turn.

PATCH /api/sessions/:id allowed_read_paths supports revocation only. Zod
refines absolute + no traversal markers; runtime findUnauthorizedAdditions
guard rejects any entry not already present in the row, so a malicious
curl -X PATCH -d '{"allowed_read_paths":["/etc"]}' returns 400 instead of
bypassing the grant flow (Sam's compliance-review action item).

Frontend: RequestReadAccessCard renders pending (path + reason + Allow/Deny)
and answered (granted/denied summary with the resolved root) variants;
MessageList.flatten/group special-cases the tool name; SettingsPane adds a
per-session grants list with per-row revoke that PATCHes the shortened
array.

Tests: 11 grant_resolver, 8 path_guard, 8 sessions PATCH subset, including
explicit cases for symlink escape mid-walk, walk-bound termination at
whitelist root, /etc bypass attempt via PATCH, and nearest-project
disambiguation. 292 total server tests green.

Pairs with v1.13.16-xml-parser — the model now self-recovers from both
a wrong tool name AND from a refused path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:45:52 +00:00
277 changed files with 26663 additions and 2466 deletions

View File

@@ -21,6 +21,7 @@ out/
.opencode/
.vscode/
.idea/
.claude/worktrees/
# Test artifacts / coverage
coverage/

View File

@@ -1,6 +1,6 @@
NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boochat
LLAMA_SWAP_URL=http://100.101.41.16:8401
PROJECT_ROOT_WHITELIST=/opt
BOOTSTRAP_ROOT=/opt/projects
@@ -11,6 +11,11 @@ POSTGRES_PASSWORD=CHANGE_ME
# point BooCode at a different SearXNG instance.
SEARXNG_URL=http://100.114.205.53:8888
# Task model: lightweight model for auto-naming, search rewrite, etc.
# Direct llama-server instance (NOT llama-swap). Falls back to LLAMA_SWAP_URL
# with FAST_MODEL when unset.
# TASK_MODEL_URL=http://100.90.172.55:7995
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
# sessions where the model only needs read-only filesystem access.

8
.gitignore vendored
View File

@@ -1,6 +1,11 @@
node_modules
dist
.env
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
.claude/
.cursor/
.cursorignore
CLAUDE.local.md
*.log
.DS_Store
@@ -10,3 +15,6 @@ secrets/
data/*
!data/AGENTS.md
!data/skills/
!data/mcp.json
!data/coder-providers.example.json
codecontext/fork.tar.gz

View File

@@ -28,10 +28,24 @@
- Prefer codecontext (`search_symbols`, `get_symbol_info`, `get_dependencies`) over `grep` for symbol-level questions. Fall back to `grep` / `view_file` when codecontext returns degraded or empty results — that signals an unsupported language or parse failure.
- Verify before reporting work complete: run the relevant test/build/smoke command and confirm output matches the claim. Evidence first, assertion second.
## Output format
- Stay in Markdown by default for every reply, short or long.
- Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Detection is opportunistic — the BooChat backend tags the assistant message as an HTML artifact, opens it in a sandboxed pane, and offers Download. Do not emit HTML unprompted; long Markdown is the right answer for most explanatory output.
- When asked to produce HTML, avoid generic AI aesthetics: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font. Prefer interactive controls (sliders / knobs / SVG / side-by-side diffs) over passive prose-in-HTML. Pattern reference: claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html (Thariq Shihipar, May 2026).
- The HTML artifact is rendered in a sandboxed iframe with `connect-src 'none'``fetch()`, WebSockets, and tracking pixels do not work. All logic must be client-side.
## Convention: rules vs recipes
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
## Verification discipline
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
## Known limitations
- Codecontext re-analyzes the project graph on each call against a different target_dir. First call to a new project may take 1-3 seconds; subsequent calls to the same project return in ~10ms.

View File

@@ -1,27 +1,117 @@
# BooCoder
# BooCoder — Container Guidance
> (Stub. v2.0 implementation pending. This file documents the intended contract.)
You are BooCoder, a write-capable coding agent. You can read AND modify files within the project scope.
## Capabilities
## You can
- Everything in `BOOCHAT.md`
- Write tools (pending): `write_file`, `edit_file`, `delete_file` (all gated through pending-changes sandbox)
- Shell (pending): `run_command` (Docker-isolated per-session)
- Read files (view_file, list_dir, grep, find_files)
- Edit files (edit_file, create_file, delete_file) — all changes queue in pending_changes
- Apply pending changes to disk (apply_pending)
- Revert applied changes (rewind)
- Dispatch tasks to external agents (dispatch_external_agent)
- Use MCP tools from configured servers
## Constraints
## You cannot
- All writes land in a pending-changes virtual layer; nothing touches the real filesystem until `/apply`
- `run_command` executes inside the session sandbox, not the host
- No git commits, pushes, or pulls — Sam owns those
- Stop and ask before destructive operations (delete, overwrite, recreate)
- Write outside the project root (path-guard enforced)
- Write to secret files (.env, *.pem, id_rsa*, credentials.json)
- Apply changes without explicit user approval (unless auto-apply is enabled per task)
- Push to git remotes
- Access the internet except via configured MCP servers
## Pending changes discipline
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.
## Behavior
- Show a diff preview before any write
- Group related edits into a single `/apply` batch
- If a tool fails, surface the error verbatim — don't paper over it
- Show diffs clearly. Explain what you're changing and why.
- For multi-file changes, organize as a logical unit (one task = one coherent change set).
- If uncertain about scope, use smaller edits and verify between steps.
- Cite file paths + line numbers for context.
- Verify before reporting work complete: run the relevant test/build/smoke and confirm output matches the claim. Evidence first, assertion second.
## Convention: rules vs recipes
## Verification discipline
Always-true rules live here, in `BOOCHAT.md`, and in `CLAUDE.md` (100% present each turn). On-demand recipes live in `/data/skills/` (roughly 6% invoke rate in multi-turn per Codeminer42, 2026). Don't file workflow rules as skills — they misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices).
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
## Provider lifecycle (v2.3)
BooCoder's coding agents are a **config-backed registry**: built-ins live in `provider-registry.ts`, and `data/coder-providers.json` layers overrides + custom entries on top. Registration ≠ installation — the config lists what you *want*; a probe reports what's *ready*.
### Config file: `data/coder-providers.json`
Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **gitignored** — it's live runtime config that the coder reads *and writes* (UI toggles `PATCH` it), so tracking it would churn `git status`. The tracked reference is `data/coder-providers.example.json`; copy it to `coder-providers.json` to seed overrides. A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup.
```json
{
"providers": {
"goose": { "enabled": false },
"amp-acp": {
"extends": "acp",
"label": "Amp",
"description": "ACP wrapper for Amp",
"command": ["amp-acp"],
"enabled": true
}
}
}
```
Per-provider override fields (all optional):
| Field | Meaning |
|-------|---------|
| `extends` | `"acp"` — required for a NEW (custom) provider; built-in overrides omit it |
| `label` | Display name (required for custom) |
| `description` | Sub-label shown in the picker / settings |
| `command` | `[binary, ...args]` to spawn (required for custom; overrides a built-in's default argv) |
| `env` | Extra env vars merged into the spawn |
| `enabled` | Default `true`; `false` hides it from the composer |
| `order` | UI sort key |
| `models` / `additionalModels` | Replace / merge onto the discovered model list |
A PATCH to one provider id **replaces that id's override object wholesale** (per-id shallow merge), so to flip a single field keep the rest; a `null` value for an id deletes its override (reverts to the built-in default).
### Refresh contract
The snapshot is cached and a provider's cold ACP probe (tier-2) is **skipped** while `available_agents.last_probed_at` is younger than `PROVIDER_PROBE_TTL_MS` (default `86400000` = 24h). Opening the composer is therefore fast and does not re-probe. To force a cold re-probe (after installing a CLI or editing models): **`POST /api/providers/refresh`** (the Refresh button in the Providers settings tab), which clears the cache and re-probes.
### Enable / disable
Two ways:
- **Settings → Providers tab** — open the sidebar → **Settings****Providers**: toggle a provider on/off, refresh it, or open its diagnostic. (Earlier builds exposed a gear in the composer; that control was moved into Settings.)
- **Edit the config** (`"enabled": false`) then `POST /api/providers/refresh`.
A **disabled** provider leaves the composer's provider picker but stays listed in the Providers tab (status "Disabled") so you can re-enable it. **Native `boocode` is always-on** — an `enabled:false` on it is ignored (with a warn log) and it is never rendered as toggleable.
### Adding a custom ACP provider
- **Catalog modal**: Providers tab → **Add provider** → pick an entry → it PATCHes the config (`extends:'acp'` + label + command, enabled) and refreshes that provider.
- **Hand-edit** `data/coder-providers.json`: add an id with `extends:'acp'`, `label`, and `command`, then `POST /api/providers/refresh`.
Either way, **adding to config does NOT install the binary.** Until the CLI is on `PATH` the provider shows **"Not installed"** (status `unavailable`) and does not appear in the composer picker.
### Known limitation — subset refresh
`POST /api/providers/refresh` accepts an optional `{ "providers": ["id", ...] }` body and returns a `refreshed` count scoped to that subset — **but the underlying cold re-probe currently covers ALL installed providers**, not just the requested subset. True per-provider force is a future change (it needs a snapshot-internal parameter). This is intentional for now, not a bug: a subset refresh still re-probes everything; only the reported count is scoped.
### Deploy + smoke
Two deploy targets:
- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
- **Web UI (container):** `docker compose up --build -d boocode`
Green gate (verified across phases 15): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`.
Smoke (via Tailscale):
```bash
curl http://100.114.205.53:9502/api/providers/snapshot # lists every registered provider
curl http://100.114.205.53:9500/api/coder/providers/config # raw config, through the BooChat proxy
# Settings → Providers: disable goose → it leaves the composer picker, stays in the tab
# POST refresh → models repopulate; Add a catalog entry → it appears after refresh (unavailable until its CLI is installed)
```

View File

@@ -2,6 +2,154 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.5.15-acp-path-guard — 2026-05-29
Security fix + repo hygiene. Fixes a path-traversal in the ACP filesystem bridge (`acp-client-fs.ts`, flagged by the automated push security review): the worktree guard used an unbounded `startsWith(resolve(worktreePath))`, so a sibling path sharing the worktree as a string prefix (`<worktree>-evil/…`) escaped the scope — and `writeWorktreeTextFile` writes to disk directly (no `pending_changes` gate), so a confused/buggy ACP agent could write outside its worktree. Now uses a separator-bounded check matching `write_guard.ts` (`resolve()` + `startsWith(root + sep)` / `=== root`) via a shared `resolveInWorktree`, with a regression test covering `../` traversal and the sibling-prefix bug. Symlink-swap/`O_NOFOLLOW` hardening was intentionally skipped — consistent with `write_guard`'s no-realpath stance, and the agent already runs with host FS access so this is a containment guard, not a trust boundary. Separately, stops tracking the live `data/coder-providers.json` (it's runtime config the UI reads *and writes* on provider toggles, which churned `git status`) — it's now gitignored with a tracked `data/coder-providers.example.json` reference; the loader falls back to built-ins-only when the live file is absent. The provider-type duplication (coder ↔ web) stays guarded by the existing text-identity `provider-types-parity.test.ts` — a shared package was considered and declined (drift is already prevented; not worth the Docker/build-order risk at solo scale).
## v2.5.14-claude-md — 2026-05-29
Docs-only — CLAUDE.md session-learnings update, no code. Adds gotchas surfaced while shipping the v2.3 provider-lifecycle batch: the host `boocoder.service` keeps running the old process after `pnpm -C apps/coder build` (stale-process tell = new routes 404 while old routes 200, restart don't re-debug); the `boocode` container `build: .` deploys the working tree, so web edits are live on the Vite dev server but not production until `docker compose up --build -d boocode`; `PATCH /api/providers/config` replaces a provider's override wholesale (send `{...existing, enabled}` or a custom ACP entry's command is wiped) and `data/coder-providers.json` is live config not to be committed as code; external agents dispatch one-shot with no context/token tracking (only native `boocode` tracks ctx; OpenCode-as-server is the unshipped `v2-6-persistent-agent-sessions` plan); the `ui/` primitive inventory with `button role=switch` / Dialog fallbacks for the absent switch/sheet; and the mobile Dialog-with-list scroll-containment recipe. Also backfills previously-uncommitted doc bullets for the `v2.5.7``v2.5.11` coder work (provider-type parity test, async ACP command discovery, AgentComposerBar `installed` filter, provider-registry path disambiguation).
## v2.5.13-provider-lifecycle-phase5 — 2026-05-29
Closeout of the v2.3 provider-lifecycle batch — the web UI (Phase 5) plus docs (Phase 6). Provider management moved into **Settings → Providers**: a tab listing every registered provider with a status badge (Available / Disabled / Not installed / Error / Loading), an enable/disable toggle, a per-provider refresh, and a plaintext diagnostic; toggling sends the provider's *full* override (preserving a custom ACP entry's command under the wholesale-replace PATCH merge) then refetches the snapshot. The composer's provider picker now filters to `enabled && (status === 'ready' || 'loading')`, so disabled and unavailable providers drop out of the picker and are managed only in settings (native `boocode` always shows). A curated ACP catalog (`apps/web/src/data/acp-provider-catalog.ts`) + `AddProviderModal` register custom providers via `PATCH /api/providers/config` then a subset refresh, and the web client gained `getProvidersConfig` / `patchProvidersConfig` / `refreshProviders` / `getProviderDiagnostic`. Two mobile fixes ship alongside: the Settings pane is now reachable on phones (opening it pushes `?pane=` atomically so the mobile URL-sync effect keeps it active instead of snapping back to the chat pane), and the Add-provider modal caps to the viewport with a single `overscroll-contain` scroll region so the list scrolls instead of dragging the whole modal. This completes the arc begun in `v2.5.4-provider-lifecycle-phase1` (config-backed registry over the built-ins) → `v2.5.5-provider-lifecycle-phase2` (loading/unavailable snapshot lifecycle + tier-2 probe TTL gate) → `v2.5.6-provider-lifecycle-phase3` (generic `resolveLaunchSpec` ACP dispatch) → `v2.5.12-provider-lifecycle-phase4` (config GET/PATCH, subset refresh, diagnostic HTTP API). Docs landed in `BOOCODER.md` (config file, refresh contract, enable/disable, custom ACP, the honest subset-refresh known limitation) and `docs/DEFERRED-WORK.md` §2 is marked addressed; the remaining Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
## v2.5.12-provider-lifecycle-phase4 — 2026-05-29
Phase 4 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §6): the HTTP API to read, patch, refresh, and diagnose providers. `routes/providers.ts` gains `GET /api/providers/config` (the raw loaded `CoderProvidersFile`), `PATCH /api/providers/config` (a partial providers map — an id's override object is replaced wholesale, a `null` value deletes it), an optional `{ providers?: string[] }` body on `POST /api/providers/refresh` (the `refreshed` count reflects the requested subset; the force probe itself still covers all installed providers, since per-provider force is a snapshot-internal change left to a later phase), and `GET /api/providers/:id/diagnostic` returning JSON `{ diagnostic: string }` — a read-only report (resolved def, install_path, last_probed_at, enabled, `which` availability, last cached probe error) with no probe spawn. PATCH correctness is the whole story: the order is validate→save→reload→clear, a malformed body or an invalid merged config returns 422 without writing the file, and a `save()` failure returns 500 without reloading the registry or clearing the snapshot cache, so on-disk and in-memory state can never diverge. New pure `mergeProviderConfigPatch` + `ProviderConfigPatchSchema` in `provider-config.ts`, a read-only `peekSnapshotEntry` cache accessor (source of the diagnostic's last-error — no probe/cache logic change), and a new `provider-diagnostic.ts` formatter. The web client gains `api.coder.getProvidersConfig` / `patchProvidersConfig` / `refreshProviders(providers?)` / `getProviderDiagnostic`, with mirrored `ProviderOverride` / `CoderProvidersFile` / `ProviderConfigPatch` types; the existing `/api/coder/*` proxy blanket-forwards the new routes with no change. +28 tests (134 coder total: pure merge/validate, the diagnostic formatter, and `app.inject` route tests proving the 422-no-write and save-fail-no-divergence guards). The diagnostic returns JSON rather than the §8 plaintext so it flows through the JSON `request` client helper (reconciling design §6.4's `{ diagnostic }` with §8's string report). No UI (Phase 5). Builds on `v2.5.6-provider-lifecycle-phase3`.
## v2.5.11-claude-skill-discovery — 2026-05-29
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
## v2.5.10-opencode-live-commands — 2026-05-29
Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred.
## v2.5.9-agent-slash-commands — 2026-05-29
Segmented per-agent slash menu in the coder pane, plus cross-agent skills. The `/` menu now shows two labeled groups — **the active agent's commands first** (opencode/claude/qwen manifest + live ACP `available_commands`), **BooCoder skills second** — instead of always showing BooCoder's skills regardless of provider. `SlashCommandPicker` gains an opt-in `groups` prop (the flat `items` path is unchanged, so **BooChat's menu is byte-identical** — parity verified: no BooChat caller passes the grouped prop, and the skills lookup / invocation routing are untouched); `ChatInput` takes `slashGroups`; `CoderPane` builds the groups from the selected provider's commands + skills. Skills now **run under the selected agent**: the coder `skill_invoke` route accepts a `provider` and, when external, injects the server-side skill body into a dispatched task (instead of native inference) — so a skill like brainstorming executes through opencode/claude with the body kept server-side, mirroring the messages-route external dispatch. Also folds in the earlier initial-chat fix: invoking a skill on the landing chat now runs the same create-chat → assign-to-pane → invoke transition as a text send (`handleLandingSkill`) rather than invoking invisibly without a pane transition (the blank-screen repro). Web tsc + coder build clean.
## v2.5.8-mobile-composer-row — 2026-05-29
Mobile fix for the `AgentComposerBar`: the refresh button was wrapping to a second line. Root cause was layout order, not width — the status dot carried `ml-auto` (pinned to the far-right edge) and the refresh button followed it in DOM order, so it overflowed and wrapped. The dot + refresh are now one right-aligned (`ml-auto`) unit, keeping the refresh on the top line. Additionally, `CompactPicker` gained an `iconOnly` option and the Mode (permission) picker now renders icon-only on mobile (shield + chevron, no "Bypass"/"Plan" text label; `aria-label`/`title` and the tap-to-open list still convey the value) to free row width. Desktop is unchanged (full labels). Web-only change.
## v2.5.7-claude-models-and-picker-fix — 2026-05-29
Two provider-layer changes. **(1) Fix the empty provider picker** — a regression from `v2.5.5` (Phase 2): on a cache miss `getProviderSnapshot` returned synchronous `installed:false` `loading` entries, which `AgentComposerBar` filters out (`e.installed && e.status !== 'error'`); with the client-side poll deferred to Phase 5, a single fetch landed on `loading` forever and no providers appeared. `getProviderSnapshot` now awaits the build and returns terminal entries (the sync `loading` return is deferred until Phase 5 ships the poll); builds stay fast via the tier-2 cold-probe skip. **(2) Claude models** — the list was a hardcoded 2-entry static list (Opus 4 / Sonnet 4, May 2025), and the v2.3 config schema's `models`/`additionalModels` were parsed but never wired. `buildResolvedRegistry` now carries config `models` (replace) + `additionalModels` (merge) onto `ResolvedProviderDef`, and `provider-snapshot` applies them to every ready model list — so `/data/coder-providers.json` can add or replace any provider's models with no code change. Claude `staticModels` bumped to `opus`/`sonnet`/`haiku` latest-aliases plus pinned `claude-opus-4-8` / `claude-sonnet-4-6` / `claude-haiku-4-5-20251001` (passed verbatim to `claude --model`; the CLI accepts both aliases and pinned full names). +2 unit tests (109 total). Builds on `v2.5.6-provider-lifecycle-phase3`.
## v2.5.6-provider-lifecycle-phase3 — 2026-05-29
Phase 3 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §5): generic ACP dispatch. `acp-spawn.ts` gains `resolveLaunchSpec(resolved, installPath)` — it consults the resolved registry's `launchCommand` (a config override or a custom-ACP entry's command) first, falling back to the kept `resolveAcpSpawnArgs` switch for built-ins. `acp-dispatch.ts` now spawns `spec.binary`/`spec.args` with `env: { ...process.env, ...spec.env }` instead of the hardcoded per-name argv, and `dispatcher.ts` loads the resolved def by `task.agent` and passes it through. This lets config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (claude/opencode/goose/qwen) is **byte-identical** to pre-v2.3 — proven by a regression test asserting opencode→`['acp']`, goose→`['acp']`, qwen→`['--acp']`, binary=`installPath ?? id`, and empty config env → plain `process.env`. One deliberate deviation from the spec's literal `!installPath → null`: the `installPath ?? id` fallback is preserved so a missing install path still spawns the bare agent name as before. `setSessionMode`/permission/streaming and the dispatcher poll/NOTIFY/running-guard are untouched. 7 new `acp-spawn.test.ts` cases. No routes/UI (Phase 4+). Builds on `v2.5.5-provider-lifecycle-phase2`.
## v2.5.5-provider-lifecycle-phase2 — 2026-05-29
Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 530s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`.
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §23): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.
## v2.5.3-remove-cursor-copilot — 2026-05-29
Retire the cursor and copilot providers from BooCoder entirely. Removes their `acp-spawn` argv cases, `provider-manifest` mode blocks + manifest keys, `provider-commands` command maps, the `provider-snapshot` cursor model-CLI branch (and the now-orphaned `exec`/`promisify` imports), and the `agent-probe` copilot ACP-detect branch; deletes the dead `cursor-models.ts` module and its test. The `PROVIDERS` registry array already lacked both entries, so only the doc comment needed correcting. Built-ins unchanged: claude, opencode, goose, qwen, native boocode. Standalone cleanup; pairs with `v2.5.4-provider-lifecycle-phase1` which builds on it.
## v2.5.2-coder-ux-fixes — 2026-05-29
Working-tree checkpoint bundling this session's fixes with in-progress coder UI work. This session: the BooCoder dispatcher now reacts to new tasks immediately via a Postgres `LISTEN/NOTIFY` (`tasks_new`) AFTER INSERT trigger, with the poll loop kept at 2s as a missed-notification fallback (`dispatcher.ts`, `apps/coder/src/schema.sql`); the mobile nav drawer no longer sticks open after returning to a backgrounded tab — `useViewport` re-syncs on `pageshow`/`visibilitychange`/`resize`/`orientationchange` (iOS reported a stale width on bfcache restore, leaving `isMobile=false`); assistant reasoning renders as a collapsible "Thinking" block in `MessageBubble`, surfacing ACP `agent_thought_chunk` from opencode/goose/qwen and native `reasoning_parts`; paste-to-chip inserts pasted text verbatim instead of wrapping it in a code fence; and a "New file from pasted text" affordance in the RightRail browser queues a `pending_changes` create through the new `POST /api/sessions/:id/pending/create` endpoint, paired with a fix repointing the DiffPanel's dead approve/reject calls to the real `/api/pending/:id/apply` and `/reject` routes. Also carried in the tree but not authored this session: the CoderPane `ChatInput` migration and `AgentComposerBar` refinements, plus backend tweaks to `auto_name`, inference `tool-phase`/`turn`, `secret_guard`, and `provider-registry`. Ships the `v2-6-persistent-agent-sessions` openspec proposal/design/tasks (free agent-switching with per-agent memory, opencode-as-server) as planning docs only — the feature is unimplemented and reserves the `v2.6.0` tag for it. Build green across server/coder/web; server suite 531 passing. (CHANGELOG note: the v2.3v2.5.1 entries were never backfilled and remain absent above.)
## v2.2.2-xml-placeholder-reject — 2026-05-26
Reject placeholder XML tool args at parse time in `extractToolCallBlocks` (`xml-parser.ts`). Drops calls when any string arg is `...`, empty/whitespace, `<path>`, `<file>`, `placeholder`, or angle-bracket sentinels; appends the raw XML block to flushed prose instead of silently deleting it. Fixes qwen3.6 answer-then-spurious-tools tail that caused duplicate assistant rows (full answer + failed `xml_call_*` tools + regenerated answer). Four new tests in `xml-parser.test.ts`. Known nit: rejection logs via `console.debug` instead of pino — filed in `docs/DEFERRED-WORK.md` §6 for a later cleanup.
## v2.2.1-pane-scoped-chats — 2026-05-26
Follow-up fixes on the v2.2 Paseo provider stack. Pane-scoped chat resolution: `resolveChatId(sql, sessionId, paneId)` reads `sessions.workspace_panes`, requires `pane_id` on coder POST routes, and creates a scoped chat per coder/terminal pane instead of falling back to the session's first open chat (which fused BooCoder writes into the BooChat pane). Client `useWorkspacePanes` seeds new coder/terminal panes with dedicated chats on create, hydrate, and workspace sync; `CoderPane` blocks send until seeded and filters WS frames + `GET /messages?chat_id=` to that chat. External-agent tool UI: new `CoderMessageList` renders BooChat-style `ToolCallLine` timeline (tools before answer text on combined ACP rows). WS user-delta handling replaces content instead of appending (fixes garbled duplicate user messages when optimistic UI met full-body deltas). BooChat inference: `buildMessagesPayload` strips orphan assistant `tool_calls` without matching `tool` rows and skips stray tool rows when the owning assistant turn is incomplete (fixes "Tool results are missing for tool calls" on shared chats with ACP history). Pairs with `v2.2-paseo-providers`.
## v2.2-paseo-providers — 2026-05-26
Paseo-equivalent provider stack for BooCoder. Seven providers (boocode, cursor, claude, opencode, goose, qwen, copilot) with snapshot API (`provider-snapshot.ts`, ACP cold probe, per-provider model merge, cursor models from ACP). Frontend `AgentComposerBar` replaces `ProviderPicker` — provider / mode / model / thinking in the coder composer; `SlashCommandPicker` + `useProviderSnapshot` hook. ACP dispatch rewritten (`acp-dispatch.ts`, `acp-stream.ts`, `acp-spawn.ts`, `agent-turn-persist.ts`, `acp-tool-snapshot.ts`) with Paseo merge/stream/persist pattern, inline `PermissionCard` prompts, and `reasoning_delta` WS frames. Agent slash-command hints via ACP `available_commands_update` cached in `agent-commands-cache.ts` + `AgentCommandsHint`. Arena and MCP entry points accept `mode_id` / `thinking_option_id`. SSH helpers removed; all host exec via `host-exec.ts` direct spawn. Server adds coder proxy route + shared skill invoke. New tests: acp-derive, acp-tool-snapshot, cursor-models, provider-commands, provider-snapshot, agents. Docs: `AGENTS.md`, `docs/ARCHITECTURE.md`, openspec `v2-2-paseo-providers`.
## v2.1.1-roadmap-cleanup — 2026-05-25
Roadmap reconciliation, README updates, and openspec archive housekeeping. No runtime behavior changes.
## v2.1.0-provider-picker — 2026-05-25
Provider picker: BooCoder moves from Docker container to host systemd service (`boocoder.service`). All agent dispatch (ACP + PTY) switches from SSH tunnel to direct `spawn`/`exec` — no more `sshSpawn`/`sshExec`/`sshSpawnWithStdin` (marked `@deprecated`). New provider registry (`provider-registry.ts`) with 5 providers (boocode, opencode, goose, claude, qwen), per-provider model discovery (llama-swap for ACP agents, `~/.qwen/settings.json` for qwen, static for claude), and `agent-probe.ts` runs direct `which`/`exec` instead of SSH. `GET /api/providers` route assembles the provider list with installed status, models, and transport (ACP→PTY fallback if `supports_acp` is false). Frontend `ProviderPicker` component in CoderPane header lets users pick provider/model per message; messages route through `tasks` row for external providers instead of inference enqueue. Smart scroll: `MessageList` only auto-scrolls when user is near bottom (150px threshold). DB schema adds `models`, `label`, `transport` columns to `available_agents`. Bug fixes: `loadContext` SELECT now includes `allowed_read_paths` (cross-repo read grants were silently failing), cap hit sentinel insertion moved before `buildMessagesPayload` call.
## v2.0.5 — 2026-05-25
FAST_MODEL routing: optional `FAST_MODEL` env var routes cheaper models (titles, summaries, labeling) to a small model on llama-swap (e.g. `nemotron-nano-4b`) instead of loading the 35B for 20-token calls. Falls back to session model or DEFAULT_MODEL. Tool-use summaries: `runCapHitSummary` now writes the cap_hit sentinel before building the summary payload (bug fix — sentinel was written after, causing it to appear after the summary text in the message list). Qwen Code dispatch: `qwen -p "<task>" --output-format stream-json` via PTY (non-interactive mode, no `--yolo` flag needed). Arena: `POST /api/arena` dispatches the same task to N models/agents in parallel, each with its own task + worktree; `GET /api/arena/:id` for results; `POST /api/arena/:id/select/:task_id` picks winner.
## v2.0.4-hardening — 2026-05-25
Path-guard fuzz suite: 25+ traversal-attack tests covering ../ sequences (all depths), encoded traversal (%2e%2e), null byte injection, absolute path escape, prefix-without-separator, backslash traversal, and the full secret-file deny list (.env, *.pem, id_rsa*, *.key, credentials.json, *.kdbx, .netrc). Plus 5 valid-path positive tests confirming normal writes aren't blocked and 5 edge-case tests (empty, whitespace-only, very long path, triple-dot, multiple slashes). Null-byte and whitespace-only guards added to `resolveWritePath` (previously only checked empty string). DB-integration test skeleton for pending_changes full-cycle (queue create/edit/delete, apply, rewind) gated on DATABASE_URL via `describe.runIf`. Production readiness verified: all services healthy, all builds clean, 57 tests passing (23 existing + 34 new).
## v2.0.3 — 2026-05-25
CLI client (`apps/coder/src/cli.ts`, 249 lines) for headless agent interaction. Human inbox view (`human_inbox` view) surfaces tasks in `blocked`/`failed` state. Cost tracking: `tool_cost_stats` view with per-tool 100-call rolling window. `new_task` tool (Boomerang pattern): creates tasks with project context and optional arena contestants. `check_task_status` and `list_tasks` tools for task lifecycle management. Stats routes (`GET /api/stats`) for cost aggregation. Dispatcher extended to support new task states.
## v2.0.2 — 2026-05-25
BooCoder MCP server (`mcp-server.ts`, 201 lines) exposing 6 write-capable tools over stdio: `edit_file`, `create_file`, `delete_file`, `view_pending_changes`, `apply_pending`, `rewind`. Registered in `apps/coder/src/index.ts` as an MCP stdio server. Enables external agents (opencode, claude, qwen) to call BooCoder's write tools through the MCP protocol.
## v2.0.1 — 2026-05-25
ACP dispatch (`acp-dispatch.ts`, 271 lines): runs ACP-capable agents (opencode, goose) via SSH tunnel wrapping stdio into NDJSON streams for `@agentclientprotocol/sdk` JSON-RPC sessions. PTY dispatch (`pty-dispatch.ts`, 139 lines): runs non-ACP agents (claude, qwen) via SSH with stdin pipe for non-interactive mode. Worktree management (`worktrees.ts`, 118 lines): per-task git worktree creation and cleanup. SSH helper (`ssh.ts`, 126 lines): `sshSpawn`, `sshExec`, `sshSpawnWithStdin` for host command execution. Dispatcher extended to route tasks to ACP vs PTY based on agent capability. Agent probe updated to verify ACP support.
## v2.0.0-final — 2026-05-25
Dispatcher (`dispatcher.ts`, 191 lines): task queue with polling loop, Path A (native inference) and Path B (external agent dispatch). Task routes (`tasks.ts`, 138 lines): CRUD for tasks with state transitions. Agent probe (`agent-probe.ts`, 51 lines): startup scan of host for installed agents (opencode, goose, claude, pi, qwen), version detection, ACP capability verification. Schema adds `tasks` table. CLAUDE.md updated with v2.0.0 architecture docs covering BooCoder, DB rename, MCP config, workspace deps.
## v2.0.0 — 2026-05-25
BooCoder frontend: `CoderPane.tsx` (432 lines) as a `'coder'` pane type within BooChat's SPA — chat pane + diff pane (pending changes) + session picker. Standalone fallback SPA in `apps/coder/web/` (Vite + React) served at `:9502` directly. Session streaming via `useSessionStream` WS hook. API client with typed endpoints. Workspace pane persistence via `useWorkspacePanes`. Server routes for pending changes (`PATCH/POST /api/coder/sessions/:id/pending`). Verification discipline rules + chat naming from assistant response.
## v2.0.0-beta — 2026-05-25
Write tools: `edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind` — queue in `pending_changes` table, nothing hits disk until applied. `write_guard.ts` validates paths (resolve + prefix-check, no realpath for creates). Inference loop integration via `inference_context.ts` (bridges inference turn state to tool execution). API routes: `messages.ts` (POST /api/coder/sessions/:id/messages), `pending.ts` (GET/POST /api/coder/sessions/:id/pending). WebSocket support (`ws.ts`) for real-time pending changes updates. Tool adapter (`adapter.ts`) converts inference tool calls to tool execution. Write guard tests (115 lines). Server-side inference loop wired to BooCoder tools.
## v2.0.0-alpha — 2026-05-25
BooCoder foundation: Docker container (`apps/coder/Dockerfile`), docker-compose service, host env file. Schema: `sessions`, `chats`, `messages`, `pending_changes`, `tasks`, `message_parts` tables. DB renamed from `boocode` to `boochat`. Config module, PostgreSQL connection (porsager/postgres). Initial Fastify server with health endpoint. BOOCODER.md guidance file. Implementation plan (8 phases). Proposal updated with AGENTS.md extensions, Boomerang pattern, observation hooks.
## v2.0-proposal — 2026-05-24
v2.0 proposal: BooCoder write tools, pending-changes queue, ACP dispatch, MCP server. Openspec proposal (`proposal.md`, 274 lines) and task breakdown (`tasks.md`, 130 lines) defining the v2.0 feature scope — write-capable coding agent with file operations, external agent dispatch via ACP/PTY, and MCP server for tool exposure.
## v1.16.0-codesight-merge — 2026-05-24
Ports codesight's highest-value analysis capabilities into the codecontext sidecar as 4 new MCP tools. Tier 1 (graph queries on existing edges, no re-parsing): `get_blast_radius` (BFS reverse-edge traversal — "what breaks if I change this file?", with depth tracking) and `get_hot_files` (most-imported files ranked by incoming edge count — change-risk indicators). Tier 2 (tree-sitter AST re-parsing on demand): `get_routes` (Fastify/Express HTTP route extraction with method, path, file, line, inferred tags for db/auth/cache) and `get_middleware` (middleware registration detection via import-name heuristics and app.register/addHook/setErrorHandler patterns, classifying as auth/cors/rate-limit/security/error-handler/logging/validation). All 4 tools use `defer s.graphMu.RUnlock()` for consistent mutex discipline (reviewer caught that the initial implementation released the lock early on the Tier 2 tools). Route object-property extraction delegates to `extractStringValue` for template-literal handling (reviewer catch). codecontext sidecar rebuilt from `/opt/forks/codecontext` commit `b19e646`, tagged `v1.16.0-codesight-merge`. BooCode wrapper tools follow the existing codecontext pattern — 4 new files in `apps/server/src/services/tools/codecontext/`, registered in ALL_TOOLS. 29 new Go tests + 363/363 BooCode server tests passing. No schema changes, no frontend changes.
## v1.15.0-mcp-multi — 2026-05-24
Multi-server MCP client with stdio + Streamable HTTP transports, JSON config file, and per-agent tool glob patterns. Generalizes the v1.14.1 single-server Context7 PoC into a registry of named MCP servers with per-server graceful degradation. JSON config at `/data/mcp.json` (bind-mounted alongside `AGENTS.md`) matches opencode's `mcpServers` schema shape so server entries are copy-pasteable. Config file missing = no MCP (opt-in by file presence). Stdio transport spawns a persistent subprocess via the SDK's `StdioClientTransport` with NDJSON framing; Streamable HTTP reuses the v1.14.1 pattern via `StreamableHTTPClientTransport`. Tool prefix generalized from `context7_<name>` to `<serverName>_<toolName>` with a reverse `toolToServer` map for dispatch routing. Per-agent AGENTS.md `tools:` field now supports glob patterns (`context7_*`, `!web_*`) via `matchToolGlob` (last-match-wins, `!` prefix denies); replaces the exact-match `.includes()` in `stream-phase.ts`. Glob patterns bypass `ALL_TOOL_NAMES` validation in the parser since MCP tool names aren't known at parse time. `refreshToolNames()` in `agents.ts` rebuilds the `DEFAULT_TOOLS` snapshot after `appendMcpTools` so agents without explicit `tools:` lists see MCP tools — reviewer caught that the module-load-time snapshot would permanently exclude late-registered tools. Read-only invariant preserved: all MCP tools with `readOnlyHint: false` rejected at discovery. Result size capped at 5MB. Shutdown hook closes all transports. v1.14.1 env vars (`MCP_CONTEXT7_URL`, `MCP_CONTEXT7_API_KEY`) removed — superseded by the config file. Default `data/mcp.json` ships with Context7 disabled; flip `"enabled": true` to activate. 363/363 server tests passing (27 new: multi-server wrapping, glob matching, routing, degradation). No schema changes, no frontend changes.
## v1.14.1-mcp-poc — 2026-05-23
Single-server MCP client PoC against Context7. New `apps/server/src/services/mcp-client.ts` (~200 lines) wraps `@modelcontextprotocol/sdk` v1.29.0 with Streamable HTTP transport. On startup (when `MCP_CONTEXT7_URL` is set), connects to Context7, discovers tools via `tools/list`, wraps each as a `ToolDef` prefixed `context7_<name>`, and appends to `ALL_TOOLS` (alpha-sorted for prompt-cache stability). `appendMcpTools()` in `tools.ts` handles the late-registration; `ALL_TOOLS` changed from `ReadonlyArray` to mutable to support it. Read-only invariant guard rejects any MCP tool with `readOnlyHint: false` (MCP SDK v1.29.0 uses `readOnlyHint`, not `readOnly`). Tool dispatch is transparent — `executeToolCall` routes MCP tool calls through the `ToolDef.execute` wrapper, which strips the `context7_` prefix before calling the MCP server. Graceful degradation: MCP server down at startup → zero tools, warn log; MCP server down mid-session → error-shaped result, model self-corrects. Result size capped at 5MB with truncation (matches native `view_file`'s `MAX_FILE_BYTES`). Adversarial review caught that the Zod `.default('https://...')` on the URL config made MCP effectively always-on instead of opt-in — fixed by removing the default. 348/348 server tests passing (16 new mcp-client tests covering tool wrapping, read-only guard, name prefixing, content extraction). No schema changes, no frontend changes. Proves the MCP tool-discovery → tool-call → result-render loop end-to-end before the full v1.15 port.
## v1.14.0-outer-loop — 2026-05-23
Converts the inference engine's ad-hoc `executeToolPhase → runAssistantTurn` recursion into an explicit `while` loop with a configurable step cap. 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` is the hard ceiling (4x the 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). `executeToolPhase` no longer recurses — returns a `ToolPhaseResult` struct (`action: 'continue' | 'paused' | 'synthesis_done'`) so the caller (the while loop) decides whether to continue or break. `steps: 0` is handled as "no tool calls allowed" — one text-only stream phase, tool calls ignored with a warn log. Step-cap hits produce a sentinel summary (reuses `cap_hit` kind so `CapHitSentinel.tsx` renders it without frontend changes; text distinguishes "Step limit reached" from "Tool budget exhausted"). Doom-loop check migrated from pre-recursion position to top of loop body — same predicate (`detectDoomLoop`), same threshold (3 identical calls), `break` instead of `return`. `step_start` parts are in the schema CHECK but not emitted as message_parts in v1.14 — writing to the assistant message before the stream phase creates a sequence-0 collision with `partsFromAssistantMessage`; a structured log line is emitted instead. Adversarial review caught the collision pre-deploy. 332/332 server tests passing; no frontend changes. Pairs with `v1.13.20-drop-legacy-cols` (parts is now the sole source of truth, and this batch's loop operates entirely through parts).
## v1.13.20-drop-legacy-cols — 2026-05-23
Final phase of the v1.13.0 strangler-fig migration. Removes the dual-write into `messages.tool_calls` / `messages.tool_results` JSON columns and drops the columns themselves; `message_parts` is now the only source of truth for tool-call and tool-result data. 10 dual-write sites stripped (5 in `tool-phase.ts`, 2 in `routes/skills.ts`, 2 in `routes/messages.ts`, 1 in `routes/chats.ts` fork-clone) — recon's grep-driven inventory caught 2 sites beyond the original v1.13.2 roadmap count. `messages_with_parts` view simplified to parts-only subselects (COALESCE fallbacks gone) and rewritten via `CREATE OR REPLACE VIEW` BEFORE the column DROP since Postgres rejects column-drop on view-referenced cols. Adversarial review caught a runtime bug the green test suite missed: `chats.ts:/api/chats/:id/discard_stale` had a `RETURNING ... tool_calls, tool_results, ...` clause referencing the dropped columns; would have crashed on every 60s-no-token-activity recovery in production. Fixed by switching to two-step UPDATE-then-SELECT-from-view so the response keeps the parts-synthesized fields. `Message` API type retains `tool_calls?` / `tool_results?` fields (override on the original v1.13.2 plan) — the view continues to populate them from parts, so the wire shape is unchanged and the frontend needs no updates. v1.12.1 cleanup block (`DROP CONSTRAINT messages_status_check`/`messages_role_check`) removed — those one-shots have done their work. `tool_cost_stats.test.ts` had a direct `INSERT INTO messages` touching the legacy columns that wasn't in the roadmap's inventory; rewritten to parts-table inserts and confirmed semantically faithful. 339/339 server tests passing including the 7 DB-integration tests (live-DB applied the schema migration and ran the parts-only view end-to-end). Pairs with `v1.13.0-ai-sdk-v6` (which introduced the dual-write) and `v1.13.1-B` (which moved the read path to `messages_with_parts`); umbrella `v1.13` tag ships on the same commit.
## v1.13.19-html-artifact-panes — 2026-05-23
Pane-based artifact viewer with on-request HTML support. Every assistant message gets an "Open in pane" icon button (`PanelRightOpen`, mobile 44px tap-target) in `MessageBubble`'s ActionRow; click opens the message in the workspace splitter as either a Markdown pane (Copy raw source + Download `.md`) or an HTML pane (Download `.html` only, no Copy). The HTML path triggers when the model emits a self-contained `<!DOCTYPE html>` or fenced ` ```html` artifact (opt-in only — `BOOCHAT.md` rule says Markdown is default at every length; HTML only on explicit user request like "render this as HTML"). Backend detection in `finalizeCompletion` (`error-handler.ts`) writes a new `message_parts.kind='html_artifact'` row with payload `{html_content, char_count, title}` (`<title>` → first `<h1>` → first 80 chars of inner text). Schema CHECK extended via the v1.13.13 drop-and-re-add pattern. 1MB cap is graceful — over-cap artifacts skip the part write and plain content lands; decision factored into a pure `decideHtmlArtifactWrite` helper so the warn-and-skip branch is unit-testable without mocking the full InferenceContext. Pane state is reference-only (`{chat_id, message_id, title}`) — content is fetched on mount, keeping `sessions.workspace_panes` jsonb small and avoiding 1MB blobs riding the `session_workspace_updated` WS frame. New `services/artifacts.ts` ships slug derivation (Markdown: first `#` heading → first 6 words; HTML: `<title>``<h1>` → inner text) and write helpers that realpath the artifacts directory after `mkdir` to close a symlink-escape gap (`assertArtifactsDirSafe`). `routes/artifacts.ts` exposes POST `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` (writes to `<projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext>`) plus GET `/api/projects/:project_id/artifacts/:filename` with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox` defense-in-depth on LLM-served HTML. iframe sandbox locks to `allow-scripts allow-clipboard-write allow-downloads` with no `allow-same-origin` and uses `srcDoc` (not `src`) for opaque-origin isolation. Frontend extracts `MarkdownRenderer.tsx` from `MessageBubble`'s inline `MarkdownBody` for reuse; `MarkdownArtifactPane.tsx` / `HtmlArtifactPane.tsx` render with loading + error states. 404-vs-real-error discrimination in `openInPane`: a real network/500 failure toasts and bails instead of silently masquerading as a Markdown pane. 31 new server unit tests (slug derivation, detection positive/negative, write helpers, symlink-escape, 1MB cap, real-symlink filesystem test); 332/332 server tests passing; `tsc -p apps/web/tsconfig.app.json --noEmit` clean; `pnpm -C apps/web build` green. Smoke deferred to first deploy.
## v1.13.18-codecontext-file-path — 2026-05-22
Fix: four codecontext wrappers (`get_file_analysis`, `get_symbol_info`, `get_dependencies`, `get_semantic_neighborhoods`) forwarded `file_path` to the sidecar unchanged, but the sidecar's index is keyed on absolute paths — every relative path from the model returned "File not found in graph" (three back-to-back failures in one chat at 17:56 UTC, ~48 s of wasted tool budget). New `resolveProjectPath` helper in `codecontext_client.ts:64-89` realpath-resolves the candidate, applies the same escape check as the existing `target_dir` resolver (matching the error template byte-for-byte except the field name), and falls through with the normalised absolute on ENOENT so the sidecar issues its own self-correctable "File not found" error. Wired into `callCodecontext` once at the args-spread site — all four wrappers benefit without per-wrapper edits. `.trim()` added to all four `file_path` Zod schemas to absorb trailing newlines from model output. Adversarial review caught a P2 escape-bypass: an absolute path with `..` (e.g. `<projectRoot>/../etc/passwd`) that ENOENTs at realpath would slip through the literal prefix-check, fixed by `resolve()`-normalising the absolute branch too. 9 new test cases in `codecontext_client.test.ts` (7 spec scenarios + symlink-out-of-root + absolute-with-`..` ENOENT) plus a 1-line update in `codecontext_tools.test.ts` asserting the new resolved-absolute contract. Pairs with `v1.13.17-cross-repo-reads` — both harden path traversal, but v1.13.18 stays inside the project root while v1.13.17 widens access outside it.
## v1.13.17-cross-repo-reads — 2026-05-22
On-demand read access to paths outside the session's primary project root. Closes the dead-end where `pathGuard` rejected every cross-repo read with no recovery path. New `request_read_access(path, reason)` tool emits an `ask_user_input`-style pause; user picks Allow/Deny via inline chips in `RequestReadAccessCard.tsx`; on Allow, the new `POST /api/chats/:id/grant_read_access` endpoint re-resolves the grant root and appends to `sessions.allowed_read_paths` (new `TEXT[]` column, default empty). Grant unit per design D1 = nearest registered `projects.path` ancestor → else nearest repo-shaped ancestor (`.git/` / `package.json` / `go.mod` / `Cargo.toml`) under `PROJECT_ROOT_WHITELIST` → else refuse without prompting. `pathGuard` extended with an optional `extraRoots` argument threaded from `session.allowed_read_paths` through `executeToolCall` to the four filesystem tools (view_file, list_dir, grep, find_files); `view_file` re-anchors the secret-guard check on `basename(real)` whenever the path resolved via a grant root so `.env` / `id_rsa*` deny still fires across grants. `grant_resolver.ts`'s ancestor walk checks the whitelist invariant on every iteration (not just final parent) so a symlinked input can't escape mid-walk. PATCH `/api/sessions/:id` exposes `allowed_read_paths` only for revocation: zod refines paths to absolute + no traversal markers, and a runtime subset guard (`findUnauthorizedAdditions`) rejects any entry not already present in the row, so a malicious `curl -X PATCH -d '{"allowed_read_paths":["/etc"]}'` 400s instead of bypassing the grant flow. Settings pane gains a per-session revoke list; archiving the session clears grants implicitly. 11 grant_resolver tests pin the symlink-escape-mid-walk guard (Sam's checkpoint-1 ask) and the nearest-project disambiguation; 8 path_guard tests cover extraRoots traversal; 8 sessions PATCH tests cover the subset guard including the `/etc` bypass attempt. Pairs with `v1.13.16-xml-parser` (model now both self-recovers from a wrong tool name AND from a refused path).
## v1.13.16-xml-parser — 2026-05-22
Two-part fix for the model-emitted XML drift the v1.13.15 investigation surfaced. **Parser extension:** `xml-parser.ts` now recognizes the Anthropic `<invoke name="…"><parameter name="…">…</parameter></invoke>` shape alongside the existing Qwen/Hermes `<tool_call><function=…>…</function></tool_call>` shape. qwen3.6-35b-a3b-mxfp4 drifts to the Anthropic format when prompted as an Architect-style agent (Claude Code documentation in its pre-training corpus). Both formats route through the same synthetic-id `xml_call_${idx}` ToolCall path. The existing Qwen parser was tightened to tolerate whitespace around `=` (`<function = name>` shape) so a stray space doesn't get absorbed into the function name. **Unknown-tool recovery hint:** new `tool-suggestions.ts` exports `levenshtein()` + `suggestToolName()` + `formatUnknownToolError()`. When the dispatcher (`tool-phase.ts:executeToolCall`) receives an unknown tool name, the error returned to the model includes a "Did you mean: X?" hint based on Levenshtein distance ≤3 or substring match against `Object.keys(TOOLS_BY_NAME)`. Targets the qwen3.6 drift to `read_file` → suggest `view_file`. Test coverage in `xml-parser.test.ts` (46 tests, all green) covers both parsers, the partial-opener detector for both flavors, the unified extraction helper, and the new error formatter.

View File

@@ -2,6 +2,8 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Cursor agents:** start with `AGENTS.md` (navigation) and `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference.
## What is BooCode
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
@@ -46,7 +48,7 @@ Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `app
- **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`), `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; value back-edges into turn.ts for the runAssistantTurn recursion — cycle safe because deref at call time, not module top-level), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + 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` (v1.13.0 dual-write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts`), `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 threaded through the `executeToolPhase → runAssistantTurn` recursion; reset in `runInference` at user-message boundary. Add new per-turn state to `TurnArgs`, not module-level closures.
- **`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.
@@ -63,12 +65,32 @@ Key services:
- **`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.ts`** — `buildSystemPrompt` 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. `COALESCE`s parts-table rows over the legacy JSON columns, so pre-v1.13.0 history still resolves. Writes still target `messages`; the v1.13.0 dual-write into `message_parts` keeps both halves in sync. New payload-assembly code must use the view — calling `messages.tool_calls` directly will miss anything written post-v1.13.1-B if the JSON column ever drifts (and dual-write makes that easy to miss). Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`.
- **`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.
- **`apps/coder/src/services/provider-registry.ts`** (BooCoder, NOT apps/server) — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
- **`apps/coder/src/services/agent-probe.ts`** (BooCoder) — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
- **`apps/coder/src/routes/providers.ts`** (BooCoder) — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference). The apps/server side of this flow is the "Provider picker dispatch" bullet below.
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
### BooCoder (`apps/coder/src/`)
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
- After `pnpm -C apps/coder build` the host `boocoder.service` keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler returns that shape). Restart, don't re-debug.
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
- External agents dispatch **one-shot** (`opencode acp` / `goose acp` / `qwen --acp`) and report no context-window/token usage; only native `boocode` (llama-swap engine) tracks ctx. OpenCode-as-HTTP-server (warm process + `@opencode-ai/sdk`, the source of a real context bar) is the **planned, unshipped** `openspec/changes/v2-6-persistent-agent-sessions` batch; Paseo's per-provider native clients (design §12) were deliberately not ported.
### Frontend (`apps/web/src/`)
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
@@ -105,24 +127,34 @@ Sessions hold 15 panes (chat / empty / placeholder terminal+agent). v1.12.1 m
## 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.
PostgreSQL 16. Database name: `boochat` (renamed from `boocode` in v2.0.0-alpha; Docker service name stays `boocode_db`). Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `message_parts` (v1.13.0), `pending_changes` (v2.0.0), `tasks` (v2.0.0), `available_agents` (v2.0.0). Views: `messages_with_parts` (v1.13.1-B parts-merge read path), `tool_cost_stats` (v1.13.10 per-tool 100-call rolling window), `human_inbox` (v2.0.0 — tasks WHERE state IN blocked/failed). (`session_panes` was dropped in v1.12.1; workspace pane state lives in `sessions.workspace_panes jsonb`.) Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. The older anonymous `messages_status_check` (without 'cancelled') and `messages_role_check` (without 'system') were dropped in v1.12.1; only the `_chk` variants remain.
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
## Environment
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist).
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (optional; default `/data/mcp.json` — JSON config for MCP servers matching opencode's `mcpServers` shape; file missing = no MCP).
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL when unset. Set to a small model on llama-swap (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 required on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — non-interactive mode (`-p`) runs autonomously without approval prompts. ACP bridge is HTTP daemon (not stdio); use PTY dispatch.
- Arena (v2.0.5): `POST /api/arena {project_id, input, contestants: [{agent?, model?}]}` dispatches the same task to N models/agents in parallel. Each contestant gets its own task + worktree. `GET /api/arena/:id` for results. `POST /api/arena/:id/select/:task_id` picks winner.
## Workflow
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Per-batch docs live under `openspec/changes/<slug>/{proposal,tasks,design}.md`. Already-shipped batches are snapshots 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).
- The `boocode` container is `build: .` — it builds web+server from the **working tree**, so uncommitted changes deploy. Web edits are live on the Vite dev server (HMR) but NOT on production (`:9500` / code.indifferentketchup.com) until `docker compose up --build -d boocode`.
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. 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.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped 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. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. 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`.
- Frontend blank-screen / runtime crash: get the stack-trace column offset from the browser console, then `cut -c <start>-<end> apps/web/dist/assets/index-*.js | sed -n '<line>p'` to read the exact minified expression that threw. Faster than bisecting source. Watch for `=== null`/`!== null` on optional fields fed an `as unknown as` cast — those bypass tsc.
- 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.
@@ -131,7 +163,9 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- 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.
- 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` at project root is honored when `--respect-gitignore` is passed (enabled in the shim).
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild requires staging the fork source first: `tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext --exclude=.git --exclude=bin .` then `docker compose build --no-cache codecontext`. The Dockerfile COPYs `fork.tar.gz` into the builder stage (Gitea is behind Authelia, no HTTP clone). `fork.tar.gz` is gitignored.
- Go binary: `/snap/go/current/bin/go` (not on PATH by default). Use `export PATH=$PATH:/snap/go/current/bin` or full path for Go commands.
- `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
@@ -143,10 +177,12 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- 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.
- `ui/` primitives present: button, card, context-menu, dialog, dropdown-menu, input, label, radio-group, sonner, textarea. No switch/sheet/drawer/badge/checkbox — use a `<button role="switch" aria-checked>` toggle (a hand-rolled `Switch` already lives in `SettingsPane.tsx`) and a Dialog-based panel for "drawers".
- `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.
- A scrollable list inside a Dialog on mobile: cap `DialogContent` (`max-h-[85vh]` + `grid-rows-[auto_minmax(0,1fr)_auto]`) and make the list the single scroll region with `overscroll-contain` — otherwise touch-scroll drags the whole fixed modal / chains to the page.
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
- **New tools** live in their own `services/<name>.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`.
@@ -154,3 +190,17 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- 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).
- **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.
- **Sidecar routing** (`services/inference/provider.ts`): `upstreamModel(config, modelId, agent)` routes to `LLAMA_SIDECAR_URL` when agent has `llama_extra_args`, otherwise `LLAMA_SWAP_URL`. `resolveRoute(agent)` returns `{route: 'swap'|'sidecar', flags}`. Sidecar provider created fresh per call (not cached) because `X-Agent-Flags` header varies per agent. Boot-time guard in `index.ts` refuses to start if any agent has `llama_extra_args` but `LLAMA_SIDECAR_URL` is unset.
- **Secret guard safe patterns** (`services/secret_guard.ts`): `.env.example`, `.env.sample`, `.env.template`, `.env.defaults` are allowlisted via `SAFE_PATTERNS` set. Do NOT add `.env.production`/`.env.development`/`.env.test` — those can hold real secrets.
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
- **Adding a new `SessionEvent` type**: add the interface, add it to the `SessionEvent` union, add a `case` in `useSidebar.ts` `applyEvent` switch (no-op `return prev` is fine), and subscribe in any hook that needs it (e.g. `useSessionStream` for `refetch_messages`).
- **BooCoder provider registry** (`apps/coder/src/services/provider-registry.ts`): static list of provider defs (boocode, opencode, goose, claude, qwen). `PROBED_AGENT_NAMES` derives from it. Adding/removing providers means editing this file, not the frontend.
- **AgentComposerBar filters `e.installed`**: provider snapshot entries with `installed:false` (loading/unavailable) are dropped from the dropdown. `getProviderSnapshot` must await the full build — returning synchronous `loading` placeholders makes every provider vanish (the v2.5.7 "no providers showing up" regression); surfacing loading states needs a client poll.
- **Coder↔web provider-type parity** (`apps/coder/src/services/provider-types.ts``apps/web/src/api/types.ts`): enforced by runtime `provider-types-parity.test.ts` (compile-time cross-import is blocked by TS6307 on web's composite tsconfig). Mirror of the ws-frames parity pattern — edit both copies together or the test fails.
- **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) instead discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins` `skills/`+`commands/`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in — BooChat passes flat `items` (unchanged).
- **Pane header architecture (mobile vs desktop)**: Desktop coder pane header (BooCode label + [+] [×]) lives in `Workspace.tsx` gated by `isCoder && !isMobile`. Mobile coder controls (● ×) live in `Session.tsx` header row next to `MobileTabSwitcher`/`NewPaneMenu`. `AgentComposerBar` (provider/mode/model pickers) renders inside `CoderPane.tsx` on both. The ● status dot is passed via `connected` prop from CoderPane to AgentComposerBar.
- **MessageBubble shared between BooChat and BooCoder** (`components/MessageBubble.tsx`): accepts optional `actions?: MessageActions` callbacks (onRegenerate, onResend, onFork, onDelete) and `hideActions?: ('fork'|'delete'|'openInPane')[]`. Defaults use BooChat API; CoderPane overrides via `CoderMessageList` props. `CoderTextBubble` was removed. **`CoderMessageList` passes `CoderMessageWire as unknown as Message`** — the coder wire shape lacks `metadata`/`kind`/`summary`, so those fields are `undefined` (not `null`) on coder messages. Null-guards on any `Message` field MUST use loose `!= null`, not strict `!== null` (`undefined !== null` is `true``.kind` throws → blank-screen crash). The `as unknown as` cast hides this from tsc; build + typecheck pass while runtime crashes.
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for per-agent llama-server process pool. Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child process gotchas: use `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent process spawning (SSH `start /B` doesn't survive session close).

10
CURRENT.md Normal file
View File

@@ -0,0 +1,10 @@
# Current focus
Last updated: 2026-05-26
- **Batch:** v2.3-provider-lifecycle (openspec drafted; not started)
- **Branch:** `main`
- **Blockers:** none
- **Last shipped:** `v2.2.2-xml-placeholder-reject`
Update this file when starting or finishing a batch. Agents: read this first for session intent; if stale vs `CHANGELOG.md`, trust CHANGELOG for shipped state.

661
LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -1,6 +1,10 @@
# boocode
Self-hosted single-user developer chat app. v1: chat only.
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals).
**Latest release:** `v2.2.1-pane-scoped-chats` (2026-05-26) · [`CHANGELOG.md`](CHANGELOG.md) · **Current focus:** [`CURRENT.md`](CURRENT.md)
**Agent navigation:** [`AGENTS.md`](AGENTS.md) · **Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md)
## Stack
@@ -13,6 +17,8 @@ Self-hosted single-user developer chat app. v1: chat only.
- `apps/server` — Fastify API + WebSocket + inference loop + file-read tools
- `apps/web` — React frontend; served by Fastify in production, Vite in dev
- `apps/booterm` — Fastify + node-pty + tmux for in-browser terminal panes
- `apps/coder` — Fastify write tools + ACP/PTY dispatcher + MCP server (BooCoder)
## Local dev
@@ -28,7 +34,7 @@ cp .env.example .env
docker compose up -d boocode_db
# run server (port 3000) and web (port 5173) in two shells
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boocode \
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat \
LLAMA_SWAP_URL=http://100.101.41.16:8401 \
pnpm dev:server
@@ -49,11 +55,32 @@ docker compose up --build -d
Binds to `100.114.205.53:9500` (Tailscale). Authelia is expected to gate the
upstream and inject `Remote-User`. Postgres binds loopback only.
## What v1 has
BooCoder runs as a **host systemd service** (`boocoder.service`, port `:9502`), not in Docker:
Project sidebar, sessions per project, chat with streaming responses over
WebSocket, four file-read tools scoped to the project root (`view_file`,
`list_dir`, `grep`, `find_files`), and a model picker driven by llama-swap's
`/v1/models`.
```bash
pnpm -C apps/server build && pnpm -C apps/coder build
sudo systemctl restart boocoder
curl http://100.114.205.53:9502/api/health
```
What v1 does not have lives in v2 (terminal pane) and v3 (Coder pane).
## Services
|Service|Port|Description|
|---|---|---|
|BooChat|`100.114.205.53:9500`|Read-only chat + SPA |
|BooTerm|`100.114.205.53:9501`|PTY/tmux terminal panes |
|BooCoder|host:9502|Write tools + agent dispatch + MCP server (systemd service, not Docker) |
|Postgres|`127.0.0.1:5500`|Shared database (`boochat`; Docker service `boocode_db`) |
|codecontext|internal `:8080`|Code graph sidecar (Docker network only) |
## What's shipped
See [`boocode_roadmap.md`](boocode_roadmap.md) for full version history. Highlights as of **v2.2.1**:
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (multi-server + stdio), tool-cost tracking, skills system, builtin agent registry, multi-pane workspace (chat / terminal / coder)
- **BooTerm**: in-browser terminal panes via tmux + xterm.js, per-session tmux sessions, SSH-out support
- **BooCoder (v2.2)**: write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`), pending-changes queue with diff UI, Paseo-style provider snapshot (7 providers: boocode, cursor, claude, opencode, goose, qwen, copilot), `AgentComposerBar` (provider / mode / model / thinking), ACP dispatch with inline permission prompts + tool/reasoning streaming, PTY fallback, Arena, MCP server (6 tools, stdio), CLI client, human inbox, Boomerang orchestration, path-guard fuzz suite, **pane-scoped chats** (v2.2.1 — each coder/terminal pane owns its chat)
## Planned
- **v2.3 provider lifecycle** — config-backed provider registry (`/data/coder-providers.json`), enable/disable toggles, two-tier probe (openspec drafted). See [`CURRENT.md`](CURRENT.md).

View File

@@ -23,5 +23,6 @@
"@types/pg": "^8.11.10",
"tsx": "^4.16.2",
"typescript": "^5.5.0"
}
},
"license": "AGPL-3.0-only"
}

16
apps/coder/.env.host Normal file
View File

@@ -0,0 +1,16 @@
NODE_ENV=production
PORT=9502
HOST=100.114.205.53
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat
LLAMA_SWAP_URL=http://100.101.41.16:8401
PROJECT_ROOT_WHITELIST=/opt
BOOTSTRAP_ROOT=/opt/projects
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
LOG_LEVEL=info
SEARXNG_URL=http://100.114.205.53:8888
GITEA_BASE_URL=https://git.indifferentketchup.com
GITEA_USER=indifferentketchup
GITEA_SSH_HOST=100.114.205.53:2222
MCP_CONFIG_PATH=/data/mcp.json
SKILLS_ROOT=/opt/boocode/data/skills
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json

35
apps/coder/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS builder
RUN corepack enable
WORKDIR /build
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY apps/server/package.json ./apps/server/
COPY apps/coder/package.json ./apps/coder/
COPY apps/coder/web/package.json ./apps/coder/web/
RUN pnpm install --frozen-lockfile
# Build server first (coder depends on it via workspace dep for types + inference)
COPY apps/server ./apps/server
RUN pnpm -C apps/server build
COPY apps/coder ./apps/coder
RUN pnpm -C apps/coder/web build
RUN pnpm -C apps/coder build
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
FROM node:20-bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git openssh-client && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /out/coder ./
COPY --from=builder /build/apps/coder/web/dist ./web
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]

34
apps/coder/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "@boocode/coder",
"version": "2.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
"start": "node dist/index.js",
"cli": "tsx src/cli.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.22.1",
"@boocode/server": "workspace:*",
"@fastify/static": "^7.0.4",
"@fastify/websocket": "^10.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"fastify": "^4.28.1",
"postgres": "^3.4.4",
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/ws": "^8.5.10",
"tsx": "^4.16.2",
"typescript": "^5.5.0",
"vitest": "^3.0.0"
},
"license": "AGPL-3.0-only"
}

249
apps/coder/src/cli.ts Normal file
View File

@@ -0,0 +1,249 @@
#!/usr/bin/env node
/**
* BooCoder CLI client.
*
* Usage:
* boocode run "task description" [--agent opencode] [--model claude-opus-4-7] [--project <id>]
* boocode ls [--state pending|running|completed|failed]
* boocode attach <task-id>
* boocode send <task-id> "message"
*/
import { WebSocket } from 'ws';
const BASE_URL = process.env.BOOCODER_URL ?? 'http://100.114.205.53:9502';
// ─── Arg parsing ─────────────────────────────────────────────────────────────
function getFlag(args: string[], name: string): string | undefined {
const idx = args.indexOf(name);
if (idx === -1 || idx + 1 >= args.length) return undefined;
return args[idx + 1];
}
function hasFlag(args: string[], name: string): boolean {
return args.includes(name);
}
// ─── HTTP helpers ────────────────────────────────────────────────────────────
async function api(method: string, path: string, body?: unknown): Promise<unknown> {
const url = `${BASE_URL}${path}`;
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${path}${res.status}: ${text}`);
}
return res.json();
}
// ─── WS streaming ────────────────────────────────────────────────────────────
function streamSession(sessionId: string): void {
const wsUrl = BASE_URL.replace(/^http/, 'ws') + `/api/ws/sessions/${sessionId}`;
const ws = new WebSocket(wsUrl);
ws.on('message', (data) => {
try {
const frame = JSON.parse(data.toString()) as { type: string; content?: string; name?: string; arguments?: string };
if (frame.type === 'delta' && frame.content) {
process.stdout.write(frame.content);
} else if (frame.type === 'tool_call') {
process.stdout.write(`\n[tool: ${frame.name ?? '?'}(${(frame.arguments ?? '').slice(0, 80)})]\n`);
} else if (frame.type === 'tool_result') {
process.stdout.write(`[tool_result]\n`);
} else if (frame.type === 'status' || frame.type === 'chat_status') {
// Silent
}
} catch {
// Non-JSON frame, ignore
}
});
ws.on('error', (err) => {
process.stderr.write(`WS error: ${err.message}\n`);
});
ws.on('close', () => {
process.stdout.write('\n');
process.exit(0);
});
process.on('SIGINT', () => {
ws.close();
process.exit(0);
});
}
// ─── Commands ────────────────────────────────────────────────────────────────
async function cmdRun(args: string[]): Promise<void> {
const input = args.find((a) => !a.startsWith('--'));
if (!input) {
process.stderr.write('Usage: boocode run "task description" [--agent X] [--model X] [--project X]\n');
process.exit(1);
}
const agent = getFlag(args, '--agent');
const model = getFlag(args, '--model');
const project_id = getFlag(args, '--project');
if (!project_id) {
process.stderr.write('Error: --project <uuid> is required\n');
process.exit(1);
}
const result = (await api('POST', '/api/tasks', {
project_id,
input,
...(agent && { agent }),
...(model && { model }),
})) as { id: string; state: string };
process.stdout.write(`Task created: ${result.id} (state: ${result.state})\n`);
// Poll until task has session_id, then stream; or poll until terminal state
const POLL_MS = 2000;
for (;;) {
await sleep(POLL_MS);
const task = (await api('GET', `/api/tasks/${result.id}`)) as {
id: string; state: string; session_id?: string; output_summary?: string;
};
if (task.session_id) {
process.stdout.write(`Streaming session ${task.session_id}...\n`);
streamSession(task.session_id);
return; // streamSession handles exit
}
if (task.state === 'completed') {
process.stdout.write(`\nCompleted: ${task.output_summary ?? '(no summary)'}\n`);
return;
}
if (task.state === 'failed') {
process.stderr.write(`\nFailed: ${task.output_summary ?? '(no summary)'}\n`);
process.exit(1);
}
if (task.state === 'cancelled') {
process.stderr.write(`\nCancelled.\n`);
process.exit(1);
}
}
}
async function cmdLs(args: string[]): Promise<void> {
const state = getFlag(args, '--state');
const query = state ? `?state=${state}` : '';
const tasks = (await api('GET', `/api/tasks${query}`)) as Array<{
id: string; state: string; agent: string | null; input: string; created_at: string;
}>;
if (tasks.length === 0) {
process.stdout.write('No tasks.\n');
return;
}
// Table header
process.stdout.write(
pad('ID', 38) + pad('STATE', 12) + pad('AGENT', 14) + pad('INPUT', 52) + 'CREATED\n',
);
process.stdout.write('-'.repeat(120) + '\n');
for (const t of tasks) {
process.stdout.write(
pad(t.id, 38) +
pad(t.state, 12) +
pad(t.agent ?? '-', 14) +
pad(t.input.slice(0, 50), 52) +
(t.created_at?.slice(0, 19) ?? '') + '\n',
);
}
}
async function cmdAttach(args: string[]): Promise<void> {
const taskId = args[0];
if (!taskId) {
process.stderr.write('Usage: boocode attach <task-id>\n');
process.exit(1);
}
const task = (await api('GET', `/api/tasks/${taskId}`)) as { session_id?: string };
if (!task.session_id) {
process.stderr.write('Task has no session yet (still pending?).\n');
process.exit(1);
}
streamSession(task.session_id);
}
async function cmdSend(args: string[]): Promise<void> {
const taskId = args[0];
const message = args[1];
if (!taskId || !message) {
process.stderr.write('Usage: boocode send <task-id> "message"\n');
process.exit(1);
}
const task = (await api('GET', `/api/tasks/${taskId}`)) as { session_id?: string };
if (!task.session_id) {
process.stderr.write('Task has no session yet.\n');
process.exit(1);
}
// Find active chat
const sessionId = task.session_id;
// POST message to the session's chat (the messages route expects session_id in path)
await api('POST', `/api/sessions/${sessionId}/messages`, { content: message });
// Then attach to stream the response
streamSession(sessionId);
}
// ─── Utils ───────────────────────────────────────────────────────────────────
function pad(s: string, width: number): string {
return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── Main ────────────────────────────────────────────────────────────────────
const [cmd, ...rest] = process.argv.slice(2);
switch (cmd) {
case 'run':
cmdRun(rest).catch(fatal);
break;
case 'ls':
cmdLs(rest).catch(fatal);
break;
case 'attach':
cmdAttach(rest).catch(fatal);
break;
case 'send':
cmdSend(rest).catch(fatal);
break;
default:
process.stdout.write(
'BooCoder CLI\n\n' +
'Commands:\n' +
' run "task" [--agent X] [--model X] [--project <id>] Create and stream a task\n' +
' ls [--state pending|running|completed|failed] List tasks\n' +
' attach <task-id> Stream a running task\n' +
' send <task-id> "message" Send input to a task\n' +
'\n' +
`Base URL: ${BASE_URL} (set BOOCODER_URL to override)\n`,
);
if (cmd && cmd !== '--help' && cmd !== '-h') process.exit(1);
}
function fatal(err: unknown): void {
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}

54
apps/coder/src/config.ts Normal file
View File

@@ -0,0 +1,54 @@
import { z } from 'zod';
// BooCoder's config is a superset of the server's Config type so it can be
// passed directly into the inference runner's InferenceContext. Fields the
// inference loop reads: LLAMA_SWAP_URL, PROJECT_ROOT_WHITELIST. The rest
// default to values that satisfy the server's Zod schema without BooCoder
// needing to supply them in its environment.
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().url(),
LLAMA_SWAP_URL: z.string().url(),
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
BOOTSTRAP_ROOT: z.string().default('/opt/projects'),
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
LOG_LEVEL: z.string().default('info'),
CONTAINER_GUIDANCE_FILE: z.string().optional(),
// Fields needed to satisfy the server's Config type but unused by BooCoder:
SEARXNG_URL: z.string().url().default('http://100.114.205.53:8888'),
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),
GITEA_USER: z.string().default('indifferentketchup'),
GITEA_TOKEN: z.string().optional(),
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
MCP_CONFIG_PATH: z.string().optional(),
// v2.3: config-backed provider overrides/custom-ACP entries merged over the
// hardcoded built-ins. Missing file = built-ins only (see provider-config.ts).
CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'),
// v2.3 phase 2: tier-2 (cold ACP probe) is skipped when available_agents was
// probed more recently than this. 24h default — stale model lists self-heal
// on the next snapshot; an explicit /refresh always re-probes.
PROVIDER_PROBE_TTL_MS: z.coerce.number().int().positive().default(86_400_000),
// v2.0.5: cheaper model for titles, summaries, labeling.
FAST_MODEL: z.string().optional(),
// SSH access to the host for external agent dispatch (Phase 5)
BOOCODER_SSH_HOST: z.string().default('100.114.205.53'),
BOOCODER_SSH_USER: z.string().default('samkintop'),
});
export type Config = z.infer<typeof ConfigSchema>;
let cached: Config | null = null;
export function loadConfig(): Config {
if (cached) return cached;
const parsed = ConfigSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
cached = parsed.data;
return cached;
}

45
apps/coder/src/db.ts Normal file
View File

@@ -0,0 +1,45 @@
import postgres from 'postgres';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import type { Config } from './config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export type Sql = ReturnType<typeof postgres>;
let sqlInstance: Sql | null = null;
export function getSql(config: Config): Sql {
if (sqlInstance) return sqlInstance;
sqlInstance = postgres(config.DATABASE_URL, {
max: 10,
idle_timeout: 30,
connect_timeout: 10,
onnotice: () => {},
});
return sqlInstance;
}
export async function applySchema(sql: Sql): Promise<void> {
const schemaPath = resolve(__dirname, 'schema.sql');
const ddl = await readFile(schemaPath, 'utf8');
await sql.unsafe(ddl);
}
export async function pingDb(sql: Sql): Promise<boolean> {
try {
await sql`SELECT 1`;
return true;
} catch {
return false;
}
}
export async function closeDb(): Promise<void> {
if (sqlInstance) {
await sqlInstance.end({ timeout: 5 });
sqlInstance = null;
}
}

239
apps/coder/src/index.ts Normal file
View File

@@ -0,0 +1,239 @@
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { loadConfig } from './config.js';
import { getSql, applySchema, pingDb, closeDb } from './db.js';
import { startMcpServer } from './services/mcp-server.js';
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
// inference loop, broker, and tool registry without duplication.
import { createInferenceRunner } from '@boocode/server/inference';
import { createBroker } from '@boocode/server/broker';
import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools';
import type { Config as ServerConfig } from '@boocode/server/config';
import type { WsFrame } from '@boocode/server/ws-frames';
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
import { WRITE_TOOLS } from './services/tools/index.js';
import { adaptWriteTool } from './services/tools/adapter.js';
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
// Routes
import { registerMessageRoutes } from './routes/messages.js';
import { registerSkillRoutes } from './routes/skills.js';
import { registerPendingRoutes } from './routes/pending.js';
import { registerTaskRoutes } from './routes/tasks.js';
import { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js';
import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js';
import { agentPool } from './services/agent-pool.js';
import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
import { setPermissionHooks } from './services/permission-waiter.js';
import { homedir } from 'node:os';
async function main() {
// MCP mode: stdio transport, no HTTP server
if (process.argv.includes('--mcp')) {
const config = loadConfig();
const sql = getSql(config);
await applySchema(sql);
await startMcpServer(sql);
return;
}
const config = loadConfig();
const app = Fastify({
logger: { level: config.LOG_LEVEL },
});
// Allow empty JSON bodies (same pattern as apps/server).
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const str = (body as string) ?? '';
if (str.trim().length === 0) {
done(null, {});
return;
}
try {
done(null, JSON.parse(str));
} catch (err) {
done(err as Error, undefined);
}
});
const sql = getSql(config);
await applySchema(sql);
app.log.info('database schema applied');
// Broker: in-memory pub/sub for session + user channel streaming.
const broker = createBroker(app.log);
setPermissionHooks({
onPrompt: async (prompt) => {
await sql`
UPDATE tasks SET state = 'blocked' WHERE id = ${prompt.taskId} AND state = 'running'
`;
broker.publishFrame(prompt.sessionId, {
type: 'permission_requested',
task_id: prompt.taskId,
session_id: prompt.sessionId,
kind: prompt.kind,
tool_title: prompt.toolTitle,
...(prompt.input ? { input: prompt.input } : {}),
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
} as WsFrame);
},
onResolved: async (taskId, sessionId) => {
await sql`
UPDATE tasks SET state = 'running' WHERE id = ${taskId} AND state = 'blocked'
`;
broker.publishFrame(sessionId, {
type: 'permission_resolved',
task_id: taskId,
session_id: sessionId,
} as WsFrame);
},
});
// --- Tool registry extension ---
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
// TOOLS_BY_NAME so tool-phase.ts dispatch sees the full set.
const adaptedWriteTools = WRITE_TOOLS.map((t) => adaptWriteTool(t));
appendMcpTools(adaptedWriteTools);
app.log.info(`tool registry: ${ALL_TOOLS.length} tools loaded (${WRITE_TOOLS.length} write tools)`);
// Inference runner: same engine as BooChat, uses ALL_TOOLS (which includes
// the appended write tools) for tool dispatch.
const inference = createInferenceRunner(
{
sql,
config: config as unknown as ServerConfig,
log: app.log,
publish: (sessionId, frame) => {
broker.publishFrame(sessionId, frame as unknown as WsFrame);
},
broker,
},
(user, frame) => {
broker.publishUserFrame(user, frame as unknown as WsFrame);
}
);
// Wrap the inference runner to set/clear the write-tool context around each run.
// The inference runner calls enqueue() which fires asynchronously — we hook
// into the enqueue to set context before the run starts.
const inferenceApi = {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
// Set the inference context so write tools can access sql + sessionId.
// The context persists for the duration of the inference run. Since
// BooCoder is single-user and runs one inference at a time per session,
// this module-level state is safe.
setInferenceContext({ sql, sessionId, taskId: null });
inference.enqueue(sessionId, chatId, assistantId, user);
},
cancel: async (sessionId: string, chatId: string) => {
const result = await inference.cancel(sessionId, chatId);
clearInferenceContext();
return result;
},
hasActive: (chatId: string) => inference.hasActive(chatId),
};
// Register WebSocket support
await app.register(fastifyWebsocket);
// Health endpoint
app.get('/api/health', async (_req, reply) => {
const dbOk = await pingDb(sql);
const status = dbOk ? 200 : 503;
return reply.status(status).send({
ok: dbOk,
db: dbOk,
tools: ALL_TOOLS.length,
});
});
// Phase 4: probe available agents on startup
await probeAgents(sql, app.log);
// Warm provider snapshot in background (ACP cold probes + model merges)
void getProviderSnapshot(sql, config, homedir(), true)
.then((entries) => persistProbedModels(sql, entries, app.log))
.catch((err) => {
app.log.warn(
{ err: err instanceof Error ? err.message : String(err) },
'provider-snapshot: warm failed',
);
});
// Phase 4: dispatcher — polls tasks table and runs inference
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
dispatcher.start();
app.addHook('onClose', async () => {
// stop() first so in-flight dispatcher turns settle, then drain the pool.
// Pool is empty in Phase 0 (nothing spawns yet) — dispose() is inert.
await dispatcher.stop();
await agentPool.dispose();
});
// Register routes
registerMessageRoutes(app, sql, broker, inferenceApi);
registerSkillRoutes(app, sql, broker, inferenceApi);
registerPendingRoutes(app, sql);
registerTaskRoutes(app, sql, inferenceApi);
registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql);
registerProviderRoutes(app, sql, config);
registerWebSocket(app, sql, broker);
// Serve static frontend (built web app). In production, the dist/ is
// copied to ../web relative to the dist/ directory at /app/web. In dev,
// check adjacent to the source.
const webRoot = resolve(__dirname, '../web');
if (existsSync(webRoot)) {
await app.register(fastifyStatic, {
root: webRoot,
prefix: '/',
// Don't intercept /api routes — static only serves files that exist.
wildcard: false,
});
// SPA fallback: serve index.html for non-API routes that don't match a file.
app.setNotFoundHandler(async (req, reply) => {
if (req.url.startsWith('/api')) {
reply.code(404);
return { error: 'not found' };
}
return reply.sendFile('index.html');
});
app.log.info(`serving frontend from ${webRoot}`);
}
// Graceful shutdown
const shutdown = async () => {
app.log.info('shutting down');
await app.close();
await closeDb();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
await app.listen({ port: config.PORT, host: config.HOST });
app.log.info(`BooCoder listening on ${config.HOST}:${config.PORT}`);
}
main().catch((err) => {
console.error('fatal:', err);
process.exit(1);
});

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { registerProviderRoutes } from '../providers.js';
import { load } from '../../services/provider-config.js';
import { loadProviderConfig } from '../../services/provider-config-registry.js';
import { clearProviderSnapshotCache } from '../../services/provider-snapshot.js';
import type { Config } from '../../config.js';
import type { Sql } from '../../db.js';
/** Minimal sql stub: available_agents reads return []. */
function mockSql(): Sql {
return vi.fn((strings: TemplateStringsArray) => {
const q = strings.join('');
if (q.includes('available_agents')) return Promise.resolve([]);
return Promise.resolve([]);
}) as unknown as Sql;
}
let tmpCounter = 0;
function freshPath(): string {
tmpCounter += 1;
return join(tmpdir(), `coder-providers-routes-${process.pid}-${tmpCounter}.json`);
}
function buildApp(providersPath: string): FastifyInstance {
const app = Fastify();
// Mirror index.ts: tolerate empty JSON bodies.
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const str = (body as string) ?? '';
if (str.trim().length === 0) return done(null, {});
try {
done(null, JSON.parse(str));
} catch (err) {
done(err as Error, undefined);
}
});
const config = {
CODER_PROVIDERS_PATH: providersPath,
LLAMA_SWAP_URL: 'http://llama-swap.test',
PROVIDER_PROBE_TTL_MS: 86_400_000,
} as unknown as Config;
registerProviderRoutes(app, mockSql(), config);
return app;
}
const JSON_HEADERS = { 'content-type': 'application/json' };
const createdPaths: string[] = [];
beforeEach(() => {
clearProviderSnapshotCache();
loadProviderConfig('/nonexistent-coder-providers.json'); // reset registry to built-ins
vi.restoreAllMocks();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test')));
});
afterEach(() => {
for (const p of createdPaths.splice(0)) {
try {
rmSync(p, { force: true });
} catch {
/* ignore */
}
}
});
describe('GET /api/providers/config', () => {
it('returns the current config file (built-ins-only when missing)', async () => {
const path = freshPath();
createdPaths.push(path);
const app = buildApp(path);
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ providers: {} });
await app.close();
});
it('reflects an existing file', async () => {
const path = freshPath();
createdPaths.push(path);
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false } } }));
const app = buildApp(path);
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
expect(res.json()).toEqual({ providers: { goose: { enabled: false } } });
await app.close();
});
});
describe('PATCH /api/providers/config', () => {
it('valid patch → 200, writes the merged file (order: validate→save→reload→clear)', async () => {
const path = freshPath();
createdPaths.push(path);
writeFileSync(path, JSON.stringify({ providers: { goose: { label: 'Goose' } } }));
const app = buildApp(path);
const res = await app.inject({
method: 'PATCH',
url: '/api/providers/config',
headers: JSON_HEADERS,
payload: JSON.stringify({ providers: { opencode: { enabled: false } } }),
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ ok: true });
// File written + merged (goose untouched, opencode added).
const onDisk = load(path);
expect(onDisk.providers).toEqual({
goose: { label: 'Goose' },
opencode: { enabled: false },
});
await app.close();
});
it('null value deletes the override', async () => {
const path = freshPath();
createdPaths.push(path);
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false }, opencode: { enabled: false } } }));
const app = buildApp(path);
const res = await app.inject({
method: 'PATCH',
url: '/api/providers/config',
headers: JSON_HEADERS,
payload: JSON.stringify({ providers: { goose: null } }),
});
expect(res.statusCode).toBe(200);
expect(load(path).providers).toEqual({ opencode: { enabled: false } });
await app.close();
});
it('INVALID body → 422 and the file is NOT written (validate before save)', async () => {
const path = freshPath();
createdPaths.push(path);
const before = JSON.stringify({ providers: { goose: { enabled: true } } });
writeFileSync(path, before);
const app = buildApp(path);
const res = await app.inject({
method: 'PATCH',
url: '/api/providers/config',
headers: JSON_HEADERS,
payload: JSON.stringify({ providers: { goose: { enabled: 'yes' } } }), // bad type
});
expect(res.statusCode).toBe(422);
// File must be byte-for-byte unchanged — nothing written on a 422.
expect(readFileSync(path, 'utf8')).toBe(before);
await app.close();
});
it('save failure → 500 and the file is NOT created (no state divergence)', async () => {
const path = join(tmpdir(), `no-such-dir-${process.pid}-${Date.now()}`, 'coder-providers.json');
const app = buildApp(path);
const res = await app.inject({
method: 'PATCH',
url: '/api/providers/config',
headers: JSON_HEADERS,
payload: JSON.stringify({ providers: { goose: { enabled: false } } }),
});
expect(res.statusCode).toBe(500);
expect(existsSync(path)).toBe(false);
await app.close();
});
});
describe('POST /api/providers/refresh', () => {
it('no body → refreshes all registered providers', async () => {
const app = buildApp(freshPath());
const res = await app.inject({ method: 'POST', url: '/api/providers/refresh' });
expect(res.statusCode).toBe(200);
expect(res.json().refreshed).toBeGreaterThan(0);
await app.close();
});
it('subset body → refreshed count reflects only the requested providers', async () => {
const app = buildApp(freshPath());
const res = await app.inject({
method: 'POST',
url: '/api/providers/refresh',
headers: JSON_HEADERS,
payload: JSON.stringify({ providers: ['boocode'] }),
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ refreshed: 1 });
await app.close();
});
});
describe('GET /api/providers/:id/diagnostic', () => {
it('known provider → 200 JSON { diagnostic }', async () => {
const app = buildApp(freshPath());
const res = await app.inject({ method: 'GET', url: '/api/providers/boocode/diagnostic' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('application/json');
expect(res.json().diagnostic).toContain('provider: boocode');
await app.close();
});
it('unknown provider → 404', async () => {
const app = buildApp(freshPath());
const res = await app.inject({ method: 'GET', url: '/api/providers/nope/diagnostic' });
expect(res.statusCode).toBe(404);
await app.close();
});
});

View File

@@ -0,0 +1,136 @@
/**
* v2.0.5: Arena routes — competitive dispatch of the same task to multiple agents.
*
* POST /api/arena — create an arena with 2-5 contestants
* GET /api/arena/:id — get all tasks in an arena
* POST /api/arena/:id/select/:task_id — mark a task as the arena winner
*/
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
const ContestantSchema = z.object({
agent: z.string().max(100).optional(),
model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
});
const CreateArenaBody = z.object({
project_id: z.string().uuid(),
input: z.string().min(1).max(64_000),
contestants: z.array(ContestantSchema).min(2).max(5),
});
interface TaskRow {
id: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
state: string;
}
export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
// POST /api/arena — create a new arena
app.post('/api/arena', async (req, reply) => {
const parsed = CreateArenaBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, input, contestants } = parsed.data;
const arenaId = crypto.randomUUID();
const tasks: TaskRow[] = [];
for (const contestant of contestants) {
const [task] = await sql<TaskRow[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, arena_id)
VALUES (
${project_id},
${input},
${contestant.agent ?? null},
${contestant.model ?? null},
${contestant.mode_id ?? null},
${contestant.thinking_option_id ?? null},
${arenaId}
)
RETURNING id, agent, model, mode_id, thinking_option_id, state
`;
tasks.push(task!);
}
reply.code(201);
return {
arena_id: arenaId,
tasks: tasks.map((t) => ({
id: t.id,
agent: t.agent,
model: t.model,
mode_id: t.mode_id,
thinking_option_id: t.thinking_option_id,
state: t.state,
})),
};
});
// GET /api/arena/:arena_id — list all tasks in an arena
app.get<{ Params: { arena_id: string } }>('/api/arena/:arena_id', async (req, reply) => {
const { arena_id } = req.params;
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(arena_id)) {
reply.code(400);
return { error: 'invalid arena_id format' };
}
const tasks = await sql`
SELECT id, project_id, state, input, output_summary, agent, model, mode_id, thinking_option_id, execution_path, session_id, started_at, ended_at, created_at, arena_id
FROM tasks
WHERE arena_id = ${arena_id}
ORDER BY created_at
`;
if (tasks.length === 0) {
reply.code(404);
return { error: 'arena not found' };
}
return { arena_id, tasks };
});
// POST /api/arena/:arena_id/select/:task_id — mark the winner
app.post<{ Params: { arena_id: string; task_id: string } }>(
'/api/arena/:arena_id/select/:task_id',
async (req, reply) => {
const { arena_id, task_id } = req.params;
// Verify the task belongs to this arena
const rows = await sql<{ id: string; state: string; arena_id: string | null }[]>`
SELECT id, state, arena_id FROM tasks WHERE id = ${task_id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'task not found' };
}
const task = rows[0]!;
if (task.arena_id !== arena_id) {
reply.code(409);
return { error: 'task does not belong to this arena' };
}
// Mark as selected via output_summary prefix (lightweight — no schema change)
await sql`
UPDATE tasks
SET output_summary = COALESCE('[SELECTED] ' || output_summary, '[SELECTED]')
WHERE id = ${task_id}
`;
return { selected: true, task_id, arena_id };
}
);
}

View File

@@ -0,0 +1,81 @@
import type { Sql } from '../db.js';
interface WorkspacePaneRow {
id: string;
kind: string;
chatId?: string;
chatIds?: string[];
activeChatIdx?: number;
}
function chatNameForKind(kind: string): string {
if (kind === 'coder' || kind === 'agent') return 'BooCoder';
if (kind === 'terminal') return 'Terminal';
return 'Chat';
}
function activeChatIdForPane(pane: WorkspacePaneRow): string | undefined {
const chatIds = pane.chatIds ?? [];
const idx = pane.activeChatIdx ?? 0;
if (idx >= 0 && idx < chatIds.length) return chatIds[idx];
return pane.chatId;
}
/** Resolve the active chat for a workspace pane; auto-seed when empty. */
export async function resolveChatId(
sql: Sql,
sessionId: string,
paneId: string,
): Promise<string | null> {
return sql.begin(async (tx) => {
const sessionRows = await tx<{ workspace_panes: WorkspacePaneRow[] }[]>`
SELECT workspace_panes FROM sessions WHERE id = ${sessionId} FOR UPDATE
`;
if (sessionRows.length === 0) return null;
const panes = sessionRows[0]!.workspace_panes ?? [];
const paneIdx = panes.findIndex((p) => p.id === paneId);
if (paneIdx < 0) return null;
const pane = panes[paneIdx]!;
const existingChatId = activeChatIdForPane(pane);
if (existingChatId) {
const chatRows = await tx<{ id: string }[]>`
SELECT id FROM chats
WHERE id = ${existingChatId}
AND session_id = ${sessionId}
AND status = 'open'
`;
if (chatRows.length > 0) return existingChatId;
}
const [newChat] = await tx<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, ${chatNameForKind(pane.kind)}, 'open')
RETURNING id
`;
if (!newChat) return null;
const nextChatIds = [...(pane.chatIds ?? []), newChat.id];
const nextActiveIdx = nextChatIds.length - 1;
const nextPanes = panes.map((p, i) =>
i === paneIdx
? {
...p,
chatIds: nextChatIds,
activeChatIdx: nextActiveIdx,
chatId: newChat.id,
}
: p,
);
await tx`
UPDATE sessions
SET workspace_panes = ${tx.json(nextPanes as never)},
updated_at = clock_timestamp()
WHERE id = ${sessionId}
`;
return newChat.id;
});
}

View File

@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
export function registerInboxRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/inbox — tasks needing human attention (blocked or failed)
app.get('/api/inbox', async () => {
return sql`
SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, session_id, started_at, ended_at, created_at
FROM human_inbox
ORDER BY created_at DESC
LIMIT 100
`;
});
// POST /api/inbox/:id/retry — reset a blocked/failed task to pending for re-dispatch
app.post<{ Params: { id: string } }>('/api/inbox/:id/retry', async (req, reply) => {
const taskId = req.params.id;
const result = await sql`
UPDATE tasks
SET state = 'pending', started_at = NULL, ended_at = NULL, output_summary = NULL
WHERE id = ${taskId} AND state IN ('blocked', 'failed')
RETURNING id, state
`;
if (result.length === 0) {
reply.code(404);
return { error: 'task not found or not in retryable state' };
}
return { id: result[0]!.id, state: result[0]!.state };
});
}

View File

@@ -0,0 +1,402 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import { resolveChatId } from './chat-resolve.js';
const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1),
answers: z
.array(
z.object({
question: z.string(),
selected_options: z.array(z.string()),
free_text: z.string().nullable(),
}),
)
.min(1)
.max(3),
});
const AskUserInputArgs = z.object({
questions: z
.array(
z.object({
question: z.string(),
type: z.enum(['single_select', 'multi_select']),
options: z.array(z.string()).min(1),
}),
)
.min(1)
.max(3),
});
const SendBody = z.object({
content: z.string().min(1).max(64_000),
pane_id: z.string().min(1).max(200),
chat_id: z.string().uuid().optional(),
provider: z.string().max(100).optional(),
model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
});
interface InferenceApi {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean;
}
interface MessageRow {
id: string;
role: string;
content: string | null;
status: string | null;
tool_calls: Array<{ id: string; name: string; args?: Record<string, unknown> }> | null;
tool_results: {
tool_call_id: string;
output: unknown;
truncated?: boolean;
error?: string;
} | null;
reasoning_parts: Array<{ text?: string }> | null;
}
function mapCoderMessageRow(row: MessageRow) {
if (row.role === 'tool') {
if (!row.tool_results?.tool_call_id) return null;
return {
id: row.id,
role: 'tool' as const,
tool_results: row.tool_results,
};
}
if (row.role !== 'user' && row.role !== 'assistant' && row.role !== 'system') {
return null;
}
const tool_calls = row.tool_calls?.map((tc) => ({
id: tc.id,
function: {
name: tc.name,
arguments: JSON.stringify(tc.args ?? {}),
},
}));
const reasoningText = row.reasoning_parts?.map((p) => p.text ?? '').join('') ?? '';
return {
id: row.id,
role: row.role as 'user' | 'assistant' | 'system',
content: row.content ?? '',
status: (row.status ?? 'complete') as 'streaming' | 'complete' | 'failed',
...(reasoningText ? { reasoning_text: reasoningText } : {}),
...(tool_calls?.length ? { tool_calls } : {}),
};
}
export function registerMessageRoutes(
app: FastifyInstance,
sql: Sql,
broker: Broker,
inference: InferenceApi,
): void {
// GET /api/sessions/:sessionId/messages — hydrate CoderPane on load / reconnect
app.get<{ Params: { sessionId: string }; Querystring: { chat_id?: string } }>(
'/api/sessions/:sessionId/messages',
async (req, reply) => {
const sessionId = req.params.sessionId;
const chatId = req.query.chat_id;
const sessionRows = await sql<{ id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
if (chatId) {
const chatRows = await sql<{ id: string }[]>`
SELECT id FROM chats
WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found or not open in this session' };
}
}
const rows = chatId
? await sql<MessageRow[]>`
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
FROM messages_with_parts
WHERE session_id = ${sessionId} AND chat_id = ${chatId}
ORDER BY created_at ASC, id ASC
`
: await sql<MessageRow[]>`
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
FROM messages_with_parts
WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC
`;
return rows.map(mapCoderMessageRow).filter((m) => m !== null);
},
);
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/messages',
async (req, reply) => {
const parsed = SendBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const sessionId = req.params.sessionId;
const { content, pane_id, chat_id: explicitChatId, provider, model, mode_id, thinking_option_id } =
parsed.data;
const isExternal = provider && provider !== 'boocode';
// Validate session exists
const sessionRows = await sql<{ id: string; project_id: string }[]>`
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const resolved = await resolveChatId(sql, sessionId, pane_id);
if (!resolved) {
reply.code(404);
return { error: 'pane not found' };
}
let chatId = resolved;
if (explicitChatId) {
const chatRows = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE id = ${explicitChatId} AND session_id = ${sessionId} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found or not open in this session' };
}
chatId = explicitChatId;
}
if (!isExternal) {
// Reject if inference is already running on this chat
if (inference.hasActive(chatId)) {
reply.code(409);
return { error: 'inference already running on this chat' };
}
}
// Create user message
const [userMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
RETURNING id
`;
await sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
// Publish user message frames
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: userMsg!.id,
chat_id: chatId,
role: 'user',
} as unknown as WsFrame);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: userMsg!.id,
chat_id: chatId,
content,
} as unknown as WsFrame);
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: userMsg!.id,
chat_id: chatId,
} as unknown as WsFrame);
if (isExternal) {
// External provider: create a task for the dispatcher
const projectId = sessionRows[0]!.project_id;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
RETURNING id, state
`;
reply.code(202);
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
}
// Native provider: create streaming assistant row + enqueue inference
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
reply.code(202);
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
},
);
// POST /api/chats/:id/answer_user_input — answer a pending ask_user_input
app.post<{ Params: { id: string } }>(
'/api/chats/:id/answer_user_input',
async (req, reply) => {
const parsed = AnswerUserInputBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid_body', details: parsed.error.flatten() };
}
const { tool_call_id, answers } = parsed.data;
const chatRows = await sql<{ id: string; session_id: string }[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat_not_found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const callerRows = await sql<{
message_id: string;
payload: { id: string; name: string; args: Record<string, unknown> };
}[]>`
SELECT p.message_id, p.payload
FROM message_parts p
JOIN messages m ON m.id = p.message_id
WHERE m.chat_id = ${chat.id}
AND m.role = 'assistant'
AND p.kind = 'tool_call'
AND p.payload->>'id' = ${tool_call_id}
ORDER BY m.created_at DESC
LIMIT 1
`;
if (!callerRows[0]) {
reply.code(404);
return { error: 'unknown_tool_call_id' };
}
const foundCall = callerRows[0].payload;
if (foundCall.name !== 'ask_user_input') {
reply.code(400);
return { error: 'tool_call_not_ask_user_input' };
}
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
if (!argsParsed.success) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
}
const questions = argsParsed.data.questions;
if (answers.length !== questions.length) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `expected ${questions.length} answer(s), got ${answers.length}` };
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i]!;
const a = answers[i]!;
for (const sel of a.selected_options) {
if (!q.options.includes(sel)) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} option not in question: ${sel}` };
}
}
if (q.type === 'single_select' && a.selected_options.length > 1) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} multi on single_select` };
}
if (a.selected_options.length === 0 && (!a.free_text || !a.free_text.trim())) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
}
}
const toolRows = await sql<{
message_id: string;
payload: { tool_call_id: string; output: unknown };
}[]>`
SELECT p.message_id, p.payload
FROM message_parts p
JOIN messages m ON m.id = p.message_id
WHERE m.chat_id = ${chat.id}
AND m.role = 'tool'
AND p.kind = 'tool_result'
AND p.payload->>'tool_call_id' = ${tool_call_id}
ORDER BY m.created_at DESC
LIMIT 1
`;
if (!toolRows[0]) {
reply.code(404);
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
}
if (toolRows[0].payload?.output !== null) {
reply.code(409);
return { error: 'tool_call_already_answered' };
}
const answerSet = { answers };
const newToolResults = { tool_call_id, output: answerSet, truncated: false };
const toolMessageId = toolRows[0].message_id;
const result = await sql.begin(async (tx) => {
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return { tool_message_id: toolMessageId, assistant_message_id: assistantMsg!.id };
});
broker.publishFrame(sessionId, {
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id,
chat_id: chat.id,
output: answerSet,
truncated: false,
} as unknown as WsFrame);
inference.enqueue(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
// POST /api/sessions/:sessionId/stop — cancel active inference
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/stop',
async (req, reply) => {
const sessionId = req.params.sessionId;
// Find active chats in this session
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open'
`;
let cancelled = false;
for (const chat of chats) {
if (inference.hasActive(chat.id)) {
cancelled = await inference.cancel(sessionId, chat.id);
break;
}
}
return { cancelled };
},
);
}

View File

@@ -0,0 +1,172 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import {
listPending,
applyOne,
applyAll,
rejectOne,
rewindOne,
queueCreate,
} from '../services/pending_changes.js';
import { WriteGuardError } from '../services/write_guard.js';
const CreateBody = z.object({
file_path: z.string().min(1),
content: z.string(),
});
/**
* Resolve project root from a session's project path.
*/
async function resolveProjectRoot(sql: Sql, sessionId: string): Promise<string | null> {
const rows = await sql<{ path: string }[]>`
SELECT p.path FROM sessions s
JOIN projects p ON s.project_id = p.id
WHERE s.id = ${sessionId}
`;
return rows.length > 0 ? rows[0]!.path : null;
}
/**
* Resolve project root from a pending change's session.
*/
async function resolveProjectRootForChange(sql: Sql, changeId: string): Promise<string | null> {
const rows = await sql<{ path: string }[]>`
SELECT p.path FROM pending_changes pc
JOIN sessions s ON pc.session_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE pc.id = ${changeId}
`;
return rows.length > 0 ? rows[0]!.path : null;
}
export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/sessions/:sessionId/pending — list pending changes for a session
app.get<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/pending',
async (req, reply) => {
const sessionId = req.params.sessionId;
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const pending = await listPending(sql, sessionId);
return pending;
},
);
// POST /api/sessions/:sessionId/pending/create — queue a new-file create
// (manual create from the RightRail file browser; no inference involved).
// queueCreate runs resolveWritePath internally, so a path that escapes the
// project root or hits a secret file throws WriteGuardError → 422 with the
// guard message. Mirrors the { error } 404 shape used by the other routes
// and the 422 status used by apply/rewind on failure.
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/pending/create',
async (req, reply) => {
const sessionId = req.params.sessionId;
const parsed = CreateBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const projectRoot = await resolveProjectRoot(sql, sessionId);
if (!projectRoot) {
reply.code(404);
return { error: 'session or project not found' };
}
try {
const change = await queueCreate(
sql,
sessionId,
null,
parsed.data.file_path,
parsed.data.content,
projectRoot,
);
return change;
} catch (err) {
if (err instanceof WriteGuardError) {
reply.code(422);
return { error: err.message };
}
throw err;
}
},
);
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/pending/apply',
async (req, reply) => {
const sessionId = req.params.sessionId;
const projectRoot = await resolveProjectRoot(sql, sessionId);
if (!projectRoot) {
reply.code(404);
return { error: 'session or project not found' };
}
const results = await applyAll(sql, sessionId, projectRoot);
return { results };
},
);
// POST /api/pending/:id/apply — apply a single pending change
app.post<{ Params: { id: string } }>(
'/api/pending/:id/apply',
async (req, reply) => {
const changeId = req.params.id;
const projectRoot = await resolveProjectRootForChange(sql, changeId);
if (!projectRoot) {
reply.code(404);
return { error: 'pending change or project not found' };
}
const result = await applyOne(sql, changeId, projectRoot);
if (!result.success) {
reply.code(422);
}
return result;
},
);
// POST /api/pending/:id/reject — reject a single pending change
app.post<{ Params: { id: string } }>(
'/api/pending/:id/reject',
async (req, reply) => {
const changeId = req.params.id;
await rejectOne(sql, changeId);
return { ok: true };
},
);
// POST /api/pending/:id/rewind — rewind (undo) an applied change
app.post<{ Params: { id: string } }>(
'/api/pending/:id/rewind',
async (req, reply) => {
const changeId = req.params.id;
const projectRoot = await resolveProjectRootForChange(sql, changeId);
if (!projectRoot) {
reply.code(404);
return { error: 'pending change or project not found' };
}
const result = await rewindOne(sql, changeId, projectRoot);
if (!result.success) {
reply.code(422);
}
return result;
},
);
}

View File

@@ -0,0 +1,127 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import {
getProviderSnapshot,
clearProviderSnapshotCache,
peekSnapshotEntry,
} from '../services/provider-snapshot.js';
import {
load,
save,
CoderProvidersFileSchema,
ProviderConfigPatchSchema,
mergeProviderConfigPatch,
} from '../services/provider-config.js';
import {
reloadProviderConfig,
getResolvedRegistry,
} from '../services/provider-config-registry.js';
import {
getProviderDiagnostic,
type DiagnosticAgentRow,
} from '../services/provider-diagnostic.js';
const RefreshBodySchema = z.object({ providers: z.array(z.string()).optional() });
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
const cwd = req.query.cwd;
return getProviderSnapshot(sql, config, cwd);
});
// 4.1 — current loaded config file (raw CoderProvidersFile, not the resolved registry).
app.get('/api/providers/config', async (_req, _reply) => {
return load(config.CODER_PROVIDERS_PATH);
});
// 4.2 — patch the config file (design.md §6.2). Strict order is the whole
// correctness story: validate → save → reload → clear. A malformed body or an
// invalid merged result returns 422 and NEVER writes; a save failure returns
// 500 and leaves in-memory state untouched (no file/registry divergence).
app.patch('/api/providers/config', async (req, reply) => {
// 1. Validate the PATCH body shape (malformed → 422, never reaches merge).
const parsed = ProviderConfigPatchSchema.safeParse(req.body);
if (!parsed.success) {
return reply.code(422).send({
error: 'invalid provider config patch',
issues: parsed.error.flatten(),
});
}
// 2. Shallow per-id merge over the current file (null deletes; object replaces).
const current = load(config.CODER_PROVIDERS_PATH);
const merged = mergeProviderConfigPatch(current, parsed.data);
// 3. Validate the merged result — refuse to write a config that won't load.
const validated = CoderProvidersFileSchema.safeParse(merged);
if (!validated.success) {
return reply.code(422).send({
error: 'merged provider config is invalid',
issues: validated.error.flatten(),
});
}
// 4. Persist. If save throws, STOP here — do NOT reload/clear, so the file on
// disk and the in-memory resolved registry can never diverge.
try {
save(config.CODER_PROVIDERS_PATH, validated.data);
} catch (err) {
req.log.error(
{ err: err instanceof Error ? err.message : String(err), path: config.CODER_PROVIDERS_PATH },
'provider-config: save failed — in-memory state untouched',
);
return reply.code(500).send({ error: 'failed to write provider config' });
}
// 5 + 6. Rebuild the in-memory resolved registry from the new file, then drop
// the snapshot cache so the next /snapshot reflects the change.
reloadProviderConfig();
clearProviderSnapshotCache();
// 7. Return the new config (per §6.2 `{ ok: true }`, plus the merged providers
// so the client can update without a follow-up GET).
return { ok: true, providers: validated.data.providers };
});
// 4.3 — force a cold probe. Optional { providers?: string[] } narrows the
// reported subset (design.md §6.3 Paseo pattern). The force=true snapshot is
// the only existing re-probe primitive (per-provider force would be a
// snapshot-internal change, out of Phase 4 scope), so the probe runs for all
// installed providers; the `refreshed` count reflects the requested subset.
app.post('/api/providers/refresh', async (req, reply) => {
const parsed = RefreshBodySchema.safeParse(req.body ?? {});
if (!parsed.success) {
return reply.code(422).send({ error: 'invalid refresh body', issues: parsed.error.flatten() });
}
const subset = parsed.data.providers;
clearProviderSnapshotCache();
const entries = await getProviderSnapshot(sql, config, undefined, true);
const refreshed =
subset && subset.length > 0
? entries.filter((e) => subset.includes(e.name)).length
: entries.length;
return { refreshed };
});
// 4.4 — per-provider diagnostic (design.md §6.4 → JSON `{ diagnostic: string }`).
// Read-only: reports cached state (resolved def + available_agents row + warm
// snapshot cache for the last probe error) plus a `which` PATH check. No probe
// spawn. The report itself is a plaintext block (§8); the route wraps it as JSON.
app.get<{ Params: { id: string } }>('/api/providers/:id/diagnostic', async (req, reply) => {
const id = req.params.id;
const resolved = getResolvedRegistry().get(id);
if (!resolved) {
return reply.code(404).send({ error: `unknown provider '${id}'` });
}
const rows = await sql<DiagnosticAgentRow[]>`
SELECT name, install_path, supports_acp, models, last_probed_at
FROM available_agents WHERE name = ${id}
`;
const report = await getProviderDiagnostic(resolved, rows[0], {
cachedEntry: peekSnapshotEntry(id),
});
return { diagnostic: report };
});
}

View File

@@ -0,0 +1,124 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import { getSkillBody } from '@boocode/server/skills';
import {
buildSkillInvokeSyntheticFrames,
buildSkillInvokeUserFrames,
DEFAULT_SKILL_USER_MESSAGE,
runSkillInvokeTransaction,
} from '@boocode/server/skill-invoke';
import { resolveChatId } from './chat-resolve.js';
const SkillInvokeBody = z.object({
pane_id: z.string().min(1).max(200),
skill_name: z.string().min(1),
user_message: z.string().max(64_000).nullable().optional(),
// v2.5.9: when set to an external provider, the skill runs UNDER that agent —
// its body is injected into a dispatched task instead of native inference.
provider: z.string().max(100).optional(),
model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
});
interface InferenceApi {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
hasActive: (chatId: string) => boolean;
}
export function registerSkillRoutes(
app: FastifyInstance,
sql: Sql,
broker: Broker,
inference: InferenceApi,
): void {
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/skill_invoke',
async (req, reply) => {
const parsed = SkillInvokeBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const sessionId = req.params.sessionId;
const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data;
const sessionRows = await sql<{ id: string; project_id: string }[]>`
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const chatId = await resolveChatId(sql, sessionId, pane_id);
if (!chatId) {
reply.code(404);
return { error: 'pane not found' };
}
if (inference.hasActive(chatId)) {
reply.code(409);
return { error: 'inference already running on this chat' };
}
const userText = parsed.data.user_message?.trim()
? parsed.data.user_message
: DEFAULT_SKILL_USER_MESSAGE;
const body = await getSkillBody(skill_name);
if (body === null) {
reply.code(404);
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
}
// v2.5.9: external agent → run the skill UNDER that agent. The skill body
// stays server-side (like the native path's tool message) and is injected
// into a dispatched task; the agent receives the skill instructions + the
// user's text. Mirrors the messages-route external-provider dispatch.
if (provider && provider !== 'boocode') {
const [userMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${userText}, 'complete', clock_timestamp())
RETURNING id
`;
broker.publishFrame(sessionId, { type: 'message_started', message_id: userMsg!.id, chat_id: chatId, role: 'user' } as WsFrame);
broker.publishFrame(sessionId, { type: 'delta', message_id: userMsg!.id, chat_id: chatId, content: userText } as WsFrame);
broker.publishFrame(sessionId, { type: 'message_complete', message_id: userMsg!.id, chat_id: chatId } as WsFrame);
const taskInput = `${body}\n\n---\n\n${userText}`;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
RETURNING id, state
`;
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
reply.code(202);
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
}
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
sessionId,
chatId,
skillName: skill_name,
skillBody: body,
userText,
});
for (const frame of buildSkillInvokeSyntheticFrames(chatId, result, toolCall, body)) {
broker.publishFrame(sessionId, frame as WsFrame);
}
for (const frame of buildSkillInvokeUserFrames(chatId, result.user_message_id, userText)) {
broker.publishFrame(sessionId, frame as WsFrame);
}
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
}

View File

@@ -0,0 +1,48 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
const CostQuery = z.object({
group_by: z.enum(['project', 'agent', 'day']).default('project'),
});
export function registerStatsRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/stats/costs — aggregate cost_tokens by project, agent, or day
app.get('/api/stats/costs', async (req, reply) => {
const parsed = CostQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid query', details: parsed.error.flatten() };
}
const { group_by } = parsed.data;
switch (group_by) {
case 'project':
return sql`
SELECT project_id, COUNT(*)::int AS task_count, COALESCE(SUM(cost_tokens), 0)::int AS total_tokens
FROM tasks
WHERE cost_tokens IS NOT NULL
GROUP BY project_id
ORDER BY total_tokens DESC
`;
case 'agent':
return sql`
SELECT COALESCE(agent, 'native') AS agent, COUNT(*)::int AS task_count, COALESCE(SUM(cost_tokens), 0)::int AS total_tokens
FROM tasks
WHERE cost_tokens IS NOT NULL
GROUP BY agent
ORDER BY total_tokens DESC
`;
case 'day':
return sql`
SELECT DATE(created_at) AS day, COUNT(*)::int AS task_count, COALESCE(SUM(cost_tokens), 0)::int AS total_tokens
FROM tasks
WHERE cost_tokens IS NOT NULL
GROUP BY DATE(created_at)
ORDER BY day DESC
LIMIT 90
`;
}
});
}

View File

@@ -0,0 +1,185 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import { getPendingPermission, respondToPermission, cancelPendingPermission } from '../services/permission-waiter.js';
import { getTaskCommands } from '../services/agent-commands-cache.js';
interface InferenceApi {
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
}
const CreateBody = z.object({
project_id: z.string().uuid(),
input: z.string().min(1).max(64_000),
agent: z.string().max(100).optional(),
model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
});
const PermissionBody = z.object({
option_id: z.string().max(200).nullable(),
updated_input: z.record(z.unknown()).optional(),
});
const ListQuery = z.object({
state: z.enum(['pending', 'running', 'completed', 'failed', 'blocked', 'cancelled']).optional(),
project_id: z.string().uuid().optional(),
});
export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: InferenceApi): void {
// POST /api/tasks — create a new task
app.post('/api/tasks', async (req, reply) => {
const parsed = CreateBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, input, agent, model, mode_id, thinking_option_id } = parsed.data;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id)
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null})
RETURNING id, state
`;
reply.code(201);
return { id: task!.id, state: task!.state };
});
// GET /api/tasks — list tasks with optional filters
app.get('/api/tasks', async (req, _reply) => {
const parsed = ListQuery.safeParse(req.query);
if (!parsed.success) {
return { error: 'invalid query', details: parsed.error.flatten() };
}
const { state, project_id } = parsed.data;
// Build query with optional filters
if (state && project_id) {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
WHERE state = ${state} AND project_id = ${project_id}
ORDER BY created_at DESC
LIMIT 100
`;
} else if (state) {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
WHERE state = ${state}
ORDER BY created_at DESC
LIMIT 100
`;
} else if (project_id) {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
WHERE project_id = ${project_id}
ORDER BY created_at DESC
LIMIT 100
`;
} else {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
ORDER BY created_at DESC
LIMIT 100
`;
}
});
// GET /api/tasks/:id — single task detail
app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => {
const rows = await sql`
SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, execution_path, worktree_path, session_id, cost_tokens, started_at, ended_at, created_at
FROM tasks
WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'task not found' };
}
return rows[0];
});
// POST /api/tasks/:id/cancel — cancel a pending or running task
app.post<{ Params: { id: string } }>('/api/tasks/:id/cancel', async (req, reply) => {
const taskId = req.params.id;
// Get current task state + session info
const rows = await sql<{ id: string; state: string; session_id: string | null }[]>`
SELECT id, state, session_id FROM tasks WHERE id = ${taskId}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'task not found' };
}
const task = rows[0]!;
if (task.state !== 'pending' && task.state !== 'running' && task.state !== 'blocked') {
reply.code(409);
return { error: `cannot cancel task in state '${task.state}'` };
}
cancelPendingPermission(taskId);
// If running, try to cancel inference
if ((task.state === 'running' || task.state === 'blocked') && task.session_id) {
// Find active chat in the task's session
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
`;
for (const chat of chats) {
await inference.cancel(task.session_id, chat.id);
}
}
await sql`
UPDATE tasks
SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId} AND state IN ('pending', 'running', 'blocked')
`;
return { cancelled: true };
});
// GET /api/tasks/:id/permission — pending permission prompt (if any)
app.get<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
const prompt = getPendingPermission(req.params.id);
if (!prompt) {
reply.code(404);
return { error: 'no pending permission' };
}
return prompt;
});
// POST /api/tasks/:id/permission — respond to a pending permission prompt
app.post<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
const parsed = PermissionBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const ok = respondToPermission(req.params.id, parsed.data.option_id, parsed.data.updated_input as Record<string, unknown> | undefined);
if (!ok) {
reply.code(404);
return { error: 'no pending permission' };
}
return { ok: true };
});
// GET /api/tasks/:id/commands — cached ACP slash commands (if any)
app.get<{ Params: { id: string } }>('/api/tasks/:id/commands', async (req, reply) => {
const commands = getTaskCommands(req.params.id);
if (!commands?.length) {
reply.code(404);
return { error: 'no commands cached' };
}
return { taskId: req.params.id, commands };
});
}

View File

@@ -0,0 +1,51 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
export function registerWebSocket(
app: FastifyInstance,
sql: Sql,
broker: Broker,
): void {
// Per-session streaming WebSocket. Clients connect here to receive live
// inference frames (deltas, tool_calls, tool_results, message_complete).
app.get<{ Params: { sessionId: string } }>(
'/api/ws/sessions/:sessionId',
{ websocket: true },
async (socket, req) => {
const sessionId = req.params.sessionId;
// Validate session exists
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
if (session.length === 0) {
socket.send(JSON.stringify({ type: 'error', error: 'session not found' }));
socket.close(1008, 'session not found');
return;
}
// Send snapshot of existing messages so client can hydrate
const messages = await sql<Record<string, unknown>[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, reasoning_parts, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
summary, tail_start_id, compacted_at
FROM messages_with_parts
WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC
`;
socket.send(JSON.stringify({ type: 'snapshot', messages }));
// Subscribe to broker for live frames
const unsubscribe = broker.subscribe(sessionId, (frame) => {
if (socket.readyState !== socket.OPEN) return;
try {
socket.send(JSON.stringify(frame));
} catch (err) {
app.log.warn({ err, sessionId }, 'ws send failed');
}
});
socket.on('close', () => unsubscribe());
socket.on('error', () => unsubscribe());
},
);
}

122
apps/coder/src/schema.sql Normal file
View File

@@ -0,0 +1,122 @@
-- v2.0.0: BooCoder schema — pending changes, tasks, agent registry.
-- Applied on startup by apps/coder/src/db.ts:applySchema().
-- Lives in the same 'boochat' database as BooChat's tables.
CREATE TABLE IF NOT EXISTS pending_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL,
task_id UUID,
file_path TEXT NOT NULL,
operation TEXT NOT NULL,
diff TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT pending_changes_operation_chk CHECK (operation IN ('create', 'edit', 'delete')),
CONSTRAINT pending_changes_status_chk CHECK (status IN ('pending', 'applied', 'rejected', 'reverted'))
);
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL,
parent_task_id UUID REFERENCES tasks(id),
state TEXT NOT NULL DEFAULT 'pending',
input TEXT NOT NULL,
output_summary TEXT,
agent TEXT,
model TEXT,
execution_path TEXT,
worktree_path TEXT,
cost_tokens INTEGER,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen'))
);
CREATE TABLE IF NOT EXISTS available_agents (
name TEXT PRIMARY KEY,
install_path TEXT,
version TEXT,
supports_acp BOOLEAN NOT NULL DEFAULT false,
supports_mcp_client BOOLEAN NOT NULL DEFAULT false,
last_probed_at TIMESTAMPTZ
);
-- v2.0.0 Phase 4: link tasks to their inference sessions.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id);
-- v2.0.5: add 'qwen' to execution_path CHECK + arena_id column.
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_execution_path_chk;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tasks_execution_path_chk') THEN
ALTER TABLE tasks ADD CONSTRAINT tasks_execution_path_chk
CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen'));
END IF;
END $$;
-- v2.0.5: arena support — group tasks into competitive arenas.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS arena_id UUID;
-- Human inbox: tasks needing attention
CREATE OR REPLACE VIEW human_inbox AS
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
-- v2.1.0: provider picker — extend available_agents with model discovery.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
-- v2.5.10: persisted ACP available_commands (captured during the cold probe), so
-- an agent's live command set survives the tier-2 probe skip and shows without a
-- dispatch.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]'::jsonb;
-- v2.2.0: Paseo-style session config on tasks.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
-- v2.6: one shared worktree per session (all agents/panes in the session operate in it).
CREATE TABLE IF NOT EXISTS session_worktrees (
session_id UUID PRIMARY KEY REFERENCES sessions(id),
worktree_path TEXT NOT NULL,
base_commit TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- v2.6: one backend session per (session, agent); resumed on switch-back.
CREATE TABLE IF NOT EXISTS agent_sessions (
session_id UUID NOT NULL REFERENCES sessions(id),
agent TEXT NOT NULL,
backend TEXT NOT NULL,
agent_session_id TEXT,
server_port INTEGER,
status TEXT NOT NULL DEFAULT 'idle',
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (session_id, agent),
CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server', 'acp_warm')),
CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle', 'active', 'crashed', 'closed'))
);
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same
-- transaction, so the dispatcher reacts immediately instead of waiting for the
-- fallback poll. Postgres holds the notification until COMMIT, so the listener
-- always sees the committed row. A trigger covers all insert paths with no
-- app-code drift. Idempotent: re-applied on every startup.
CREATE OR REPLACE FUNCTION notify_tasks_new() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('tasks_new', '');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS tasks_notify_new ON tasks;
CREATE TRIGGER tasks_notify_new
AFTER INSERT ON tasks
FOR EACH ROW
EXECUTE FUNCTION notify_tasks_new();

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readWorktreeTextFile, writeWorktreeTextFile } from '../acp-client-fs.js';
const created: string[] = [];
function freshWorktree(): string {
const wt = mkdtempSync(join(tmpdir(), 'acp-wt-'));
created.push(wt);
return wt;
}
afterEach(() => {
for (const d of created.splice(0)) {
try {
rmSync(d, { recursive: true, force: true });
rmSync(`${d}-evil`, { recursive: true, force: true });
} catch {
/* ignore */
}
}
});
describe('acp-client-fs worktree scoping', () => {
it('writes then reads a file inside the worktree', async () => {
const wt = freshWorktree();
await writeWorktreeTextFile(wt, 'sub/dir/note.txt', 'hello');
expect(await readWorktreeTextFile(wt, 'sub/dir/note.txt')).toBe('hello');
});
it('rejects ../ traversal on read', async () => {
const wt = freshWorktree();
await expect(readWorktreeTextFile(wt, '../../etc/passwd')).rejects.toThrow(/escapes worktree/);
});
it('rejects ../ traversal on write', async () => {
const wt = freshWorktree();
await expect(writeWorktreeTextFile(wt, '../escape.txt', 'x')).rejects.toThrow(/escapes worktree/);
});
it('rejects a sibling-prefix path (the unbounded-startsWith bug)', async () => {
const wt = freshWorktree();
// Absolute path that shares the worktree as a STRING prefix but is a sibling
// dir: `<wt>-evil/...`. A bare `startsWith(<wt>)` wrongly admits it.
await expect(readWorktreeTextFile(wt, `${wt}-evil/secret.txt`)).rejects.toThrow(/escapes worktree/);
await expect(writeWorktreeTextFile(wt, `${wt}-evil/secret.txt`, 'x')).rejects.toThrow(
/escapes worktree/,
);
});
});

View File

@@ -0,0 +1,154 @@
import { describe, it, expect } from 'vitest';
import type { SessionConfigOption } from '@agentclientprotocol/sdk';
import {
deriveModesFromACP,
deriveModelDefinitionsFromACP,
findThoughtLevelConfigId,
} from '../acp-derive.js';
describe('deriveModesFromACP', () => {
it('prefers modeState.availableModes when present', () => {
const { modes, currentModeId } = deriveModesFromACP(
[{ id: 'fallback', label: 'Fallback' }],
{
currentModeId: 'plan',
availableModes: [
{ id: 'plan', name: 'Plan', description: 'Read-only planning' },
{ id: 'code', name: 'Code' },
],
},
);
expect(modes).toEqual([
{ id: 'plan', label: 'Plan', description: 'Read-only planning' },
{ id: 'code', label: 'Code', description: undefined },
]);
expect(currentModeId).toBe('plan');
});
it('falls back to configOptions mode select', () => {
const configOptions: SessionConfigOption[] = [
{
type: 'select',
id: 'mode',
category: 'mode',
currentValue: 'auto',
options: [
{ value: 'auto', name: 'Auto' },
{ value: 'manual', name: 'Manual', description: 'Ask first' },
],
},
];
const { modes, currentModeId } = deriveModesFromACP([], null, configOptions);
expect(modes).toEqual([
{ id: 'auto', label: 'Auto', description: undefined },
{ id: 'manual', label: 'Manual', description: 'Ask first' },
]);
expect(currentModeId).toBe('auto');
});
it('uses static fallback when no ACP mode data', () => {
const fallback = [{ id: 'default', label: 'Default' }];
const { modes, currentModeId } = deriveModesFromACP(fallback, null, null);
expect(modes).toEqual(fallback);
expect(currentModeId).toBeNull();
});
});
describe('deriveModelDefinitionsFromACP', () => {
it('maps availableModels with thought_level options', () => {
const configOptions: SessionConfigOption[] = [
{
type: 'select',
id: 'thought',
category: 'thought_level',
currentValue: 'medium',
options: [
{ value: 'low', name: 'Low' },
{ value: 'medium', name: 'Medium' },
],
},
];
const models = deriveModelDefinitionsFromACP(
{
currentModelId: 'gpt-4',
availableModels: [
{ modelId: 'gpt-4', name: 'GPT-4' },
{ modelId: 'gpt-4-mini', name: 'Mini', description: 'Cheaper' },
],
},
configOptions,
);
expect(models).toEqual([
{
id: 'gpt-4',
label: 'GPT-4',
description: undefined,
isDefault: true,
thinkingOptions: [
{ id: 'low', label: 'Low', isDefault: false },
{ id: 'medium', label: 'Medium', isDefault: true },
],
defaultThinkingOptionId: 'medium',
},
{
id: 'gpt-4-mini',
label: 'Mini',
description: 'Cheaper',
isDefault: false,
thinkingOptions: [
{ id: 'low', label: 'Low', isDefault: false },
{ id: 'medium', label: 'Medium', isDefault: true },
],
defaultThinkingOptionId: 'medium',
},
]);
});
it('falls back to model select config when no availableModels', () => {
const configOptions: SessionConfigOption[] = [
{
type: 'select',
id: 'model',
category: 'model',
currentValue: 'sonnet',
options: [
{ value: 'sonnet', name: 'Sonnet' },
{ value: 'opus', name: 'Opus' },
],
},
];
const models = deriveModelDefinitionsFromACP(null, configOptions);
expect(models).toEqual([
{ id: 'sonnet', label: 'Sonnet', isDefault: true, defaultThinkingOptionId: undefined },
{ id: 'opus', label: 'Opus', isDefault: false, defaultThinkingOptionId: undefined },
]);
});
});
describe('findThoughtLevelConfigId', () => {
it('returns thought_level select id', () => {
const configOptions: SessionConfigOption[] = [
{
type: 'select',
id: 'effort',
category: 'thought_level',
currentValue: 'high',
options: [{ value: 'high', name: 'High' }],
},
];
expect(findThoughtLevelConfigId(configOptions)).toBe('effort');
});
it('returns null when missing', () => {
expect(findThoughtLevelConfigId(null)).toBeNull();
});
});

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { resolveLaunchSpec, resolveAcpSpawnArgs } from '../acp-spawn.js';
import { buildResolvedRegistry } from '../provider-config-registry.js';
import type { CoderProvidersFile } from '../provider-config.js';
import { PROVIDERS } from '../provider-registry.js';
/** Resolved def for a provider id under the given config (default: no override). */
function builtin(name: string, providers: CoderProvidersFile['providers'] = {}) {
const def = buildResolvedRegistry(PROVIDERS, { providers }).get(name);
if (!def) throw new Error(`no resolved def for ${name}`);
return def;
}
describe('resolveLaunchSpec', () => {
// --- byte-identical built-in regression (the HARD CONSTRAINT) ---------------
// These argv values are the pre-v2.3 resolveAcpSpawnArgs switch outputs and
// MUST NOT change. spawn() is `spawn(spec.binary, spec.args, ...)`, so argv
// parity here is dispatch parity.
it('opencode (no override) → byte-identical argv ["acp"], binary = installPath', () => {
const spec = resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode');
expect(spec).not.toBeNull();
expect(spec!.args).toEqual(['acp']); // pre-v2.3 value
expect(spec!.binary).toBe('/usr/bin/opencode');
expect(spec!.env).toBeUndefined();
// cross-check against the switch source-of-truth
expect(spec!.args).toEqual(resolveAcpSpawnArgs('opencode'));
});
it('goose → ["acp"], qwen → ["--acp"] (byte-identical)', () => {
expect(resolveLaunchSpec(builtin('goose'), '/usr/bin/goose')!.args).toEqual(['acp']);
expect(resolveLaunchSpec(builtin('qwen'), '/usr/bin/qwen')!.args).toEqual(['--acp']);
});
it('built-in with null installPath falls back to the bare id (pre-v2.3 `installPath ?? agent`)', () => {
const spec = resolveLaunchSpec(builtin('opencode'), null);
expect(spec!.binary).toBe('opencode');
expect(spec!.args).toEqual(['acp']);
});
it('non-ACP / unknown provider → null (claude has no ACP argv)', () => {
expect(resolveLaunchSpec(builtin('claude'), '/usr/bin/claude')).toBeNull();
expect(resolveLaunchSpec(builtin('boocode'), null)).toBeNull();
});
// --- config-driven launch (the new capability) ------------------------------
it('custom ACP entry → configured command + env reach the spec', () => {
const def = builtin('amp-acp', {
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'], env: { AMP_KEY: 'x' } },
});
const spec = resolveLaunchSpec(def, '/usr/local/bin/amp-acp');
expect(spec).not.toBeNull();
expect(spec!.binary).toBe('amp-acp'); // command[0], not the resolved install path
expect(spec!.args).toEqual(['--acp']); // command.slice(1)
expect(spec!.env).toEqual({ AMP_KEY: 'x' });
});
it('built-in WITH a config command override uses the override, not the switch default', () => {
const def = builtin('opencode', { opencode: { command: ['opencode', 'acp', '--verbose'], env: { DEBUG: '1' } } });
const spec = resolveLaunchSpec(def, '/usr/bin/opencode');
expect(spec!.binary).toBe('opencode');
expect(spec!.args).toEqual(['acp', '--verbose']);
expect(spec!.env).toEqual({ DEBUG: '1' });
});
});
describe('acp-dispatch spawn wiring (documented pass-through)', () => {
// dispatchViaAcp spawns `spawn(spec.binary, spec.args, { env: { ...process.env, ...spec.env } })`.
// The env merge layers config env over process.env; for a built-in with no
// config env, spec.env is undefined → { ...process.env } (byte-identical).
it('built-in with no config env yields an undefined spec.env (→ plain process.env at spawn)', () => {
expect(resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode')!.env).toBeUndefined();
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import {
mergeToolSnapshot,
mapToolLifecycleStatus,
snapshotToWireToolCall,
synthesizeCanceledSnapshots,
} from '../acp-tool-snapshot.js';
describe('mergeToolSnapshot', () => {
it('preserves stable toolCallId across updates', () => {
const first = mergeToolSnapshot('tc-1', {
toolCallId: 'tc-1',
title: 'Read file',
kind: 'read',
status: 'in_progress',
rawInput: { path: 'foo.ts' },
});
const merged = mergeToolSnapshot(
'tc-1',
{
toolCallId: 'tc-1',
title: 'Read file',
status: 'completed',
rawOutput: { content: 'hello' },
},
first,
);
expect(merged.toolCallId).toBe('tc-1');
expect(merged.rawInput).toEqual({ path: 'foo.ts' });
expect(merged.status).toBe('completed');
expect(merged.rawOutput).toEqual({ content: 'hello' });
});
});
describe('snapshotToWireToolCall', () => {
it('embeds ACP lifecycle meta for UI merge', () => {
const wire = snapshotToWireToolCall({
toolCallId: 'tc-42',
title: 'Edit',
kind: 'edit',
status: 'completed',
rawInput: { path: 'a.ts' },
rawOutput: 'ok',
});
expect(wire.id).toBe('tc-42');
expect(wire.name).toBe('edit');
expect(wire.args._acp).toMatchObject({ status: 'completed', title: 'Edit', output: 'ok' });
});
it('maps synthesized cancel to canceled lifecycle', () => {
const [canceled] = synthesizeCanceledSnapshots([
{ toolCallId: 'tc-1', title: 'Run', status: 'in_progress' },
]);
const wire = snapshotToWireToolCall(canceled!);
expect(wire.args._acp).toMatchObject({ status: 'canceled' });
});
});
describe('mapToolLifecycleStatus', () => {
it('maps ACP statuses to UI lifecycle', () => {
expect(mapToolLifecycleStatus('completed')).toBe('completed');
expect(mapToolLifecycleStatus('failed')).toBe('failed');
expect(mapToolLifecycleStatus('in_progress')).toBe('running');
expect(mapToolLifecycleStatus(undefined, 'canceled')).toBe('canceled');
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { readFile, rm, mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import postgres from 'postgres';
import { queueCreate, queueEdit, queueDelete, applyOne, rewindOne, listPending } from '../pending_changes.js';
/**
* Integration test for the full pending-changes lifecycle.
* Requires DATABASE_URL env var pointing to a running postgres instance.
* Skips cleanly when DATABASE_URL is not set.
*
* Run with:
* DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/coder test
*/
describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () => {
let sql: ReturnType<typeof postgres>;
const testDir = '/tmp/boocode-pending-changes-test-' + Date.now();
const projectRoot = testDir;
const testSessionId = '00000000-0000-0000-0000-000000000001';
beforeAll(async () => {
sql = postgres(process.env.DATABASE_URL!, { max: 3 });
// Apply schema
const schemaPath = resolve(__dirname, '../../schema.sql');
const ddl = readFileSync(schemaPath, 'utf8');
await sql.unsafe(ddl);
// Create temp project directory
await mkdir(testDir, { recursive: true });
});
afterAll(async () => {
// Cleanup test data
await sql`DELETE FROM pending_changes WHERE session_id = ${testSessionId}`;
await sql.end({ timeout: 5 });
// Remove temp directory
await rm(testDir, { recursive: true, force: true });
});
it('queueCreate → listPending → applyOne → verify file exists', async () => {
const change = await queueCreate(sql, testSessionId, null, 'hello.txt', 'hello world', projectRoot);
expect(change.status).toBe('pending');
expect(change.operation).toBe('create');
const pending = await listPending(sql, testSessionId);
expect(pending.some((p) => p.id === change.id)).toBe(true);
const result = await applyOne(sql, change.id, projectRoot);
expect(result.success).toBe(true);
const content = await readFile(resolve(testDir, 'hello.txt'), 'utf8');
expect(content).toBe('hello world');
});
it('queueEdit → apply → verify content changed', async () => {
// Setup: create a file first
const createChange = await queueCreate(sql, testSessionId, null, 'editable.txt', 'original content here', projectRoot);
await applyOne(sql, createChange.id, projectRoot);
// Queue an edit
const editChange = await queueEdit(sql, testSessionId, null, 'editable.txt', 'original', 'modified', projectRoot);
expect(editChange.operation).toBe('edit');
const result = await applyOne(sql, editChange.id, projectRoot);
expect(result.success).toBe(true);
const content = await readFile(resolve(testDir, 'editable.txt'), 'utf8');
expect(content).toBe('modified content here');
});
it('queueDelete → apply → verify file gone', async () => {
// Setup: create a file
const createChange = await queueCreate(sql, testSessionId, null, 'deleteme.txt', 'goodbye', projectRoot);
await applyOne(sql, createChange.id, projectRoot);
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(true);
// Queue a delete
const deleteChange = await queueDelete(sql, testSessionId, null, 'deleteme.txt', projectRoot);
const result = await applyOne(sql, deleteChange.id, projectRoot);
expect(result.success).toBe(true);
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
});
it('rewindOne → verify reverted', async () => {
// Setup: create and apply a file
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);
await applyOne(sql, createChange.id, projectRoot);
// Rewind the create (should delete the file)
const result = await rewindOne(sql, createChange.id, projectRoot);
expect(result.success).toBe(true);
expect(existsSync(resolve(testDir, 'rewindable.txt'))).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { getManifestCommands, mergeCommands, PROVIDER_COMMANDS } from '../provider-commands.js';
describe('provider-commands', () => {
it('defines commands for every external harness', () => {
for (const name of ['claude', 'opencode', 'goose', 'qwen']) {
expect(getManifestCommands(name).length, name).toBeGreaterThan(0);
}
});
it('boocode uses frontend skills — empty manifest', () => {
expect(getManifestCommands('boocode')).toEqual([]);
expect(PROVIDER_COMMANDS.boocode).toEqual([]);
});
it('mergeCommands dedupes by name with later override', () => {
const merged = mergeCommands(
[{ name: 'help', description: 'a' }],
[{ name: 'help', description: 'b' }, { name: 'clear' }],
);
expect(merged).toEqual([
{ name: 'clear' },
{ name: 'help', description: 'b' },
]);
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest';
import { buildResolvedRegistry } from '../provider-config-registry.js';
import { PROVIDERS } from '../provider-registry.js';
import type { CoderProvidersFile } from '../provider-config.js';
describe('buildResolvedRegistry', () => {
it('applies a built-in override (goose label)', () => {
const config: CoderProvidersFile = { providers: { goose: { label: 'Goosey' } } };
const reg = buildResolvedRegistry(PROVIDERS, config);
const goose = reg.get('goose');
expect(goose).toBeDefined();
expect(goose!.label).toBe('Goosey');
expect(goose!.configLabel).toBe('Goosey');
expect(goose!.enabled).toBe(true);
expect(goose!.isBuiltin).toBe(true);
expect(goose!.isCustomAcp).toBe(false);
});
it('adds a custom ACP entry (extends:acp + label + command)', () => {
const config: CoderProvidersFile = {
providers: {
'amp-acp': { extends: 'acp', label: 'Amp', description: 'ACP wrapper', command: ['amp-acp', '--acp'], env: { AMP: '1' } },
},
};
const reg = buildResolvedRegistry(PROVIDERS, config);
const amp = reg.get('amp-acp');
expect(amp).toBeDefined();
expect(amp!.isCustomAcp).toBe(true);
expect(amp!.isBuiltin).toBe(false);
expect(amp!.transport).toBe('acp');
expect(amp!.modelSource).toBe('probe');
expect(amp!.launchCommand).toEqual(['amp-acp', '--acp']);
expect(amp!.env).toEqual({ AMP: '1' });
expect(amp!.enabled).toBe(true);
});
it('keeps a disabled built-in in the registry flagged disabled (goose)', () => {
const config: CoderProvidersFile = { providers: { goose: { enabled: false } } };
const reg = buildResolvedRegistry(PROVIDERS, config);
expect(reg.has('goose')).toBe(true);
expect(reg.get('goose')!.enabled).toBe(false);
});
it('skips a custom id without extends (no throw)', () => {
const config: CoderProvidersFile = { providers: { weird: { label: 'Weird', command: ['weird'] } } };
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const reg = buildResolvedRegistry(PROVIDERS, config);
expect(reg.has('weird')).toBe(false);
// built-ins untouched
expect(reg.size).toBe(PROVIDERS.length);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('ignores enabled:false on boocode and warns', () => {
const config: CoderProvidersFile = { providers: { boocode: { enabled: false } } };
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const reg = buildResolvedRegistry(PROVIDERS, config);
expect(reg.get('boocode')!.enabled).toBe(true);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('carries config models + additionalModels onto built-in and custom defs', () => {
const reg = buildResolvedRegistry(PROVIDERS, {
providers: {
claude: { models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }] },
'amp-acp': {
extends: 'acp',
label: 'Amp',
command: ['amp-acp'],
additionalModels: [{ id: 'amp-1', label: 'Amp 1' }],
},
},
});
expect(reg.get('claude')!.configModels).toEqual([{ id: 'claude-opus-4-8', label: 'Opus 4.8' }]);
expect(reg.get('amp-acp')!.configAdditionalModels).toEqual([{ id: 'amp-1', label: 'Amp 1' }]);
});
it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => {
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
expect(reg.size).toBe(PROVIDERS.length);
expect([...reg.keys()]).toEqual(PROVIDERS.map((p) => p.name));
for (const def of PROVIDERS) {
const r = reg.get(def.name)!;
expect(r.enabled).toBe(true);
expect(r.isBuiltin).toBe(true);
expect(r.isCustomAcp).toBe(false);
expect(r.launchCommand).toBeNull();
expect(r.label).toBe(def.label);
}
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import {
mergeProviderConfigPatch,
ProviderConfigPatchSchema,
CoderProvidersFileSchema,
type CoderProvidersFile,
} from '../provider-config.js';
describe('ProviderConfigPatchSchema', () => {
it('accepts a per-provider override patch', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: false } } });
expect(parsed.success).toBe(true);
});
it('accepts a null value (delete-the-override sentinel)', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: null } });
expect(parsed.success).toBe(true);
});
it('defaults providers to {} on an empty body', () => {
const parsed = ProviderConfigPatchSchema.safeParse({});
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.providers).toEqual({});
});
it('rejects a malformed override (wrong field type)', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: 'yes' } } });
expect(parsed.success).toBe(false);
});
it('rejects a non-object providers map', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: 123 });
expect(parsed.success).toBe(false);
});
});
describe('mergeProviderConfigPatch', () => {
const current: CoderProvidersFile = {
providers: {
goose: { enabled: true, label: 'Goose' },
opencode: { enabled: true },
},
};
it('replaces an existing override object wholesale (not deep-merge)', () => {
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
// Whole override replaced — the prior `label` is gone, only `enabled` remains.
expect(merged.providers.goose).toEqual({ enabled: false });
});
it('adds a brand-new override id', () => {
const merged = mergeProviderConfigPatch(current, {
providers: { 'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp'] } },
});
expect(merged.providers['amp-acp']).toEqual({ extends: 'acp', label: 'Amp', command: ['amp-acp'] });
});
it('deletes an override when the value is null', () => {
const merged = mergeProviderConfigPatch(current, { providers: { goose: null } });
expect(merged.providers.goose).toBeUndefined();
expect(Object.keys(merged.providers)).toEqual(['opencode']);
});
it('leaves ids absent from the patch untouched', () => {
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
expect(merged.providers.opencode).toEqual({ enabled: true });
});
it('does not mutate the input config', () => {
const snapshot = JSON.parse(JSON.stringify(current));
mergeProviderConfigPatch(current, { providers: { goose: null, opencode: { enabled: false } } });
expect(current).toEqual(snapshot);
});
it('empty patch returns an equivalent config', () => {
const merged = mergeProviderConfigPatch(current, { providers: {} });
expect(merged).toEqual(current);
});
});
describe('CoderProvidersFileSchema (validate-before-save guard)', () => {
it('accepts a clean merged config', () => {
const merged = mergeProviderConfigPatch(
{ providers: {} },
{ providers: { goose: { enabled: false } } },
);
expect(CoderProvidersFileSchema.safeParse(merged).success).toBe(true);
});
it('rejects a config carrying an invalid override (never written)', () => {
// A merged object that somehow holds a bad override must fail validation
// so the PATCH route returns 422 and never calls save().
const invalid = { providers: { goose: { enabled: 'nope' } } };
expect(CoderProvidersFileSchema.safeParse(invalid).success).toBe(false);
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { getProviderDiagnostic, type DiagnosticAgentRow } from '../provider-diagnostic.js';
import { buildResolvedRegistry } from '../provider-config-registry.js';
import { PROVIDERS } from '../provider-registry.js';
import type { ProviderSnapshotEntry } from '../provider-types.js';
const registry = buildResolvedRegistry(PROVIDERS, {
providers: {
goose: { enabled: false },
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'] },
},
});
const alwaysAvailable = () => Promise.resolve(true);
const neverAvailable = () => Promise.resolve(false);
describe('getProviderDiagnostic', () => {
it('reports a disabled built-in (enabled:false, no install)', async () => {
const report = await getProviderDiagnostic(registry.get('goose')!, undefined, {
checkAvailable: neverAvailable,
});
expect(report).toContain('provider: goose');
expect(report).toContain('enabled: false');
expect(report).toContain('installed: false');
expect(report).toMatch(/command_available:\s*false/);
});
it('reports an installed built-in with its install_path, last_probed_at, model count', async () => {
const agentRow: DiagnosticAgentRow = {
name: 'opencode',
install_path: '/usr/bin/opencode',
supports_acp: true,
models: [
{ id: 'm1', label: 'M1' },
{ id: 'm2', label: 'M2' },
],
last_probed_at: '2026-05-29T12:00:00.000Z',
};
const report = await getProviderDiagnostic(registry.get('opencode')!, agentRow, {
checkAvailable: alwaysAvailable,
});
expect(report).toContain('install_path: /usr/bin/opencode');
expect(report).toContain('2026-05-29T12:00:00.000Z');
expect(report).toContain('installed: true');
expect(report).toMatch(/models_in_db:\s*2/);
expect(report).toMatch(/command_available:\s*true/);
});
it('reports a custom ACP launch command + its binary', async () => {
const report = await getProviderDiagnostic(registry.get('amp-acp')!, undefined, {
checkAvailable: alwaysAvailable,
});
expect(report).toContain('provider: amp-acp');
expect(report).toContain('amp-acp --acp');
expect(report).toContain('customAcp: true');
});
it('surfaces the last probe error from a cached snapshot entry', async () => {
const cachedEntry: ProviderSnapshotEntry = {
name: 'opencode',
label: 'OpenCode',
transport: 'acp',
status: 'error',
enabled: true,
installed: true,
models: [],
modes: [],
defaultModeId: null,
commands: [],
error: 'ACP initialize timed out',
};
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
cachedEntry,
checkAvailable: alwaysAvailable,
});
expect(report).toContain('ACP initialize timed out');
});
it('reports no error when none is cached', async () => {
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
checkAvailable: alwaysAvailable,
});
expect(report).toMatch(/last_probe_error:\s*\(none/);
});
});

View File

@@ -0,0 +1,370 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
mergeModels,
prefixLlamaSwapModels,
clearProviderSnapshotCache,
getProviderSnapshot,
peekSnapshotEntry,
} from '../provider-snapshot.js';
import { loadProviderConfig } from '../provider-config-registry.js';
vi.mock('../acp-probe.js', () => ({
probeAcpProvider: vi.fn(),
}));
import { probeAcpProvider } from '../acp-probe.js';
const mockProbe = vi.mocked(probeAcpProvider);
/** Write a temp coder-providers.json and point the resolved registry at it. */
function loadConfigFixture(providers: Record<string, unknown>): void {
const path = join(tmpdir(), `coder-providers-test-${providers ? Object.keys(providers).join('-') || 'empty' : 'empty'}.json`);
writeFileSync(path, JSON.stringify({ providers }), 'utf8');
loadProviderConfig(path);
}
function mockSql(agents: Array<{
name: string;
install_path: string | null;
supports_acp: boolean;
models: Array<{ id: string; label: string }> | null;
label: string | null;
transport: string | null;
last_probed_at?: string | null;
}>) {
return vi.fn((strings: TemplateStringsArray) => {
const query = strings.join('');
if (query.includes('FROM available_agents')) {
return Promise.resolve(agents);
}
if (query.includes('UPDATE available_agents')) {
return Promise.resolve([]);
}
return Promise.resolve([]);
}) as unknown as import('../db.js').Sql;
}
const config = {
LLAMA_SWAP_URL: 'http://llama-swap.test',
PROVIDER_PROBE_TTL_MS: 86_400_000,
} as import('../config.js').Config;
describe('prefixLlamaSwapModels', () => {
it('prefixes bare ids', () => {
expect(prefixLlamaSwapModels([{ id: 'qwen3', label: 'qwen3' }])).toEqual([
{ id: 'llama-swap/qwen3', label: 'qwen3' },
]);
});
it('leaves already-prefixed ids unchanged', () => {
expect(prefixLlamaSwapModels([{ id: 'llama-swap/qwen3', label: 'qwen3' }])).toEqual([
{ id: 'llama-swap/qwen3', label: 'qwen3' },
]);
});
});
describe('mergeModels', () => {
it('dedupes by id preserving first occurrence', () => {
const merged = mergeModels(
[{ id: 'a', label: 'A' }],
[{ id: 'a', label: 'A2' }, { id: 'b', label: 'B' }],
);
expect(merged).toEqual([
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
]);
});
});
describe('getProviderSnapshot', () => {
beforeEach(() => {
clearProviderSnapshotCache();
// Reset the resolved registry to built-ins-only (missing path → {} config).
loadProviderConfig('/nonexistent-coder-providers.json');
vi.restoreAllMocks();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: 'local-model' }, { id: 'llama-swap/existing' }],
}),
}),
);
});
it('merges opencode ACP models with prefixed llama-swap models', async () => {
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'opencode/big-pickle', label: 'Big Pickle', isDefault: true }],
modes: [{ id: 'build', label: 'Build' }],
defaultModeId: 'build',
commands: [{ name: 'custom', description: 'From ACP probe' }],
});
const sql = mockSql([
{
name: 'opencode',
install_path: '/usr/bin/opencode',
supports_acp: true,
models: null,
label: 'OpenCode',
transport: 'acp',
},
]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const opencode = entries.find((e) => e.name === 'opencode');
expect(opencode?.models.map((m) => m.id)).toEqual([
'opencode/big-pickle',
'llama-swap/local-model',
'llama-swap/existing',
]);
expect(opencode?.commands.some((c) => c.name === 'help')).toBe(true);
expect(opencode?.commands.some((c) => c.name === 'custom')).toBe(true);
});
it('combines qwen-shaped probe and settings model lists via mergeModels', () => {
const merged = mergeModels(
[{ id: 'qwen-probed', label: 'Qwen Probed' }],
[{ id: 'from-settings', label: 'from-settings' }],
);
expect(merged.map((m) => m.id)).toEqual(['qwen-probed', 'from-settings']);
});
it('returns cached entries on second call within TTL', async () => {
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'm1', label: 'M1' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: null,
label: 'Goose',
transport: 'acp',
},
]);
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
expect(mockProbe).toHaveBeenCalledTimes(1);
});
it('attaches claude thinking options', async () => {
const sql = mockSql([
{
name: 'claude',
install_path: '/usr/bin/claude',
supports_acp: false,
models: [{ id: 'claude-sonnet', label: 'Sonnet' }],
label: 'Claude Code',
transport: 'pty',
},
]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const claude = entries.find((e) => e.name === 'claude');
expect(claude?.models[0]?.thinkingOptions?.length).toBeGreaterThan(0);
expect(claude?.modes.length).toBeGreaterThan(0);
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
});
it('disabled provider → unavailable + enabled:false, WITHOUT spawning a probe', async () => {
loadConfigFixture({ goose: { enabled: false } });
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: [{ id: 'g1', label: 'G1' }],
label: 'Goose',
transport: 'acp',
last_probed_at: new Date().toISOString(),
},
]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const goose = entries.find((e) => e.name === 'goose');
expect(goose?.status).toBe('unavailable');
expect(goose?.enabled).toBe(false);
expect(goose?.installed).toBe(false);
expect(mockProbe).not.toHaveBeenCalled();
});
it('uninstalled provider → unavailable + enabled:true + installed:false', async () => {
loadConfigFixture({});
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
const sql = mockSql([]); // nothing probed/installed
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const opencode = entries.find((e) => e.name === 'opencode');
expect(opencode?.status).toBe('unavailable');
expect(opencode?.enabled).toBe(true);
expect(opencode?.installed).toBe(false);
expect(mockProbe).not.toHaveBeenCalled();
});
it('fresh DB within TTL → tier-2 cold probe SKIPPED (serves DB models)', async () => {
loadConfigFixture({});
// If this were wrongly called, cached-goose would be replaced and the
// not.toHaveBeenCalled assertion would fail.
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'SHOULD-NOT-APPEAR', label: 'nope' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: [{ id: 'cached-goose', label: 'Cached Goose' }],
label: 'Goose',
transport: 'acp',
last_probed_at: new Date().toISOString(), // fresh
},
]);
// force=false → cache-miss returns loading; second call joins the build / cache.
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
const entries = await getProviderSnapshot(sql, config, '/tmp/cwd', false);
const goose = entries.find((e) => e.name === 'goose');
expect(goose?.status).toBe('ready');
expect(goose?.installed).toBe(true);
expect(goose?.models.map((m) => m.id)).toContain('cached-goose');
expect(goose?.models.map((m) => m.id)).not.toContain('SHOULD-NOT-APPEAR');
expect(mockProbe).not.toHaveBeenCalled();
});
it('force refresh → tier-2 cold probe RUNS even when DB is fresh', async () => {
loadConfigFixture({});
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'fresh-probe', label: 'Fresh' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: [{ id: 'cached-goose', label: 'Cached' }],
label: 'Goose',
transport: 'acp',
last_probed_at: new Date().toISOString(), // fresh, but force overrides
},
]);
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
expect(mockProbe).toHaveBeenCalled();
});
it('native boocode → ready, enabled, installed', async () => {
loadConfigFixture({});
const sql = mockSql([]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const boocode = entries.find((e) => e.name === 'boocode');
expect(boocode?.status).toBe('ready');
expect(boocode?.enabled).toBe(true);
expect(boocode?.installed).toBe(true);
});
it('config models REPLACE the claude static list; additionalModels merge (+ thinking)', async () => {
loadConfigFixture({
claude: {
models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }],
additionalModels: [{ id: 'sonnet', label: 'Sonnet (latest)' }],
},
});
const sql = mockSql([
{
name: 'claude',
install_path: '/usr/bin/claude',
supports_acp: false,
models: [{ id: 'old-static', label: 'Old' }],
label: 'Claude Code',
transport: 'pty',
last_probed_at: new Date().toISOString(),
},
]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const claude = entries.find((e) => e.name === 'claude');
const ids = claude!.models.map((m) => m.id);
expect(ids).toContain('claude-opus-4-8'); // config models replaced the DB/static list
expect(ids).toContain('sonnet'); // additionalModels merged on top
expect(ids).not.toContain('old-static'); // replaced, not appended
// thinking options still attach to the config-provided models
expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
});
it('peekSnapshotEntry returns a cached entry (read-only) and undefined when cold/unknown', async () => {
loadConfigFixture({});
// Cold cache → undefined (no build triggered).
expect(peekSnapshotEntry('boocode', '/tmp/peek')).toBeUndefined();
const sql = mockSql([]);
await getProviderSnapshot(sql, config, '/tmp/peek', true);
expect(peekSnapshotEntry('boocode', '/tmp/peek')?.name).toBe('boocode');
expect(peekSnapshotEntry('does-not-exist', '/tmp/peek')).toBeUndefined();
});
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
loadConfigFixture({});
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'm1', label: 'M1' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: null,
label: 'Goose',
transport: 'acp',
last_probed_at: null,
},
]);
await getProviderSnapshot(sql, config, '/tmp/cwd', true); // cold populate
const probeCallsAfterFirst = mockProbe.mock.calls.length;
await getProviderSnapshot(sql, config, '/tmp/cwd', false); // warm read
const probeCallsAfterSecond = mockProbe.mock.calls.length;
// Success criterion: second snapshot is served from cache with no ACP spawns.
expect(probeCallsAfterSecond - probeCallsAfterFirst).toBe(0);
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Parity guard between the two copies of the provider snapshot types:
* apps/coder/src/services/provider-types.ts (backend source of truth)
* apps/web/src/api/types.ts (web wire copy)
*
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
* assignability check was attempted first (a web-side file importing coder's
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
* project and rejects out-of-include files with TS6307 — so cross-project type
* import is structurally blocked. This runtime guard FAILS on any field
* add/remove/rename/loosen in either copy, including the nested model/mode/
* command types that ProviderSnapshotEntry references. Single-source-of-truth
* (shared workspace package) is deferred as a Tier-2 follow-up.
*/
const here = dirname(fileURLToPath(import.meta.url));
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
function extractBlock(src: string, name: string): string {
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
const block = iface?.[0] ?? alias?.[0];
if (!block) throw new Error(`type block '${name}' not found`);
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
// trim each line. Field add/remove/rename/loosen still changes a field line.
return block
.split('\n')
.map((l) => l.trim())
.filter(
(l) =>
l.length > 0 &&
!l.startsWith('//') &&
!l.startsWith('/*') &&
!l.startsWith('*'),
)
.join('\n');
}
describe('provider snapshot type parity (coder ↔ web)', () => {
// Includes the nested types ProviderSnapshotEntry references, so structural
// drift anywhere in the snapshot surface is caught.
const names = [
'ProviderSnapshotStatus',
'ProviderSnapshotEntry',
'ProviderModel',
'ProviderMode',
'ThinkingOption',
'AgentCommand',
];
for (const name of names) {
it(`${name} is identical in both copies`, () => {
expect(
extractBlock(webSrc, name),
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
).toBe(extractBlock(coderSrc, name));
});
}
});

View File

@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import { resolveWritePath, isSecretPath, WriteGuardError } from '../write_guard.js';
const PROJECT_ROOT = '/opt/projects/my-app';
describe('resolveWritePath', () => {
it('resolves a relative path correctly', () => {
const result = resolveWritePath(PROJECT_ROOT, 'src/index.ts');
expect(result).toBe('/opt/projects/my-app/src/index.ts');
});
it('resolves nested relative path', () => {
const result = resolveWritePath(PROJECT_ROOT, 'src/lib/utils.ts');
expect(result).toBe('/opt/projects/my-app/src/lib/utils.ts');
});
it('throws on ../ escape', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow(WriteGuardError);
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow('path escapes project root');
});
it('throws on absolute path outside project root', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '/etc/shadow')).toThrow(WriteGuardError);
expect(() => resolveWritePath(PROJECT_ROOT, '/tmp/exploit')).toThrow('path escapes project root');
});
it('allows absolute path inside project root', () => {
const result = resolveWritePath(PROJECT_ROOT, '/opt/projects/my-app/src/new.ts');
expect(result).toBe('/opt/projects/my-app/src/new.ts');
});
it('denies .env files', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow(WriteGuardError);
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow('cannot write to secret file');
});
it('denies .env.local', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '.env.local')).toThrow(WriteGuardError);
});
it('denies .env.production', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '.env.production')).toThrow(WriteGuardError);
});
it('denies *.pem files', () => {
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow(WriteGuardError);
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow('cannot write to secret file');
});
it('denies *.key files', () => {
expect(() => resolveWritePath(PROJECT_ROOT, 'ssl/private.key')).toThrow(WriteGuardError);
});
it('denies id_rsa', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_rsa')).toThrow(WriteGuardError);
});
it('denies id_ed25519', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_ed25519')).toThrow(WriteGuardError);
});
it('denies credentials.json', () => {
expect(() => resolveWritePath(PROJECT_ROOT, 'credentials.json')).toThrow(WriteGuardError);
});
it('passes a normal file inside project', () => {
const result = resolveWritePath(PROJECT_ROOT, 'src/components/Button.tsx');
expect(result).toBe('/opt/projects/my-app/src/components/Button.tsx');
});
it('passes a non-existent nested file (no realpath)', () => {
// This is the key difference from BooChat's pathGuard: no realpath means
// files that don't exist yet still pass validation
const result = resolveWritePath(PROJECT_ROOT, 'src/new-dir/new-file.ts');
expect(result).toBe('/opt/projects/my-app/src/new-dir/new-file.ts');
});
it('throws on null/empty path', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow(WriteGuardError);
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow('file path is required');
});
it('normalizes ../ within project root and still allows', () => {
const result = resolveWritePath(PROJECT_ROOT, 'src/../lib/utils.ts');
expect(result).toBe('/opt/projects/my-app/lib/utils.ts');
});
it('rejects path that looks inside root but normalizes outside', () => {
expect(() => resolveWritePath(PROJECT_ROOT, 'src/../../other-project/hack.ts')).toThrow(WriteGuardError);
});
});
describe('isSecretPath', () => {
it('detects .env', () => {
expect(isSecretPath('.env')).toBe(true);
});
it('detects nested .env', () => {
expect(isSecretPath('config/.env')).toBe(true);
});
it('detects *.pfx', () => {
expect(isSecretPath('certs/client.pfx')).toBe(true);
});
it('does not flag normal source files', () => {
expect(isSecretPath('src/index.ts')).toBe(false);
expect(isSecretPath('README.md')).toBe(false);
expect(isSecretPath('package.json')).toBe(false);
});
it('returns false for empty string', () => {
expect(isSecretPath('')).toBe(false);
});
});

View File

@@ -0,0 +1,193 @@
import { describe, it, expect } from 'vitest';
import { resolveWritePath } from '../write_guard.js';
const projectRoot = '/opt/testproject';
describe('write_guard fuzz — traversal attacks', () => {
// Basic traversal
it('rejects ../', () => {
expect(() => resolveWritePath(projectRoot, '../etc/passwd')).toThrow();
});
it('rejects ../../', () => {
expect(() => resolveWritePath(projectRoot, '../../etc/passwd')).toThrow();
});
it('rejects deeply nested ../../../', () => {
expect(() => resolveWritePath(projectRoot, '../../../../../../../etc/shadow')).toThrow();
});
// Encoded traversal — resolve() doesn't decode percent-encoding, so these
// stay as literal filenames. The guard must still not let them escape.
it('rejects %2e%2e/ (literal percent-encoded dots)', () => {
// resolve('/opt/testproject', '%2e%2e/etc/passwd') stays inside root
// because Node's resolve treats the literal characters, not decoded.
// The file would be /opt/testproject/%2e%2e/etc/passwd which IS inside root.
// This test confirms it doesn't throw (it resolves inside) — defense in depth
// is that the filesystem won't have this path, but no traversal occurs.
const result = resolveWritePath(projectRoot, '%2e%2e/etc/passwd');
expect(result).toContain(projectRoot);
});
it('rejects ..%2f (literal percent-encoded slash)', () => {
// '../%2fetc/passwd' — the ../ IS real traversal
expect(() => resolveWritePath(projectRoot, '../%2fetc/passwd')).toThrow();
});
// Null byte injection
it('rejects null bytes', () => {
expect(() => resolveWritePath(projectRoot, 'file.txt\x00.jpg')).toThrow();
});
// Absolute path escape
it('rejects /etc/passwd', () => {
expect(() => resolveWritePath(projectRoot, '/etc/passwd')).toThrow();
});
it('rejects /opt/other-project/file', () => {
expect(() => resolveWritePath(projectRoot, '/opt/other-project/file.ts')).toThrow();
});
// Path that starts with project root as prefix but isn't under it
it('rejects prefix match without separator', () => {
expect(() => resolveWritePath(projectRoot, '/opt/testproject-evil/file.ts')).toThrow();
});
// Double slashes / traversal after valid prefix
it('rejects /opt/testproject/../etc/passwd via double-dot after valid prefix', () => {
expect(() => resolveWritePath(projectRoot, '/opt/testproject/../etc/passwd')).toThrow();
});
// Windows-style (defense-in-depth on Linux)
it('rejects backslash traversal', () => {
// On POSIX, backslash is a valid filename char, so '..\\etc\\passwd' resolves
// as a single segment inside projectRoot. Not a traversal, but test that it
// doesn't crash and stays within root.
const result = resolveWritePath(projectRoot, '..\\etc\\passwd');
// Node resolve on POSIX treats this as a literal filename segment containing backslashes
// that starts with '..' — resolve normalizes: /opt/testproject/..\\etc\\passwd
// Wait: resolve('/opt/testproject', '..\\etc\\passwd') — on POSIX backslash
// is NOT a separator, so this is a file named '..\\etc\\passwd' inside projectRoot.
// Actually no — resolve splits on '/' only on POSIX. '..' at start triggers parent.
// Let's check: the string starts with '..' but the next char is '\\' not '/'.
// Node's path.resolve on POSIX: the string '..\\etc\\passwd' does NOT contain '/'
// so it IS treated as a single path component? No — resolve still splits on '/'.
// '..\\etc\\passwd' has no '/', so resolve('/opt/testproject', '..\\etc\\passwd')
// = resolve('/opt/testproject/..\\etc\\passwd') — but wait, resolve processes
// segments separated by '/'. With no '/', the whole thing is one segment.
// Actually wrong: path.resolve calls normalizeString which handles '.' and '..'
// only when they are full segments delimited by '/'. Since there's no '/' in
// '..\\etc\\passwd', it treats the entire string as one filename.
// So: /opt/testproject/..\\etc\\passwd — inside root. No throw.
expect(result).toContain(projectRoot);
});
// Secret files (deny list)
it('rejects .env', () => {
expect(() => resolveWritePath(projectRoot, '.env')).toThrow();
});
it('rejects nested .env', () => {
expect(() => resolveWritePath(projectRoot, 'config/.env')).toThrow();
});
it('rejects .env.local', () => {
expect(() => resolveWritePath(projectRoot, '.env.local')).toThrow();
});
it('rejects id_rsa', () => {
expect(() => resolveWritePath(projectRoot, '.ssh/id_rsa')).toThrow();
});
it('rejects id_ed25519', () => {
expect(() => resolveWritePath(projectRoot, '.ssh/id_ed25519')).toThrow();
});
it('rejects *.pem', () => {
expect(() => resolveWritePath(projectRoot, 'certs/server.pem')).toThrow();
});
it('rejects *.key', () => {
expect(() => resolveWritePath(projectRoot, 'certs/private.key')).toThrow();
});
it('rejects credentials.json', () => {
expect(() => resolveWritePath(projectRoot, 'credentials.json')).toThrow();
});
it('rejects *.p12', () => {
expect(() => resolveWritePath(projectRoot, 'certs/client.p12')).toThrow();
});
it('rejects .netrc', () => {
expect(() => resolveWritePath(projectRoot, '.netrc')).toThrow();
});
it('rejects *.kdbx', () => {
expect(() => resolveWritePath(projectRoot, 'secrets/passwords.kdbx')).toThrow();
});
// Valid paths (should NOT throw)
it('allows simple relative path', () => {
expect(resolveWritePath(projectRoot, 'src/index.ts')).toBe('/opt/testproject/src/index.ts');
});
it('allows nested path', () => {
expect(resolveWritePath(projectRoot, 'src/services/tools/edit_file.ts')).toContain(projectRoot);
});
it('allows dotfile that is not in deny list', () => {
expect(resolveWritePath(projectRoot, '.gitignore')).toContain(projectRoot);
});
it('allows absolute path inside project', () => {
expect(resolveWritePath(projectRoot, '/opt/testproject/new-file.ts')).toBe('/opt/testproject/new-file.ts');
});
it('allows path with safe internal ../', () => {
expect(resolveWritePath(projectRoot, 'src/../lib/utils.ts')).toBe('/opt/testproject/lib/utils.ts');
});
});
describe('write_guard fuzz — edge cases', () => {
it('throws on empty string', () => {
expect(() => resolveWritePath(projectRoot, '')).toThrow();
});
it('throws on whitespace-only', () => {
expect(() => resolveWritePath(projectRoot, ' ')).toThrow();
});
it('throws when path IS the project root itself', () => {
// Writing to the directory itself makes no sense for a file write
expect(() => resolveWritePath(projectRoot, '/opt/testproject')).not.toThrow();
// The guard allows it (resolve === projectRoot passes the check).
// This is acceptable because the filesystem write will fail on a directory.
// If we want to block this, that's a separate concern.
});
it('handles very long path without crashing', () => {
const longSegment = 'a'.repeat(255);
const longPath = Array(20).fill(longSegment).join('/');
// Should not crash — may throw or succeed, but must not buffer-overflow
expect(() => resolveWritePath(projectRoot, longPath)).not.toThrow();
});
it('handles path with only dots', () => {
// Single dot resolves to projectRoot itself
const result = resolveWritePath(projectRoot, './src/file.ts');
expect(result).toBe('/opt/testproject/src/file.ts');
});
it('rejects triple-dot trick (... is not special but ../ within is)', () => {
// '.../etc' is a literal directory name, not traversal
const result = resolveWritePath(projectRoot, '.../etc');
expect(result).toContain(projectRoot);
});
it('rejects path with multiple consecutive slashes', () => {
// resolve normalizes these; should still be inside root
const result = resolveWritePath(projectRoot, 'src///file.ts');
expect(result).toBe('/opt/testproject/src/file.ts');
});
});

View File

@@ -0,0 +1,49 @@
import { promises as fs } from 'node:fs';
import { dirname, isAbsolute, resolve, sep } from 'node:path';
/**
* Resolve an ACP-supplied path against the agent worktree and reject anything
* that escapes it. Mirrors `write_guard.ts`'s check: `resolve()` to normalize
* `../` segments, then a **separator-bounded** prefix test — a bare
* `startsWith(root)` wrongly admits a sibling dir like `<root>-evil/...`.
*
* No realpath (consistent with `write_guard.ts`: the target may not exist yet on
* write). This is a containment guard for the ACP fs bridge, not a hard trust
* boundary — the agent process already runs with host FS access; symlink-swap
* hardening (`O_NOFOLLOW`/realpath) is out of scope here.
*/
function resolveInWorktree(worktreePath: string, filePath: string): string {
const root = resolve(worktreePath);
const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
if (absolute !== root && !absolute.startsWith(root + sep)) {
throw new Error(`path escapes worktree: ${filePath}`);
}
return absolute;
}
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
export async function readWorktreeTextFile(
worktreePath: string,
filePath: string,
line?: number | null,
limit?: number | null,
): Promise<string> {
const absolute = resolveInWorktree(worktreePath, filePath);
const raw = await fs.readFile(absolute, 'utf8');
if (!line && !limit) return raw;
const lines = raw.split(/\r?\n/);
const start = Math.max((line ?? 1) - 1, 0);
const end = limit ? start + limit : undefined;
return lines.slice(start, end).join('\n');
}
/** Write a file inside the worktree (creates parent dirs). */
export async function writeWorktreeTextFile(
worktreePath: string,
filePath: string,
content: string,
): Promise<void> {
const absolute = resolveInWorktree(worktreePath, filePath);
await fs.mkdir(dirname(absolute), { recursive: true });
await fs.writeFile(absolute, content, 'utf8');
}

View File

@@ -0,0 +1,128 @@
/**
* ACP model/mode derivation — adapted from Paseo acp-agent.ts.
*/
import type {
SessionConfigOption,
SessionModelState,
SessionModeState,
} from '@agentclientprotocol/sdk';
import type { ProviderMode, ProviderModel, ThinkingOption } from './provider-types.js';
type SelectConfigOption = Extract<SessionConfigOption, { type: 'select' }>;
interface SelectConfigChoice {
value: string;
name: string;
description?: string | null;
group?: string;
}
function findSelectConfigOption({
configOptions,
category,
id,
}: {
configOptions: SessionConfigOption[] | null | undefined;
category: string;
id?: string;
}): SelectConfigOption | null {
const option = configOptions?.find(
(entry): entry is SelectConfigOption =>
entry.type === 'select' && entry.category === category && (!id || entry.id === id),
);
return option ?? null;
}
function flattenSelectOptions(options: SelectConfigOption['options']): SelectConfigChoice[] {
const flattened: SelectConfigChoice[] = [];
for (const option of options) {
if ('value' in option) {
flattened.push(option);
continue;
}
for (const groupOption of option.options) {
flattened.push({ ...groupOption, group: option.group });
}
}
return flattened;
}
function deriveSelectorOptions(
configOptions: SessionConfigOption[] | null | undefined,
category: string,
): ThinkingOption[] {
const option = findSelectConfigOption({ configOptions, category });
if (!option) return [];
return flattenSelectOptions(option.options).map((value) => ({
id: value.value,
label: value.name,
isDefault: value.value === option.currentValue,
}));
}
export function deriveModesFromACP(
fallbackModes: ProviderMode[],
modeState?: SessionModeState | null,
configOptions?: SessionConfigOption[] | null,
): { modes: ProviderMode[]; currentModeId: string | null } {
if (modeState?.availableModes?.length) {
return {
modes: modeState.availableModes.map((mode) => ({
id: mode.id,
label: mode.name,
description: mode.description ?? undefined,
})),
currentModeId: modeState.currentModeId ?? null,
};
}
const modeOption = findSelectConfigOption({ configOptions, category: 'mode' });
if (modeOption) {
const flatOptions = flattenSelectOptions(modeOption.options);
return {
modes: flatOptions.map((option) => ({
id: option.value,
label: option.name,
description: option.description ?? undefined,
})),
currentModeId: modeOption.currentValue,
};
}
return { modes: fallbackModes, currentModeId: null };
}
export function deriveModelDefinitionsFromACP(
models: SessionModelState | null | undefined,
configOptions?: SessionConfigOption[] | null,
): ProviderModel[] {
const thinkingOptions = deriveSelectorOptions(configOptions, 'thought_level');
const defaultThinkingOptionId = thinkingOptions.find((o) => o.isDefault)?.id;
if (models?.availableModels?.length) {
return models.availableModels.map((model) => ({
id: model.modelId,
label: model.name,
description: model.description ?? undefined,
isDefault: model.modelId === models.currentModelId,
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId: defaultThinkingOptionId ?? undefined,
}));
}
const modelOptions = deriveSelectorOptions(configOptions, 'model');
return modelOptions.map((option) => ({
id: option.id,
label: option.label,
isDefault: option.isDefault,
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId: defaultThinkingOptionId ?? undefined,
}));
}
export function findThoughtLevelConfigId(
configOptions: SessionConfigOption[] | null | undefined,
): string | null {
return findSelectConfigOption({ configOptions, category: 'thought_level' })?.id ?? null;
}

View File

@@ -0,0 +1,397 @@
/**
* ACP dispatch — runs ACP-capable agents directly on the host.
*
* v2.3: Paseo-aligned tool lifecycle — stable toolCallId, merge on
* tool_call_update, reasoning stream, worktree FS client, persist-ready snapshots.
*/
import type { FastifyBaseLogger } from 'fastify';
import {
ClientSideConnection,
type Client,
type SessionNotification,
type RequestPermissionRequest,
type RequestPermissionResponse,
type ReadTextFileRequest,
type ReadTextFileResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
type CreateTerminalRequest,
type CreateTerminalResponse,
type CreateElicitationRequest,
type CreateElicitationResponse,
type SessionConfigOption,
type ClientSideConnection as ConnectionType,
} from '@agentclientprotocol/sdk';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import { spawn } from 'node:child_process';
import { findThoughtLevelConfigId } from './acp-derive.js';
import { resolveLaunchSpec } from './acp-spawn.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { createAcpNdJsonStream } from './acp-stream.js';
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
import {
type AcpToolSnapshot,
mergeToolSnapshot,
snapshotToWireToolCall,
synthesizeCanceledSnapshots,
} from './acp-tool-snapshot.js';
export interface AcpDispatchResult {
exitCode: number;
output: string;
toolSnapshots: AcpToolSnapshot[];
reasoningText: string;
stopReason: string;
}
export interface AcpDispatchOpts {
agent: string;
task: string;
worktreePath: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
taskId?: string;
sessionId?: string;
chatId?: string;
messageId?: string;
broker?: Broker;
installPath?: string;
/** v2.3 phase 3: resolved registry def for launch-spec resolution. The
* dispatcher loads this by task.agent; falls back to a registry lookup here. */
resolved?: ResolvedProviderDef;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
async function applySessionOverrides(
connection: ConnectionType,
acpSessionId: string,
configOptions: SessionConfigOption[] | null | undefined,
opts: Pick<AcpDispatchOpts, 'model' | 'modeId' | 'thinkingOptionId' | 'log'>,
): Promise<void> {
const { model, modeId, thinkingOptionId, log } = opts;
if (modeId) {
try {
await connection.setSessionMode({ sessionId: acpSessionId, modeId });
} catch (err) {
log.warn({ modeId, err: err instanceof Error ? err.message : String(err) }, 'acp-dispatch: setSessionMode failed');
}
}
if (model) {
try {
await connection.unstable_setSessionModel({ sessionId: acpSessionId, modelId: model });
} catch (err) {
log.warn({ model, err: err instanceof Error ? err.message : String(err) }, 'acp-dispatch: setSessionModel failed');
}
}
if (thinkingOptionId) {
const configId = findThoughtLevelConfigId(configOptions);
if (configId) {
try {
await connection.setSessionConfigOption({
sessionId: acpSessionId,
configId,
value: thinkingOptionId,
});
} catch (err) {
log.warn(
{ thinkingOptionId, err: err instanceof Error ? err.message : String(err) },
'acp-dispatch: setSessionConfigOption failed',
);
}
}
}
}
class AcpStreamContext {
readonly textChunks: string[] = [];
readonly reasoningChunks: string[] = [];
readonly toolSnapshots = new Map<string, AcpToolSnapshot>();
private aborted = false;
constructor(
private readonly opts: Pick<
AcpDispatchOpts,
'broker' | 'sessionId' | 'chatId' | 'messageId' | 'taskId'
>,
private readonly worktreePath: string,
) {}
get reasoningText(): string {
return this.reasoningChunks.join('');
}
get output(): string {
return this.textChunks.join('');
}
get snapshots(): AcpToolSnapshot[] {
return [...this.toolSnapshots.values()];
}
markAborted(): void {
this.aborted = true;
for (const snap of synthesizeCanceledSnapshots(this.toolSnapshots.values())) {
this.toolSnapshots.set(snap.toolCallId, snap);
this.publishToolSnapshot(snap);
}
}
private canStream(): boolean {
return !!(this.opts.broker && this.opts.sessionId && this.opts.chatId && this.opts.messageId);
}
private publishToolSnapshot(snapshot: AcpToolSnapshot): void {
if (!this.canStream()) return;
const wire = snapshotToWireToolCall(snapshot);
this.opts.broker!.publishFrame(this.opts.sessionId!, {
type: 'tool_call',
message_id: this.opts.messageId!,
chat_id: this.opts.chatId!,
tool_call: wire,
} as WsFrame);
}
handleToolUpdate(toolCallId: string, update: Parameters<typeof mergeToolSnapshot>[1]): void {
const previous = this.toolSnapshots.get(toolCallId);
const snapshot = mergeToolSnapshot(toolCallId, update, previous);
this.toolSnapshots.set(toolCallId, snapshot);
this.publishToolSnapshot(snapshot);
}
async handleSessionUpdate(params: SessionNotification): Promise<void> {
const update = params.update;
switch (update.sessionUpdate) {
case 'agent_message_chunk': {
const content = update.content;
if (content.type === 'text' && 'text' in content) {
const text = (content as { text: string }).text;
this.textChunks.push(text);
if (this.canStream()) {
this.opts.broker!.publishFrame(this.opts.sessionId!, {
type: 'delta',
message_id: this.opts.messageId!,
chat_id: this.opts.chatId!,
content: text,
} as WsFrame);
}
}
break;
}
case 'agent_thought_chunk': {
const content = update.content;
if (content.type === 'text' && 'text' in content) {
const text = (content as { text: string }).text;
this.reasoningChunks.push(text);
if (this.canStream()) {
this.opts.broker!.publishFrame(this.opts.sessionId!, {
type: 'reasoning_delta',
message_id: this.opts.messageId!,
chat_id: this.opts.chatId!,
content: text,
} as WsFrame);
}
}
break;
}
case 'tool_call':
this.handleToolUpdate(update.toolCallId, update);
break;
case 'tool_call_update':
this.handleToolUpdate(update.toolCallId, update);
break;
case 'available_commands_update': {
const commands = update.availableCommands.map((cmd) => ({
name: cmd.name,
description: cmd.description ?? undefined,
}));
if (this.opts.taskId && commands.length > 0) {
mergeTaskCommands(this.opts.taskId, commands);
if (this.canStream() && this.opts.sessionId) {
const all = getTaskCommands(this.opts.taskId) ?? commands;
this.opts.broker!.publishFrame(this.opts.sessionId, {
type: 'agent_commands',
task_id: this.opts.taskId,
session_id: this.opts.sessionId,
commands: all,
} as WsFrame);
}
}
break;
}
default:
break;
}
}
buildClient(agent: string, modeId: string | undefined, taskId: string | undefined, sessionId: string | undefined): Client {
return {
sessionUpdate: (params) => this.handleSessionUpdate(params),
requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
if (taskId && sessionId) {
return waitForPermissionResponse(taskId, sessionId, agent, modeId, params);
}
const firstOption = params.options[0];
if (firstOption) {
return { outcome: { outcome: 'selected', optionId: firstOption.optionId } };
}
return { outcome: { outcome: 'cancelled' } };
},
readTextFile: async (params: ReadTextFileRequest): Promise<ReadTextFileResponse> => {
const content = await readWorktreeTextFile(
this.worktreePath,
params.path,
params.line,
params.limit,
);
return { content };
},
writeTextFile: async (params: WriteTextFileRequest): Promise<WriteTextFileResponse> => {
await writeWorktreeTextFile(this.worktreePath, params.path, params.content);
return {};
},
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
return { terminalId: 'noop' };
},
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
if (taskId && sessionId) {
return waitForElicitationResponse(taskId, sessionId, agent, modeId, params);
}
return { action: 'decline' };
},
};
}
}
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
const {
agent,
task,
worktreePath,
installPath,
signal,
log,
taskId,
modeId,
sessionId,
chatId,
messageId,
broker,
} = opts;
// v2.3 phase 3: launch from the resolved registry def (config override /
// custom-ACP command) with the built-in switch as the fallback. The dispatcher
// passes `resolved`; fall back to a registry lookup if it didn't.
const resolved = opts.resolved ?? getResolvedRegistry().get(agent);
const spec = resolved ? resolveLaunchSpec(resolved, installPath ?? null) : null;
if (!spec) {
return {
exitCode: 1,
output: `Agent '${agent}' does not support ACP.`,
toolSnapshots: [],
reasoningText: '',
stopReason: 'error',
};
}
log.info({ agent, binary: spec.binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
const child = spawn(spec.binary, spec.args, {
cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...spec.env },
});
const streamCtx = new AcpStreamContext(
{ broker, sessionId, chatId, messageId, taskId },
worktreePath,
);
let killed = false;
const cleanup = () => {
if (!killed) {
killed = true;
streamCtx.markAborted();
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 5_000);
}
if (taskId) cancelPendingPermission(taskId);
};
if (signal) {
if (signal.aborted) {
cleanup();
return {
exitCode: 130,
output: 'Aborted before start',
toolSnapshots: streamCtx.snapshots,
reasoningText: '',
stopReason: 'cancelled',
};
}
signal.addEventListener('abort', cleanup, { once: true });
}
try {
const stream = createAcpNdJsonStream(child);
const connection = new ClientSideConnection(
() => streamCtx.buildClient(agent, modeId, taskId, sessionId),
stream,
);
await connection.initialize({
protocolVersion: 1,
clientInfo: { name: 'boocoder', version: '2.3.0' },
clientCapabilities: {},
});
const acpSession = await connection.newSession({ cwd: worktreePath, mcpServers: [] });
log.info({ sessionId: acpSession.sessionId }, 'acp-dispatch: session created');
await applySessionOverrides(connection, acpSession.sessionId, acpSession.configOptions, opts);
const promptResult = await connection.prompt({
sessionId: acpSession.sessionId,
prompt: [{ type: 'text', text: task }],
});
const stopReason = promptResult.stopReason ?? 'end_turn';
log.info(
{ agent, stopReason, toolCallCount: streamCtx.snapshots.length, reasoningChars: streamCtx.reasoningText.length },
'acp-dispatch: prompt completed',
);
await connection.closeSession({ sessionId: acpSession.sessionId }).catch(() => {});
return {
exitCode: 0,
output: streamCtx.output,
toolSnapshots: streamCtx.snapshots,
reasoningText: streamCtx.reasoningText,
stopReason,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.error({ agent, err: message }, 'acp-dispatch: error');
return {
exitCode: 1,
output: message,
toolSnapshots: streamCtx.snapshots,
reasoningText: streamCtx.reasoningText,
stopReason: 'error',
};
} finally {
if (signal) signal.removeEventListener('abort', cleanup);
cleanup();
await new Promise<void>((resolve) => {
child.on('close', resolve);
setTimeout(resolve, 3_000);
});
}
}

View File

@@ -0,0 +1,166 @@
/**
* Short-lived ACP probe — opens a session and reads models/modes from the response.
*/
import { spawn } from 'node:child_process';
import {
ClientSideConnection,
type Client,
type NewSessionResponse,
type ReadTextFileRequest,
type ReadTextFileResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
type CreateTerminalRequest,
type CreateTerminalResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
} from '@agentclientprotocol/sdk';
import { deriveModesFromACP, deriveModelDefinitionsFromACP } from './acp-derive.js';
import { getManifestDefaultModeId, getManifestModes } from './provider-manifest.js';
import { resolveAcpSpawnArgs } from './acp-spawn.js';
import { createAcpNdJsonStream } from './acp-stream.js';
import type { ProviderModel, ProviderMode } from './provider-types.js';
import type { AgentCommand } from './agent-commands-cache.js';
const PROBE_TIMEOUT_MS = 30_000;
export interface AcpProbeResult {
ok: boolean;
models: ProviderModel[];
modes: ProviderMode[];
defaultModeId: string | null;
commands: AgentCommand[];
error?: string;
}
function parseSessionResponse(session: NewSessionResponse, agent: string): AcpProbeResult {
const fallbackModes = getManifestModes(agent);
const { modes, currentModeId } = deriveModesFromACP(
fallbackModes,
session.modes,
session.configOptions,
);
const models = deriveModelDefinitionsFromACP(session.models, session.configOptions);
return {
ok: true,
models,
modes,
defaultModeId: currentModeId ?? getManifestDefaultModeId(agent),
commands: [],
};
}
export async function probeAcpProvider(
agent: string,
installPath: string,
cwd: string,
): Promise<AcpProbeResult> {
const args = resolveAcpSpawnArgs(agent);
if (!args) {
return {
ok: false,
models: [],
modes: getManifestModes(agent),
defaultModeId: getManifestDefaultModeId(agent),
commands: [],
error: 'no ACP spawn args',
};
}
const child = spawn(installPath, args, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
let killed = false;
const kill = () => {
if (!killed) {
killed = true;
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 2_000);
}
};
const timeout = setTimeout(kill, PROBE_TIMEOUT_MS);
const probedCommands: AgentCommand[] = [];
try {
const stream = createAcpNdJsonStream(child);
const connection = new ClientSideConnection(
(_agentInterface): Client => ({
async sessionUpdate(params) {
const update = params.update;
if (update.sessionUpdate === 'available_commands_update') {
for (const cmd of update.availableCommands) {
probedCommands.push({
name: cmd.name,
description: cmd.description ?? undefined,
});
}
}
},
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const first = params.options[0];
if (first) {
return { outcome: { outcome: 'selected', optionId: first.optionId } };
}
return { outcome: { outcome: 'cancelled' } };
},
async readTextFile(_params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return { content: '' };
},
async writeTextFile(_params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return {};
},
async createTerminal(_params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
return { terminalId: 'noop' };
},
}),
stream,
);
await connection.initialize({
protocolVersion: 1,
clientInfo: { name: 'boocoder-probe', version: '2.2.0' },
clientCapabilities: {},
});
const session = await connection.newSession({ cwd, mcpServers: [] });
// available_commands_update is an async session notification opencode sends
// shortly AFTER newSession resolves — reading probedCommands synchronously
// here races it and captures nothing. Wait briefly for the first batch, then
// a short settle for any stragglers (capped well under PROBE_TIMEOUT_MS).
const deadline = Date.now() + 3_000;
while (probedCommands.length === 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 150));
}
if (probedCommands.length > 0) {
await new Promise((r) => setTimeout(r, 300));
}
const result = parseSessionResponse(session, agent);
result.commands = probedCommands;
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
return result;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
models: [],
modes: getManifestModes(agent),
defaultModeId: getManifestDefaultModeId(agent),
commands: probedCommands,
error: message,
};
} finally {
clearTimeout(timeout);
kill();
await new Promise<void>((resolve) => {
child.on('close', resolve);
setTimeout(resolve, 2_000);
});
}
}

View File

@@ -0,0 +1,50 @@
import type { ResolvedProviderDef } from './provider-config-registry.js';
/**
* Resolve ACP spawn argv per built-in provider (host-probe verified 2026-05-25).
* Source of truth for built-in default argv — resolveLaunchSpec wraps these; it
* does NOT replace them.
*/
export function resolveAcpSpawnArgs(agent: string): string[] | null {
switch (agent) {
case 'opencode':
case 'goose':
return ['acp'];
case 'qwen':
return ['--acp'];
default:
return null;
}
}
/**
* v2.3 phase 3: resolve the launch spec for an ACP dispatch (design.md §5.1).
* Consults the resolved registry's launchCommand (config override or custom-ACP
* entry) first; otherwise falls back to the built-in default argv above.
*
* Byte-identical to pre-v2.3 for built-ins with no override: binary is
* `installPath ?? id` and args come from resolveAcpSpawnArgs — exactly the
* `binary = installPath ?? agent` + `resolveAcpSpawnArgs(agent)` the dispatcher
* used before. (Deliberate deviation from design §5.1's `!installPath → null`:
* the old path spawned the bare agent name when install_path was missing, so we
* preserve the `?? id` fallback rather than fail.)
*/
export function resolveLaunchSpec(
resolved: ResolvedProviderDef,
installPath: string | null,
): { binary: string; args: string[]; env?: Record<string, string> } | null {
if (resolved.launchCommand) {
return {
binary: resolved.launchCommand[0],
args: resolved.launchCommand.slice(1),
env: resolved.env,
};
}
const args = resolveAcpSpawnArgs(resolved.id);
if (!args) return null;
return { binary: installPath ?? resolved.id, args, env: resolved.env };
}
export function resolveAcpProbeBinaries(agent: string): string[] {
return [agent];
}

View File

@@ -0,0 +1,44 @@
import { Readable, Writable } from 'node:stream';
import type { ChildProcess } from 'node:child_process';
import { ndJsonStream } from '@agentclientprotocol/sdk';
export function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
nodeStream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
nodeStream.on('end', () => controller.close());
nodeStream.on('error', (err) => controller.error(err));
},
cancel() {
if ('destroy' in nodeStream && typeof (nodeStream as Readable).destroy === 'function') {
(nodeStream as Readable).destroy();
}
},
});
}
export function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
const ok = (nodeStream as Writable).write(chunk, (err) => {
if (err) reject(err);
});
if (ok) resolve();
else (nodeStream as Writable).once('drain', resolve);
});
},
close() {
return new Promise<void>((resolve) => {
(nodeStream as Writable).end(resolve);
});
},
abort() {
(nodeStream as Writable).destroy();
},
});
}
export function createAcpNdJsonStream(child: ChildProcess) {
return ndJsonStream(nodeWritableToWeb(child.stdin!), nodeReadableToWeb(child.stdout!));
}

View File

@@ -0,0 +1,120 @@
/**
* ACP tool snapshot merge + wire mapping — lifted from Paseo acp-agent.ts patterns.
* Stable toolCallId, merge on tool_call_update, status lifecycle for UI + DB.
*/
import type { ToolCall, ToolCallUpdate, ToolCallStatus, ToolKind } from '@agentclientprotocol/sdk';
export type AcpToolLifecycleStatus = 'running' | 'completed' | 'failed' | 'canceled';
export interface AcpToolSnapshot {
toolCallId: string;
title: string;
kind?: ToolKind | null;
status?: ToolCallStatus | null;
rawInput?: unknown;
rawOutput?: unknown;
}
export interface AcpWireMeta {
status: AcpToolLifecycleStatus;
kind?: string | null;
title?: string;
output?: unknown;
error?: string;
}
function coalesceDefined<T>(next: T | null | undefined, previous: T | null | undefined, fallback: T | null): T | null {
if (next !== undefined && next !== null) return next;
if (previous !== undefined && previous !== null) return previous;
return fallback;
}
export function mergeToolSnapshot(
toolCallId: string,
update: ToolCall | ToolCallUpdate,
previous?: AcpToolSnapshot,
): AcpToolSnapshot {
return {
toolCallId,
title: update.title ?? previous?.title ?? toolCallId,
kind: update.kind ?? previous?.kind ?? null,
status: update.status ?? previous?.status ?? null,
rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput,
rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput,
};
}
export function mapToolLifecycleStatus(
status: ToolCallStatus | null | undefined,
rawOutput?: unknown,
): AcpToolLifecycleStatus {
if (rawOutput === 'canceled') return 'canceled';
switch (status) {
case 'completed':
return 'completed';
case 'failed':
return 'failed';
case 'pending':
case 'in_progress':
default:
return 'running';
}
}
function readErrorMessage(rawOutput: unknown): string | undefined {
if (typeof rawOutput === 'string' && rawOutput.trim()) return rawOutput;
if (rawOutput && typeof rawOutput === 'object' && !Array.isArray(rawOutput)) {
const rec = rawOutput as Record<string, unknown>;
const msg = rec.message ?? rec.error ?? rec.reason;
if (typeof msg === 'string' && msg.trim()) return msg;
}
return undefined;
}
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
export function snapshotToWireToolCall(snapshot: AcpToolSnapshot): {
id: string;
name: string;
args: Record<string, unknown>;
} {
const lifecycle = mapToolLifecycleStatus(snapshot.status, snapshot.rawOutput);
const input = asRecord(snapshot.rawInput);
const error = lifecycle === 'failed' ? readErrorMessage(snapshot.rawOutput) : undefined;
const meta: AcpWireMeta = {
status: lifecycle,
kind: snapshot.kind ?? null,
title: snapshot.title,
...(snapshot.rawOutput !== undefined ? { output: snapshot.rawOutput } : {}),
...(error ? { error } : {}),
};
return {
id: snapshot.toolCallId,
name: String(snapshot.kind ?? snapshot.title),
args: { ...input, _acp: meta },
};
}
export function snapshotToPartPayload(snapshot: AcpToolSnapshot): {
id: string;
name: string;
args: Record<string, unknown>;
} {
const wire = snapshotToWireToolCall(snapshot);
return { id: wire.id, name: wire.name, args: wire.args };
}
export function synthesizeCanceledSnapshots(snapshots: Iterable<AcpToolSnapshot>): AcpToolSnapshot[] {
const out: AcpToolSnapshot[] = [];
for (const snapshot of snapshots) {
if (mapToolLifecycleStatus(snapshot.status) === 'running') {
out.push({ ...snapshot, status: 'failed', rawOutput: snapshot.rawOutput ?? 'canceled' });
}
}
return out;
}

View File

@@ -0,0 +1,85 @@
/**
* v2.6 — AgentBackend abstraction (Phase 0 scaffold; types only, zero runtime logic).
*
* The core abstraction for persistent agent sessions. Two implementations land
* later: `OpenCodeServerBackend` (Phase 1, opencode HTTP server) and
* `WarmAcpBackend` (Phase 2, long-lived ACP process). Backends emit
* transport-agnostic `AgentEvent`s; the dispatcher maps them to WS frames.
*
* Nothing imports this file yet — it must compile standalone.
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2.
*/
import type { AcpToolSnapshot } from './acp-tool-snapshot.js';
import type { AgentCommand } from './provider-types.js';
/** Backend transport kind. Mirrors `agent_sessions.backend` CHECK in schema.sql. */
export type AgentBackendKind = 'opencode_server' | 'acp_warm';
/**
* Normalized, transport-agnostic events a backend emits during a turn (§2).
* Derived from acp-dispatch's session-update handling, but WITHOUT the WS
* envelope (message_id/chat_id) — the dispatcher owns frame mapping.
*
* `tool_call` vs `tool_update` are kept distinct on purpose: acp-dispatch
* currently merges both into one snapshot frame, but opencode's SSE
* distinguishes tool-start from tool-result, so the contract carries both.
* `commands` mirrors the ACP `available_commands_update` path (v2.5.10).
*/
export type AgentEvent =
| { type: 'text'; text: string }
| { type: 'reasoning'; text: string }
| { type: 'tool_call'; toolCall: AcpToolSnapshot }
| { type: 'tool_update'; toolCall: AcpToolSnapshot }
| { type: 'commands'; commands: AgentCommand[] };
/** Params to establish (or look up) a backend session (§2). */
export interface EnsureSessionOpts {
agent: string;
/** Resolved model id. */
model: string;
/** Shared per-session worktree (one per `sessions.id`, not per pane). */
worktreePath: string;
projectId: string;
}
/** Opaque handle to a live backend session, persisted to `agent_sessions` (§2). */
export interface AgentSessionHandle {
sessionId: string;
agent: string;
backend: AgentBackendKind;
/** Provider's own session id (resume token); null until the backend assigns one. */
agentSessionId: string | null;
/** opencode HTTP server port; null for ACP backends. */
serverPort: number | null;
}
/** Per-turn context passed to `prompt` (§2). */
export interface PromptCtx {
worktreePath: string;
model: string;
signal: AbortSignal;
onEvent: (e: AgentEvent) => void;
}
/** Result of a completed turn (§2). Diff/persist happen outside the backend. */
export interface TurnResult {
ok: boolean;
error?: string;
}
/**
* The core backend abstraction (§2). Implementations: OpenCodeServerBackend
* (Phase 1), WarmAcpBackend (Phase 2).
*/
export interface AgentBackend {
/** Lazy: spawn server / warm process if not already up for this (session, agent). §2 */
ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle>;
/** Send a prompt; stream events via ctx.onEvent; resolves when the turn completes. §2 */
prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult>;
/** Graceful teardown of one session (session close or idle timeout). §2 */
closeSession(handle: AgentSessionHandle): Promise<void>;
/** Full teardown — kills all spawned servers/processes. §2 */
dispose(): Promise<void>;
/** Liveness for health endpoint + dispatcher fallback decision. §2 */
health(): 'up' | 'down';
}

View File

@@ -0,0 +1,28 @@
/** In-memory cache of ACP available_commands_update per task. */
import type { AgentCommand } from './provider-types.js';
import { mergeCommands } from './provider-commands.js';
export type { AgentCommand };
const commandsByTask = new Map<string, AgentCommand[]>();
export function setTaskCommands(taskId: string, commands: AgentCommand[]): void {
if (commands.length === 0) return;
commandsByTask.set(taskId, commands);
}
/** Merge by command name; later lists override earlier entries. */
export function mergeTaskCommands(taskId: string, commands: AgentCommand[]): void {
if (commands.length === 0) return;
const merged = mergeCommands(commandsByTask.get(taskId) ?? [], commands);
commandsByTask.set(taskId, merged);
}
export function getTaskCommands(taskId: string): AgentCommand[] | null {
return commandsByTask.get(taskId) ?? null;
}
export function clearTaskCommands(taskId: string): void {
commandsByTask.delete(taskId);
}

View File

@@ -0,0 +1,44 @@
/**
* v2.6 — AgentPool (Phase 0 scaffold).
*
* Lazy get-or-create registry of `AgentBackend` instances keyed by
* `${sessionId}:${agent}`. Phase 0 ships the skeleton only: an in-memory Map,
* lookup / register / health, and clean disposal wired to the server's onClose.
* Spawning lands in Phase 1/2; nothing populates the map yet.
*
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2.
*/
import type { AgentBackend } from './agent-backend.js';
export class AgentPool {
private readonly backends = new Map<string, AgentBackend>();
private key(sessionId: string, agent: string): string {
return `${sessionId}:${agent}`;
}
/** Map lookup only. Spawning is Phase 1/2 — never creates here. */
get(sessionId: string, agent: string): AgentBackend | undefined {
return this.backends.get(this.key(sessionId, agent));
}
/** Store a backend instance for this (session, agent). */
register(sessionId: string, agent: string, backend: AgentBackend): void {
this.backends.set(this.key(sessionId, agent), backend);
}
/** Summary for the health endpoint. */
health(): { size: number } {
return { size: this.backends.size };
}
/** Dispose every backend and clear the map. Tolerates throwing backends. */
async dispose(): Promise<void> {
const entries = [...this.backends.values()];
this.backends.clear();
await Promise.allSettled(entries.map((b) => b.dispose()));
}
}
/** Single shared instance — referenced only by the server's onClose hook in Phase 0. */
export const agentPool = new AgentPool();

View File

@@ -0,0 +1,149 @@
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
import { exec as execCb, execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME } from './provider-registry.js';
import { resolveAcpProbeBinaries } from './acp-spawn.js';
import { clearProviderSnapshotCache } from './provider-snapshot.js';
import { readQwenSettingsModels } from './qwen-settings.js';
import { loadConfig } from '../config.js';
import { loadProviderConfig } from './provider-config-registry.js';
const exec = promisify(execCb);
const execFile = promisify(execFileCb);
// `which` via execFile (no shell) — the binary name can come from the config
// file (custom ACP entries), so avoid interpolating it into a shell string.
async function whichBinary(bin: string): Promise<string | null> {
try {
const { stdout } = await execFile('which', [bin], { timeout: 10_000 });
const path = stdout.trim();
return path || null;
} catch {
return null;
}
}
async function resolveInstallPath(agentName: string): Promise<string | null> {
const candidates = resolveAcpProbeBinaries(agentName);
for (const bin of candidates) {
const path = await whichBinary(bin);
if (path) return path;
}
return null;
}
async function detectAcpSupport(agentName: string, installPath: string): Promise<boolean> {
const transport = PROVIDERS_BY_NAME.get(agentName)?.transport;
if (transport !== 'acp') return false;
if (agentName === 'qwen') {
try {
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
return stdout.includes('--acp');
} catch {
return false;
}
}
try {
await exec(`"${installPath}" acp --help`, { timeout: 10_000 });
return true;
} catch {
return false;
}
}
/**
* Probe for available agents on the HOST.
*
* v2.3: iterates the resolved provider registry (built-ins + config-backed
* custom ACP entries) rather than the hardcoded `PROBED_AGENT_NAMES`. Native
* boocode is not probed; disabled providers are skipped (their `available_agents`
* row is kept, not deleted). `enabled` is read from the in-memory registry only —
* no DB column in Phase 1 (design.md §3.3).
*/
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
clearProviderSnapshotCache();
log.info('agent-probe: scanning for known agents');
const registry = loadProviderConfig(loadConfig().CODER_PROVIDERS_PATH);
for (const resolved of registry.values()) {
const agentName = resolved.id;
// Native boocode is not a probed host agent.
if (resolved.transport === 'native') continue;
// Disabled providers: skip the probe, keep any existing row.
if (!resolved.enabled) {
log.info({ agent: agentName }, 'agent-probe: skipping disabled provider');
continue;
}
try {
// Custom ACP entries resolve their binary from command[0]; built-ins use
// the per-agent probe binaries.
const installPath = resolved.isCustomAcp && resolved.launchCommand
? await whichBinary(resolved.launchCommand[0])
: await resolveInstallPath(agentName);
if (!installPath) continue;
let version: string | null = null;
try {
const { stdout: verOut } = await exec(`"${installPath}" --version`, { timeout: 15_000 });
version = verOut.trim().slice(0, 100);
} catch {
/* optional */
}
// Custom ACP entries are ACP by declaration; built-ins detect support.
let supportsAcp: boolean;
if (resolved.isCustomAcp) {
supportsAcp = true;
} else {
supportsAcp = resolved.transport === 'acp';
if (supportsAcp) {
supportsAcp = await detectAcpSupport(agentName, installPath);
}
}
let models: Array<{ id: string; label: string }> = [];
if (!resolved.isCustomAcp) {
const providerDef = PROVIDERS_BY_NAME.get(agentName);
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
models = providerDef.staticModels;
}
if (agentName === 'qwen') {
models = await readQwenSettingsModels();
}
}
const label = resolved.configLabel ?? resolved.label;
const transport = resolved.isCustomAcp
? 'acp'
: resolved.transport === 'acp' && !supportsAcp
? 'pty'
: (resolved.transport ?? 'pty');
await sql`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
VALUES (${agentName}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${label}, ${transport})
ON CONFLICT (name) DO UPDATE SET
install_path = EXCLUDED.install_path,
version = EXCLUDED.version,
supports_acp = EXCLUDED.supports_acp,
last_probed_at = EXCLUDED.last_probed_at,
models = EXCLUDED.models,
label = EXCLUDED.label,
transport = EXCLUDED.transport
`;
log.info({ agent: agentName, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.debug({ agent: agentName, err: msg }, 'agent-probe: not found');
}
}
log.info('agent-probe: scan complete');
}

View File

@@ -0,0 +1,56 @@
import type { Sql } from '../db.js';
import type { AcpToolSnapshot } from './acp-tool-snapshot.js';
import { snapshotToPartPayload } from './acp-tool-snapshot.js';
interface PartInsert {
message_id: string;
sequence: number;
kind: 'reasoning' | 'tool_call';
payload: unknown;
}
async function insertParts(sql: Sql, parts: PartInsert[]): Promise<void> {
if (parts.length === 0) return;
await sql`
INSERT INTO message_parts ${sql(
parts.map((p) => ({
message_id: p.message_id,
sequence: p.sequence,
kind: p.kind,
payload: sql.json(p.payload as never),
})),
'message_id',
'sequence',
'kind',
'payload',
)}
`;
}
/** Persist external-agent reasoning + tool calls into message_parts for reload. */
export async function persistExternalAgentTurn(
sql: Sql,
assistantMessageId: string,
snapshots: AcpToolSnapshot[],
reasoningText: string,
): Promise<void> {
const parts: PartInsert[] = [];
let seq = 0;
if (reasoningText.trim()) {
parts.push({
message_id: assistantMessageId,
sequence: seq++,
kind: 'reasoning',
payload: { text: reasoningText },
});
}
for (const snapshot of snapshots) {
parts.push({
message_id: assistantMessageId,
sequence: seq++,
kind: 'tool_call',
payload: snapshotToPartPayload(snapshot),
});
}
await insertParts(sql, parts);
}

View File

@@ -0,0 +1,108 @@
/**
* v2.5.11: discover Claude Code's real, enabled commands + plugin skills from
* disk so the coder slash menu shows them (claude is PTY — no ACP discovery).
*
* Scope (v1): user-global only — `~/.claude/commands/*.md` plus the enabled
* plugins listed in `~/.claude/settings.json:enabledPlugins` (user-scope install
* paths from `~/.claude/plugins/.../installed_plugins.json`). Project-local
* plugins and `<cwd>/.claude/commands` are deferred. Names are bare.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { AgentCommand } from './provider-types.js';
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */
function frontmatterField(content: string, field: string): string | undefined {
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!block?.[1]) return undefined;
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
}
function readCommandDir(dir: string): AgentCommand[] {
if (!existsSync(dir)) return [];
let files: string[];
try {
files = readdirSync(dir);
} catch {
return [];
}
const out: AgentCommand[] = [];
for (const f of files) {
if (!f.endsWith('.md')) continue;
let description: string | undefined;
try {
description = frontmatterField(readFileSync(join(dir, f), 'utf8'), 'description');
} catch {
/* unreadable — still list the command by name */
}
out.push({ name: f.slice(0, -3), kind: 'command', ...(description ? { description } : {}) });
}
return out;
}
function readSkillDir(dir: string): AgentCommand[] {
if (!existsSync(dir)) return [];
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return [];
}
const out: AgentCommand[] = [];
for (const sub of entries) {
const skillMd = join(dir, sub, 'SKILL.md');
if (!existsSync(skillMd)) continue;
let content: string;
try {
content = readFileSync(skillMd, 'utf8');
} catch {
continue;
}
out.push({
name: frontmatterField(content, 'name') ?? sub,
kind: 'skill',
...(() => {
const d = frontmatterField(content, 'description');
return d ? { description: d } : {};
})(),
});
}
return out;
}
export function discoverClaudeCommands(): AgentCommand[] {
const root = join(homedir(), '.claude');
const out: AgentCommand[] = [];
// User custom commands.
out.push(...readCommandDir(join(root, 'commands')));
// Enabled plugins (user-scope installs).
try {
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
enabledPlugins?: Record<string, boolean>;
};
const installed = JSON.parse(
readFileSync(join(root, 'plugins', 'installed_plugins.json'), 'utf8'),
) as { plugins?: Record<string, Array<{ scope?: string; installPath?: string }>> };
const enabled = settings.enabledPlugins ?? {};
const plugins = installed.plugins ?? {};
for (const [key, on] of Object.entries(enabled)) {
if (!on) continue;
const installs = plugins[key] ?? [];
const installPath = (installs.find((i) => i.scope === 'user') ?? installs[0])?.installPath;
if (!installPath || !existsSync(installPath)) continue;
out.push(...readSkillDir(join(installPath, 'skills')));
out.push(...readCommandDir(join(installPath, 'commands')));
}
} catch {
/* missing/unreadable plugin config → user commands only */
}
// Dedupe by name (first wins).
const seen = new Set<string>();
return out.filter((c) => (seen.has(c.name) ? false : (seen.add(c.name), true)));
}

View File

@@ -0,0 +1,22 @@
/**
* v2.3 phase 2: tier-1 fast availability check — is a binary on PATH?
*
* Uses execFile (NO shell) because the binary name can come from the provider
* config file (custom ACP entries) — mirrors the Phase 1 agent-probe hardening.
* Note: agent-probe's `whichBinary` returns the resolved path (it needs it for
* `install_path`); this returns a boolean. Kept separate rather than over-
* refactored into one helper — different return contracts, two short call sites.
*/
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
const execFile = promisify(execFileCb);
export async function isCommandAvailable(binary: string): Promise<boolean> {
try {
const { stdout } = await execFile('which', [binary], { timeout: 10_000 });
return stdout.trim().length > 0;
} catch {
return false;
}
}

View File

@@ -0,0 +1,524 @@
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
import { dispatchViaAcp } from './acp-dispatch.js';
import { getResolvedRegistry } from './provider-config-registry.js';
import { dispatchViaPty } from './pty-dispatch.js';
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
import { getManifestCommands } from './provider-commands.js';
import { persistExternalAgentTurn } from './agent-turn-persist.js';
interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean;
}
interface Deps {
sql: Sql;
inference: InferenceRunner;
broker: Broker;
log: FastifyBaseLogger;
config: Config;
}
// LISTEN/NOTIFY ('tasks_new') is the fast path — the dispatcher reacts to new
// tasks immediately. The poll is only a safety net for notifications missed
// during a listen-connection drop (porsager auto-reconnects), so it can stay slow.
const POLL_INTERVAL_MS = 2_000;
const COMPLETION_POLL_MS = 2_000;
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
const { sql, inference, broker, log, config } = deps;
let timer: ReturnType<typeof setInterval> | null = null;
let listener: { unlisten: () => Promise<void> } | null = null;
let running = false;
let stopping = false;
let inflightPromise: Promise<void> | null = null;
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
// `running`/`stopping` guard makes this safe to call concurrently — a notify
// arriving mid-task returns immediately and never double-dispatches.
function triggerPoll(reason: string): void {
poll().catch((err) => {
log.error({ err, reason }, 'dispatcher: poll error');
});
}
async function poll(): Promise<void> {
if (running || stopping) return;
// Grab one pending task
const rows = await sql<{
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
}[]>`
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
FROM tasks
WHERE state = 'pending'
ORDER BY created_at
LIMIT 1
`;
if (rows.length === 0) return;
const task = rows[0]!;
running = true;
inflightPromise = runTask(task).finally(() => {
running = false;
inflightPromise = null;
});
}
async function runTask(task: {
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
}): Promise<void> {
const taskId = task.id;
// Determine execution path: if agent is specified AND exists in available_agents → Path B
if (task.agent) {
const [agentRow] = await sql<{ name: string; supports_acp: boolean; install_path: string | null }[]>`
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
`;
if (agentRow) {
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
return;
}
// Agent specified but not available — fall through to Path A with a warning
log.warn({ taskId, agent: task.agent }, 'dispatcher: specified agent not available, falling back to native');
}
// Path A — native inference (existing behavior)
await runNativeInference(task);
}
// ─── Path A: Native Inference ───────────────────────────────────────────────
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
const taskId = task.id;
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
try {
// Mark running
await sql`
UPDATE tasks
SET state = 'running', started_at = clock_timestamp(), execution_path = 'native'
WHERE id = ${taskId}
`;
// Create session + chat for this task
const model = task.model ?? config.DEFAULT_MODEL;
const sessionName = 'Task: ' + task.input.slice(0, 40);
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${model}, 'open')
RETURNING id
`;
const sessionId = session!.id;
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'Task execution', 'open')
RETURNING id
`;
const chatId = chat!.id;
// Link task to session
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
// Create user message + streaming assistant
await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
// Enqueue inference
inference.enqueue(sessionId, chatId, assistantId, 'default');
// Wait for inference to complete (poll message status)
const finalStatus = await waitForCompletion(assistantId);
if (stopping) {
await sql`
UPDATE tasks
SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId}
`;
return;
}
// Aggregate token cost for the task's session
const [costRow] = await sql<{ total: number | null }[]>`
SELECT SUM(tokens_used)::int AS total
FROM messages
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
`;
const costTokens = costRow?.total ?? null;
if (finalStatus === 'complete') {
const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId}
`;
const summary = (msg?.content ?? '').slice(0, 500);
await sql`
UPDATE tasks
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}
WHERE id = ${taskId}
`;
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
} else {
const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId}
`;
const summary = (msg?.content ?? 'Inference failed').slice(0, 500);
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}
WHERE id = ${taskId}
`;
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)');
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {});
}
}
// ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>─────────────────────────────────
async function runExternalAgent(
task: {
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
},
supportsAcp: boolean,
installPath: string | null,
): Promise<void> {
const taskId = task.id;
const agent = task.agent!;
const executionPath = supportsAcp ? 'acp' : 'pty';
log.info({ taskId, agent, executionPath }, 'dispatcher: starting task (path B — external)');
// Resolve the project's root path
const [project] = await sql<{ path: string | null }[]>`
SELECT path FROM projects WHERE id = ${task.project_id}
`;
const projectPath = project?.path;
if (!projectPath) {
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
WHERE id = ${taskId}
`;
return;
}
// Create an abort controller for this task
const ac = new AbortController();
try {
// Mark running
await sql`
UPDATE tasks
SET state = 'running', started_at = clock_timestamp(), execution_path = ${executionPath}
WHERE id = ${taskId}
`;
let sessionId: string;
let chatId: string;
if (task.session_id) {
sessionId = task.session_id;
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
`;
if (chats.length === 0) {
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
} else {
chatId = chats[0]!.id;
}
} else {
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
RETURNING id
`;
sessionId = session!.id;
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
}
if (!task.session_id) {
await sql`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
`;
}
// Step 1: Create worktree
log.info({ taskId, projectPath }, 'dispatcher: creating worktree');
const worktreePath = await createWorktree(projectPath, taskId, { signal: ac.signal });
log.info({ taskId, worktreePath }, 'dispatcher: worktree created');
// Step 2: Dispatch to agent
let outputSummary: string;
let assistantContent = '';
let acpReasoning = '';
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: assistantId,
chat_id: chatId,
role: 'assistant',
} as WsFrame);
const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands);
broker.publishFrame(sessionId, {
type: 'agent_commands',
task_id: taskId,
session_id: sessionId,
commands: manifestCommands,
} as WsFrame);
}
if (supportsAcp) {
const result = await dispatchViaAcp({
agent,
resolved: getResolvedRegistry().get(agent),
task: task.input,
worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined,
modeId: task.mode_id ?? undefined,
thinkingOptionId: task.thinking_option_id ?? undefined,
taskId,
sessionId,
chatId,
messageId: assistantId,
broker,
signal: ac.signal,
log,
});
assistantContent = result.output.slice(0, 50_000);
acpReasoning = result.reasoningText.slice(0, 200_000);
outputSummary = result.output.slice(0, 500);
await persistExternalAgentTurn(sql, assistantId, result.toolSnapshots, acpReasoning);
} else {
const result = await dispatchViaPty({
agent,
task: task.input,
worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined,
modeId: task.mode_id ?? undefined,
thinkingOptionId: task.thinking_option_id ?? undefined,
signal: ac.signal,
log,
});
assistantContent = (result.stdout || result.stderr || '(no output)').slice(0, 50_000);
outputSummary = (result.stdout || result.stderr).slice(0, 500);
if (assistantContent) {
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: assistantContent,
} as WsFrame);
}
}
await sql`
UPDATE messages
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
WHERE id = ${assistantId}
`;
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: assistantId,
chat_id: chatId,
} as WsFrame);
if (stopping) {
await sql`
UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}
`;
await cleanupWorktree(projectPath, taskId);
return;
}
// Step 3: Diff the worktree and queue pending changes
log.info({ taskId }, 'dispatcher: diffing worktree');
const diff = await diffWorktree(worktreePath, projectPath, { signal: ac.signal });
if (diff) {
// Queue a single pending_change entry with the full unified diff
await sql`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff})
`;
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff queued as pending change');
} else {
log.info({ taskId }, 'dispatcher: no changes detected in worktree');
}
// Step 4: Cleanup worktree
await cleanupWorktree(projectPath, taskId);
// Step 5: Aggregate token cost
const [extCostRow] = await sql<{ total: number | null }[]>`
SELECT SUM(tokens_used)::int AS total
FROM messages
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
`;
const extCostTokens = extCostRow?.total ?? null;
// Step 6: Mark task completed
await sql`
UPDATE tasks
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${outputSummary}, cost_tokens = ${extCostTokens}
WHERE id = ${taskId}
`;
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.error({ taskId, agent, err: errMsg }, 'dispatcher: external agent error');
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {});
// Best-effort cleanup
await cleanupWorktree(projectPath, taskId);
clearTaskCommands(taskId);
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
async function waitForCompletion(assistantId: string): Promise<string> {
for (;;) {
if (stopping) return 'cancelled';
const [row] = await sql<{ status: string }[]>`
SELECT status FROM messages WHERE id = ${assistantId}
`;
const status = row?.status ?? 'failed';
if (status !== 'streaming') return status;
await sleep(COMPLETION_POLL_MS);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
return {
start() {
log.info('dispatcher: starting poll loop + tasks_new listener');
// Fallback poll — catches notifications missed while the listen connection
// was down. The fast path is the NOTIFY listener below.
timer = setInterval(() => triggerPoll('interval'), POLL_INTERVAL_MS);
// Fast path: react immediately to new tasks. porsager reserves a dedicated
// connection and auto-resubscribes on reconnect; the onlisten callback
// fires on each (re)subscribe, so we kick a catch-up poll there too to
// sweep up anything inserted during a disconnect.
sql
.listen(
'tasks_new',
() => triggerPoll('notify'),
() => triggerPoll('listen-subscribed'),
)
.then((meta) => {
listener = meta;
})
.catch((err) => {
log.error({ err }, 'dispatcher: failed to LISTEN tasks_new — relying on poll fallback');
});
},
async stop() {
stopping = true;
if (timer) {
clearInterval(timer);
timer = null;
}
if (listener) {
await listener.unlisten().catch((err) => {
log.error({ err }, 'dispatcher: unlisten error');
});
listener = null;
}
if (inflightPromise) {
log.info('dispatcher: waiting for in-flight task');
await inflightPromise;
}
log.info('dispatcher: stopped');
},
};
}

View File

@@ -0,0 +1,66 @@
/**
* Local shell exec on the BooCoder host (replaces deprecated ssh.ts for worktrees).
*/
import { spawn } from 'node:child_process';
export interface HostExecResult {
exitCode: number;
stdout: string;
stderr: string;
}
export async function hostExec(
command: string,
opts?: { signal?: AbortSignal; timeoutMs?: number },
): Promise<HostExecResult> {
return new Promise<HostExecResult>((resolve, reject) => {
const child = spawn('bash', ['-lc', command], {
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let killed = false;
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
const cleanup = () => {
if (!killed) {
killed = true;
child.kill('SIGTERM');
}
};
if (opts?.signal) {
if (opts.signal.aborted) {
cleanup();
reject(new Error('host exec aborted before start'));
return;
}
opts.signal.addEventListener('abort', cleanup, { once: true });
}
let timer: ReturnType<typeof setTimeout> | undefined;
if (opts?.timeoutMs) {
timer = setTimeout(() => {
cleanup();
reject(new Error(`host exec timed out after ${opts.timeoutMs}ms`));
}, opts.timeoutMs);
}
child.on('close', (code) => {
if (timer) clearTimeout(timer);
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
resolve({ exitCode: code ?? 1, stdout, stderr });
});
child.on('error', (err) => {
if (timer) clearTimeout(timer);
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
reject(err);
});
child.stdin!.end();
});
}

View File

@@ -0,0 +1,232 @@
/**
* BooCoder MCP Server — exposes task primitives as MCP tools.
*
* Started when `--mcp` flag is passed to the entry point. Runs stdio transport
* so external tools (opencode in Termius) can drive the task queue.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import type { Sql } from '../db.js';
import { applyOne, rejectOne } from './pending_changes.js';
// --- Tool handlers -----------------------------------------------------------
interface TaskRow {
id: string;
state: string;
}
interface PendingRow {
id: string;
file_path: string;
operation: string;
diff: string;
session_id: string;
}
interface WorktreeRow {
id: string;
worktree_path: string;
agent: string;
started_at: string;
}
interface ProjectPathRow {
path: string;
}
function textResult(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
// --- Public entry ------------------------------------------------------------
export async function startMcpServer(sql: Sql): Promise<void> {
const server = new McpServer(
{ name: 'boocoder', version: '2.0.2' },
{ capabilities: { tools: {} } },
);
// 1. boocoder.create_task
server.tool(
'boocoder.create_task',
'Create a new task in the BooCoder task queue',
{
project_id: z.string().describe('Project UUID'),
input: z.string().describe('Task description / prompt for the agent'),
agent: z.string().optional().describe('Agent name (optional — uses default if omitted)'),
model: z.string().optional().describe('Model override (optional)'),
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
},
async (args) => {
const [row] = await sql<TaskRow[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, state)
VALUES (
${args.project_id},
${args.input},
${args.agent ?? null},
${args.model ?? null},
${args.mode_id ?? null},
${args.thinking_option_id ?? null},
'pending'
)
RETURNING id, state
`;
return textResult({
task_id: row!.id,
state: row!.state,
mode_id: args.mode_id ?? null,
thinking_option_id: args.thinking_option_id ?? null,
});
},
);
// 2. boocoder.list_pending_changes
server.tool(
'boocoder.list_pending_changes',
'List pending changes awaiting review',
{
session_id: z.string().optional().describe('Optional session filter'),
},
async (args) => {
let rows: PendingRow[];
if (args.session_id) {
rows = await sql<PendingRow[]>`
SELECT id, file_path, operation, diff, session_id
FROM pending_changes
WHERE status = 'pending' AND session_id = ${args.session_id}
ORDER BY created_at ASC
`;
} else {
rows = await sql<PendingRow[]>`
SELECT id, file_path, operation, diff, session_id
FROM pending_changes
WHERE status = 'pending'
ORDER BY created_at ASC
`;
}
const items = rows.map((r) => ({
id: r.id,
file_path: r.file_path,
operation: r.operation,
diff_preview: r.diff.slice(0, 200),
}));
return textResult(items);
},
);
// 3. boocoder.apply
server.tool(
'boocoder.apply',
'Apply a pending change (write to disk)',
{
change_id: z.string().describe('Pending change UUID'),
},
async (args) => {
// Resolve projectRoot from the change's session → project path
const [proj] = await sql<ProjectPathRow[]>`
SELECT p.path FROM pending_changes pc
JOIN sessions s ON pc.session_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE pc.id = ${args.change_id}
`;
if (!proj) {
return textResult({ success: false, file_path: '', error: 'change not found or project path unresolved' });
}
const result = await applyOne(sql, args.change_id, proj.path);
return textResult({ success: result.success, file_path: result.file_path, error: result.error });
},
);
// 4. boocoder.reject
server.tool(
'boocoder.reject',
'Reject a pending change (mark as rejected, no disk write)',
{
change_id: z.string().describe('Pending change UUID'),
},
async (args) => {
await rejectOne(sql, args.change_id);
return textResult({ success: true });
},
);
// 5. boocoder.dispatch_external_agent
server.tool(
'boocoder.dispatch_external_agent',
'Create a task targeting a specific external agent (ACP or PTY dispatch)',
{
project_id: z.string().describe('Project UUID'),
input: z.string().describe('Task prompt'),
agent: z.string().describe('Agent name (must match available_agents registry)'),
model: z.string().optional().describe('Model override (optional)'),
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
},
async (args) => {
const [row] = await sql<TaskRow[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, state)
VALUES (
${args.project_id},
${args.input},
${args.agent},
${args.model ?? null},
${args.mode_id ?? null},
${args.thinking_option_id ?? null},
'pending'
)
RETURNING id, state
`;
// Determine execution path from available_agents
const [agentRow] = await sql<{ supports_acp: boolean }[]>`
SELECT supports_acp FROM available_agents WHERE name = ${args.agent}
`;
const executionPath = agentRow?.supports_acp ? 'acp' : 'pty';
return textResult({
task_id: row!.id,
state: row!.state,
execution_path: executionPath,
mode_id: args.mode_id ?? null,
thinking_option_id: args.thinking_option_id ?? null,
});
},
);
// 6. boocoder.list_worktrees
server.tool(
'boocoder.list_worktrees',
'List active worktrees from running tasks',
{},
async () => {
const rows = await sql<WorktreeRow[]>`
SELECT id, worktree_path, agent, started_at
FROM tasks
WHERE worktree_path IS NOT NULL AND state = 'running'
ORDER BY started_at DESC
`;
const items = rows.map((r) => ({
task_id: r.id,
worktree_path: r.worktree_path,
agent: r.agent,
started_at: r.started_at,
}));
return textResult(items);
},
);
// Connect via stdio
const transport = new StdioServerTransport();
await server.connect(transport);
// Block until stdin closes (transport handles lifecycle)
await new Promise<void>((resolve) => {
process.stdin.on('end', resolve);
process.stdin.on('close', resolve);
});
await sql.end({ timeout: 5 });
}

View File

@@ -0,0 +1,224 @@
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import type { Sql } from '../db.js';
import { resolveWritePath } from './write_guard.js';
// --- Types -------------------------------------------------------------------
export interface PendingChange {
id: string;
session_id: string;
task_id: string | null;
file_path: string;
operation: 'create' | 'edit' | 'delete';
diff: string;
status: 'pending' | 'applied' | 'rejected' | 'reverted';
created_at: string;
}
export interface ApplyResult {
id: string;
file_path: string;
operation: string;
success: boolean;
error?: string;
}
// --- Queue functions ---------------------------------------------------------
export async function queueEdit(
sql: Sql,
sessionId: string,
taskId: string | null,
filePath: string,
oldString: string,
newString: string,
projectRoot: string,
): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath);
const diff = JSON.stringify({ old: oldString, new: newString });
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff})
RETURNING *
`;
return row!;
}
export async function queueCreate(
sql: Sql,
sessionId: string,
taskId: string | null,
filePath: string,
content: string,
projectRoot: string,
): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath);
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content})
RETURNING *
`;
return row!;
}
export async function queueDelete(
sql: Sql,
sessionId: string,
taskId: string | null,
filePath: string,
projectRoot: string,
): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath);
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '')
RETURNING *
`;
return row!;
}
// --- Apply functions ---------------------------------------------------------
export async function applyOne(
sql: Sql,
changeId: string,
projectRoot: string,
): Promise<ApplyResult> {
const [change] = await sql<PendingChange[]>`
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'pending'
`;
if (!change) {
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not pending' };
}
try {
// Re-validate path in case projectRoot has shifted
resolveWritePath(projectRoot, change.file_path);
switch (change.operation) {
case 'create': {
await mkdir(dirname(change.file_path), { recursive: true });
await writeFile(change.file_path, change.diff, 'utf8');
break;
}
case 'edit': {
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
const content = await readFile(change.file_path, 'utf8');
if (!content.includes(oldStr)) {
throw new Error('old_string not found in file — file may have changed since the edit was queued');
}
const updated = content.replace(oldStr, newStr);
await writeFile(change.file_path, updated, 'utf8');
break;
}
case 'delete': {
// Stash current content in diff for potential rewind
try {
const existing = await readFile(change.file_path, 'utf8');
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
} catch {
// File may already be gone — proceed with status update
}
await unlink(change.file_path);
break;
}
}
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
}
}
export async function applyAll(
sql: Sql,
sessionId: string,
projectRoot: string,
): Promise<ApplyResult[]> {
const pending = await sql<PendingChange[]>`
SELECT * FROM pending_changes
WHERE session_id = ${sessionId} AND status = 'pending'
ORDER BY created_at ASC
`;
const results: ApplyResult[] = [];
for (const change of pending) {
results.push(await applyOne(sql, change.id, projectRoot));
}
return results;
}
// --- Reject functions --------------------------------------------------------
export async function rejectOne(sql: Sql, changeId: string): Promise<void> {
await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`;
}
export async function rejectAll(sql: Sql, sessionId: string): Promise<void> {
await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} AND status = 'pending'`;
}
// --- Rewind functions --------------------------------------------------------
export async function rewindOne(
sql: Sql,
changeId: string,
projectRoot: string,
): Promise<ApplyResult> {
const [change] = await sql<PendingChange[]>`
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'applied'
`;
if (!change) {
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not applied' };
}
try {
resolveWritePath(projectRoot, change.file_path);
switch (change.operation) {
case 'create': {
// Reverse a create: delete the file
await unlink(change.file_path);
break;
}
case 'edit': {
// Reverse an edit: swap old and new
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
const content = await readFile(change.file_path, 'utf8');
if (!content.includes(newStr)) {
throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply');
}
const reverted = content.replace(newStr, oldStr);
await writeFile(change.file_path, reverted, 'utf8');
break;
}
case 'delete': {
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
await mkdir(dirname(change.file_path), { recursive: true });
await writeFile(change.file_path, change.diff, 'utf8');
break;
}
}
await sql`UPDATE pending_changes SET status = 'reverted' WHERE id = ${changeId}`;
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
}
}
// --- Query functions ---------------------------------------------------------
export async function listPending(sql: Sql, sessionId: string): Promise<PendingChange[]> {
return sql<PendingChange[]>`
SELECT * FROM pending_changes
WHERE session_id = ${sessionId} AND status = 'pending'
ORDER BY created_at ASC
`;
}

View File

@@ -0,0 +1,207 @@
/**
* Blocks ACP dispatch on permission/elicitation prompts until the user responds via API.
*/
import type { RequestPermissionRequest, RequestPermissionResponse, CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk';
import { isUnattendedMode } from './provider-manifest.js';
const DEFAULT_TIMEOUT_MS = 120_000;
interface PendingPermission {
type: 'permission';
request: RequestPermissionRequest;
sessionId: string;
resolve: (response: RequestPermissionResponse) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
interface PendingElicitation {
type: 'elicitation';
request: CreateElicitationRequest;
sessionId: string;
resolve: (response: CreateElicitationResponse) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
type PendingEntry = PendingPermission | PendingElicitation;
const pendingByTask = new Map<string, PendingEntry>();
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
export interface PermissionPrompt {
taskId: string;
kind: PermissionKind;
toolTitle?: string;
description?: string;
input?: Record<string, unknown>;
options: Array<{ optionId: string; label: string }>;
}
export interface PermissionHooks {
onPrompt?: (prompt: PermissionPrompt & { sessionId: string }) => void | Promise<void>;
onResolved?: (taskId: string, sessionId: string) => void | Promise<void>;
}
let hooks: PermissionHooks = {};
export function setPermissionHooks(next: PermissionHooks): void {
hooks = next;
}
function resolveKind(params: RequestPermissionRequest): PermissionKind {
const input = params.toolCall?.rawInput;
if (input && typeof input === 'object' && !Array.isArray(input) && 'questions' in input && Array.isArray((input as Record<string, unknown>).questions)) {
return 'question';
}
return 'tool';
}
function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
const kind = resolveKind(params);
const rawInput = params.toolCall?.rawInput;
const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
? rawInput as Record<string, unknown>
: undefined;
return {
taskId,
kind,
toolTitle: params.toolCall?.title ?? undefined,
...(input ? { input } : {}),
options: params.options.map((o) => ({
optionId: o.optionId,
label: o.name,
})),
};
}
export function waitForPermissionResponse(
taskId: string,
sessionId: string,
provider: string,
modeId: string | undefined,
params: RequestPermissionRequest,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<RequestPermissionResponse> {
if (isUnattendedMode(provider, modeId)) {
const first = params.options[0];
if (first) {
return Promise.resolve({ outcome: { outcome: 'selected', optionId: first.optionId } });
}
return Promise.resolve({ outcome: { outcome: 'cancelled' } });
}
return new Promise((resolve, reject) => {
const existing = pendingByTask.get(taskId);
if (existing) {
clearTimeout(existing.timer);
existing.reject(new Error('superseded by newer permission request'));
}
const timer = setTimeout(() => {
pendingByTask.delete(taskId);
void hooks.onResolved?.(taskId, sessionId);
resolve({ outcome: { outcome: 'cancelled' } });
}, timeoutMs);
pendingByTask.set(taskId, { type: 'permission', request: params, sessionId, resolve, reject, timer });
const prompt = toPrompt(taskId, params);
void hooks.onPrompt?.({ ...prompt, sessionId });
});
}
export function respondToPermission(taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>): boolean {
const pending = pendingByTask.get(taskId);
if (!pending) return false;
clearTimeout(pending.timer);
pendingByTask.delete(taskId);
if (pending.type === 'elicitation') {
if (updatedInput) {
const content = updatedInput as { [key: string]: string | number | boolean | string[] };
pending.resolve({ action: 'accept', content });
} else {
pending.resolve({ action: 'decline' });
}
} else {
if (optionId) {
pending.resolve({ outcome: { outcome: 'selected', optionId } });
} else {
pending.resolve({ outcome: { outcome: 'cancelled' } });
}
}
void hooks.onResolved?.(taskId, pending.sessionId);
return true;
}
export function getPendingPermission(taskId: string): PermissionPrompt | null {
const pending = pendingByTask.get(taskId);
if (!pending) return null;
if (pending.type === 'elicitation') {
return elicitationToPrompt(taskId, pending.request);
}
return toPrompt(taskId, pending.request);
}
function elicitationToPrompt(taskId: string, params: CreateElicitationRequest): PermissionPrompt {
const input: Record<string, unknown> = { message: params.message };
if ('requestedSchema' in params) {
input.requestedSchema = params.requestedSchema;
}
return {
taskId,
kind: 'elicitation',
toolTitle: params.message,
input,
options: [],
};
}
export function waitForElicitationResponse(
taskId: string,
sessionId: string,
provider: string,
modeId: string | undefined,
params: CreateElicitationRequest,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<CreateElicitationResponse> {
if (isUnattendedMode(provider, modeId)) {
return Promise.resolve({ action: 'decline' });
}
return new Promise((resolve, reject) => {
const existing = pendingByTask.get(taskId);
if (existing) {
clearTimeout(existing.timer);
existing.reject(new Error('superseded by newer elicitation request'));
}
const timer = setTimeout(() => {
pendingByTask.delete(taskId);
void hooks.onResolved?.(taskId, sessionId);
resolve({ action: 'cancel' });
}, timeoutMs);
pendingByTask.set(taskId, { type: 'elicitation', request: params, sessionId, resolve, reject, timer });
const prompt = elicitationToPrompt(taskId, params);
void hooks.onPrompt?.({ ...prompt, sessionId });
});
}
export function cancelPendingPermission(taskId: string): void {
const pending = pendingByTask.get(taskId);
if (!pending) return;
clearTimeout(pending.timer);
pendingByTask.delete(taskId);
if (pending.type === 'elicitation') {
pending.resolve({ action: 'cancel' });
} else {
pending.resolve({ outcome: { outcome: 'cancelled' } });
}
void hooks.onResolved?.(taskId, pending.sessionId);
}

View File

@@ -0,0 +1,66 @@
/**
* Static slash-command hints per harness (interactive TUI / agent session).
* Live ACP `available_commands_update` merges on top during dispatch.
*/
import type { AgentCommand } from './provider-types.js';
const CLAUDE_COMMANDS: AgentCommand[] = [
{ name: 'help', description: 'Show available slash commands' },
{ name: 'clear', description: 'Clear conversation history' },
{ name: 'compact', description: 'Compact context window' },
{ name: 'cost', description: 'Show session cost' },
{ name: 'memory', description: 'Manage project memory' },
{ name: 'model', description: 'Switch model' },
{ name: 'permissions', description: 'View or change permission mode' },
{ name: 'review', description: 'Review current changes' },
{ name: 'status', description: 'Show session status' },
{ name: 'vim', description: 'Toggle vim-style input' },
];
const OPENCODE_COMMANDS: AgentCommand[] = [
{ name: 'help', description: 'Show available commands' },
{ name: 'new', description: 'Start a new session' },
{ name: 'models', description: 'List or switch models' },
{ name: 'agents', description: 'List or switch agents' },
{ name: 'compact', description: 'Compact context' },
{ name: 'share', description: 'Share session' },
{ name: 'export', description: 'Export session' },
];
const GOOSE_COMMANDS: AgentCommand[] = [
{ name: 'help', description: 'Show available commands' },
{ name: 'clear', description: 'Clear conversation' },
{ name: 'compact', description: 'Compact context' },
{ name: 'exit', description: 'Exit session' },
];
const QWEN_COMMANDS: AgentCommand[] = [
{ name: 'help', description: 'Show available slash commands' },
{ name: 'clear', description: 'Clear conversation' },
{ name: 'memory', description: 'Manage memory' },
{ name: 'hooks', description: 'Manage hooks' },
{ name: 'review', description: 'Review changes' },
];
/** boocode harness uses /api/skills — merged on the frontend. */
export const PROVIDER_COMMANDS: Record<string, AgentCommand[]> = {
claude: CLAUDE_COMMANDS,
opencode: OPENCODE_COMMANDS,
goose: GOOSE_COMMANDS,
qwen: QWEN_COMMANDS,
boocode: [],
};
export function getManifestCommands(provider: string): AgentCommand[] {
return PROVIDER_COMMANDS[provider] ?? [];
}
export function mergeCommands(...lists: AgentCommand[][]): AgentCommand[] {
const byName = new Map<string, AgentCommand>();
for (const list of lists) {
for (const cmd of list) {
byName.set(cmd.name, cmd);
}
}
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
}

View File

@@ -0,0 +1,133 @@
/**
* v2.3 resolved provider registry — single in-memory source of truth after
* merging the hardcoded built-ins (provider-registry.ts) with the config file
* (provider-config.ts). Mirrors Paseo's buildProviderRegistry/addDerivedProviders.
*
* Phase 1 scope: build + expose the resolved registry. `launchCommand` is null
* for built-ins (the default argv is resolved at dispatch time in Phase 3) and
* is the config `command` for custom ACP entries. No DB columns (design.md §3.3);
* `enabled` lives in memory only.
*/
import type { ProviderDef } from './provider-registry.js';
import { PROVIDERS } from './provider-registry.js';
import { load, type CoderProvidersFile } from './provider-config.js';
export interface ResolvedProviderDef extends ProviderDef {
id: string;
enabled: boolean;
isBuiltin: boolean;
isCustomAcp: boolean;
/** Full argv for spawn: [binary, ...args]. Null for built-ins (resolved at dispatch). */
launchCommand: [string, ...string[]] | null;
env: Record<string, string> | undefined;
configLabel?: string;
configDescription?: string;
/** Config `models` — REPLACES the discovered/static model list when present. */
configModels?: Array<{ id: string; label: string }>;
/** Config `additionalModels` — MERGED on top of the resolved model list. */
configAdditionalModels?: Array<{ id: string; label: string }>;
}
/**
* Merge built-ins with config overrides into the resolved registry.
* Algorithm verbatim from design.md §3.1.
*/
export function buildResolvedRegistry(
builtins: ProviderDef[],
config: CoderProvidersFile,
): Map<string, ResolvedProviderDef> {
const out = new Map<string, ResolvedProviderDef>();
const overrides = config.providers ?? {};
const builtinNames = new Set(builtins.map((b) => b.name));
// 1. Built-ins, applying a config override if one is present.
for (const def of builtins) {
const ov = overrides[def.name];
let enabled = ov?.enabled !== false;
// 3. boocode is always enabled; an enabled:false override is ignored + warned.
if (def.name === 'boocode' && ov?.enabled === false) {
console.warn("provider-config: ignoring enabled:false for built-in 'boocode' (always enabled)");
enabled = true;
}
const launchCommand =
ov?.command && ov.command.length > 0 ? (ov.command as [string, ...string[]]) : null;
out.set(def.name, {
...def,
label: ov?.label ?? def.label,
id: def.name,
enabled,
isBuiltin: true,
isCustomAcp: false,
launchCommand,
env: ov?.env,
configLabel: ov?.label,
configDescription: ov?.description,
configModels: ov?.models,
configAdditionalModels: ov?.additionalModels,
});
}
// 2. Config ids that are not built-ins → custom ACP entries.
for (const [id, ov] of Object.entries(overrides)) {
if (builtinNames.has(id)) continue;
// §2.2 rules: "New id without extends → Reject at load with log."
if (ov.extends !== 'acp' || !ov.label || !ov.command || ov.command.length === 0) {
console.warn(
`provider-config: skipping custom provider '${id}' — requires extends:'acp', label, and command`,
);
continue;
}
out.set(id, {
name: id,
label: ov.label,
transport: 'acp',
modelSource: 'probe',
id,
enabled: ov.enabled !== false,
isBuiltin: false,
isCustomAcp: true,
launchCommand: ov.command as [string, ...string[]],
env: ov.env,
configLabel: ov.label,
configDescription: ov.description,
configModels: ov.models,
configAdditionalModels: ov.additionalModels,
});
}
return out;
}
// --- Module singleton ---------------------------------------------------------
let cachedRegistry: Map<string, ResolvedProviderDef> | null = null;
let cachedPath: string | null = null;
/** Load the config file at `path`, rebuild, and cache the resolved registry. */
export function loadProviderConfig(path: string): Map<string, ResolvedProviderDef> {
cachedPath = path;
cachedRegistry = buildResolvedRegistry(PROVIDERS, load(path));
return cachedRegistry;
}
/** Re-read the last-loaded config file and rebuild (Phase 4 calls this after PATCH). */
export function reloadProviderConfig(): Map<string, ResolvedProviderDef> {
if (cachedPath == null) {
cachedRegistry = buildResolvedRegistry(PROVIDERS, { providers: {} });
return cachedRegistry;
}
return loadProviderConfig(cachedPath);
}
/** The cached resolved registry (built-ins only if nothing has been loaded yet). */
export function getResolvedRegistry(): Map<string, ResolvedProviderDef> {
return cachedRegistry ?? buildResolvedRegistry(PROVIDERS, { providers: {} });
}
/** Resolved provider ids in registry order. */
export function getResolvedProviderIds(): string[] {
return [...getResolvedRegistry().keys()];
}

View File

@@ -0,0 +1,100 @@
/**
* v2.3 provider config file (`/data/coder-providers.json`) — schema + loader.
*
* Layers config-backed overrides/custom-ACP entries over the hardcoded built-ins
* (see provider-config-registry.ts). Loading NEVER throws at startup (design.md
* §2.1): a missing file, invalid JSON, or schema mismatch all fall back to
* `{ providers: {} }` (built-ins only, all enabled).
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { z } from 'zod';
// Schemas verbatim from design.md §2.2.
export const ProviderOverrideSchema = z.object({
extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends
label: z.string().min(1).optional(),
description: z.string().optional(),
command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args]
env: z.record(z.string()).optional(),
enabled: z.boolean().optional(), // default true
order: z.number().int().optional(), // UI sort key
models: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
});
export const CoderProvidersFileSchema = z.object({
providers: z.record(ProviderOverrideSchema).default({}),
});
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
/**
* PATCH body schema (design.md §6.2). A partial providers map where each value
* is either a full override object (REPLACES that id's override) or `null`
* (DELETES the override → revert to the built-in default). Ids absent from the
* patch are left untouched. The route validates the body against this first
* (malformed → 422) so a bad shape can never reach the merge/save step.
*/
export const ProviderConfigPatchSchema = z.object({
providers: z.record(ProviderOverrideSchema.nullable()).default({}),
});
export type ProviderConfigPatch = z.infer<typeof ProviderConfigPatchSchema>;
/**
* Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in
* `patch.providers` REPLACES that id's override object wholesale (NOT a deep
* field merge); a `null` value DELETES the override. Returns a new object —
* never mutates `current`. The result is a plain CoderProvidersFile (no nulls),
* which the route re-validates against CoderProvidersFileSchema before save.
*/
export function mergeProviderConfigPatch(
current: CoderProvidersFile,
patch: ProviderConfigPatch,
): CoderProvidersFile {
const providers: Record<string, ProviderOverride> = { ...current.providers };
for (const [id, override] of Object.entries(patch.providers)) {
if (override === null) {
delete providers[id];
} else {
providers[id] = override;
}
}
return { providers };
}
/** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */
export function load(path: string): CoderProvidersFile {
let raw: string;
try {
raw = readFileSync(path, 'utf8');
} catch {
// Missing file → built-ins only. Expected, not an error.
return { providers: {} };
}
let json: unknown;
try {
json = JSON.parse(raw);
} catch (err) {
console.error(`provider-config: invalid JSON in ${path} — using built-ins only`, err);
return { providers: {} };
}
const parsed = CoderProvidersFileSchema.safeParse(json);
if (!parsed.success) {
console.error(
`provider-config: schema validation failed for ${path} — using built-ins only`,
parsed.error.flatten(),
);
return { providers: {} };
}
return parsed.data;
}
/** Write the config back to disk (used by the Phase 4 PATCH route). */
export function save(path: string, config: CoderProvidersFile): void {
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
}

View File

@@ -0,0 +1,71 @@
/**
* v2.3 Phase 4 (design.md §8) — per-provider plaintext diagnostic report.
*
* Read-only by default: reports CACHED state (resolved registry def + the
* available_agents row + the warm snapshot-cache entry) plus a `which`-style
* PATH check for the launch binary. It does NOT spawn an ACP probe — §8 lists
* the live initialize probe as optional, and the route defaults to cached state.
*
* A template string is the whole formatter (no Paseo diagnostic-utils port).
*/
import type { ResolvedProviderDef } from './provider-config-registry.js';
import type { ProviderSnapshotEntry, ProviderModel } from './provider-types.js';
import { isCommandAvailable } from './command-availability.js';
/** The subset of an `available_agents` row the diagnostic reads. */
export interface DiagnosticAgentRow {
name: string;
install_path: string | null;
supports_acp?: boolean;
models?: ProviderModel[] | null;
last_probed_at?: string | Date | null;
}
interface DiagnosticOpts {
/** Warm snapshot-cache entry (read-only peek) — source of the last probe error. */
cachedEntry?: ProviderSnapshotEntry;
/** Injectable PATH check (defaults to the real `which`); stubbed in tests. */
checkAvailable?: (binary: string) => Promise<boolean>;
}
/** Resolve the binary the dispatcher would launch (for the PATH check + report). */
function resolveBinary(resolved: ResolvedProviderDef, agentRow: DiagnosticAgentRow | undefined): string {
return resolved.launchCommand?.[0] ?? agentRow?.install_path ?? resolved.id;
}
export async function getProviderDiagnostic(
resolved: ResolvedProviderDef,
agentRow: DiagnosticAgentRow | undefined,
opts: DiagnosticOpts = {},
): Promise<string> {
const checkAvailable = opts.checkAvailable ?? isCommandAvailable;
const installed = agentRow?.install_path != null;
const binary = resolveBinary(resolved, agentRow);
// boocode is native (no binary to launch) — short-circuit the PATH check.
const commandAvailable = resolved.transport === 'native' ? true : await checkAvailable(binary);
const lastProbedAt =
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).toISOString() : '(never)';
const modelCount = agentRow?.models?.length ?? 0;
const launchCommand = resolved.launchCommand
? resolved.launchCommand.join(' ')
: '(built-in default, resolved at dispatch)';
const lastError = opts.cachedEntry?.error ?? '(none recorded)';
return [
`provider: ${resolved.id}`,
`label: ${resolved.configLabel ?? resolved.label}`,
`transport: ${resolved.transport}`,
`enabled: ${resolved.enabled}`,
`builtin: ${resolved.isBuiltin}`,
`customAcp: ${resolved.isCustomAcp}`,
`installed: ${installed}`,
`install_path: ${agentRow?.install_path ?? '(none)'}`,
`binary: ${binary}`,
`command_available: ${commandAvailable}`,
`launch_command: ${launchCommand}`,
`supports_acp: ${agentRow?.supports_acp ?? '(unknown)'}`,
`last_probed_at: ${lastProbedAt}`,
`models_in_db: ${modelCount}`,
`last_probe_error: ${lastError}`,
].join('\n');
}

View File

@@ -0,0 +1,75 @@
/**
* Static provider mode metadata — lifted from Paseo provider-manifest.ts patterns.
*/
import type { ProviderMode } from './provider-types.js';
export interface ProviderManifestEntry {
defaultModeId: string | null;
modes: ProviderMode[];
/** Claude effort levels exposed as thinking options on models. */
thinkingOptions?: Array<{ id: string; label: string }>;
}
const CLAUDE_MODES: ProviderMode[] = [
{ id: 'default', label: 'Always Ask', description: 'Prompts for permission the first time a tool is used' },
{ id: 'auto', label: 'Auto mode', description: 'Model classifier reviews permission prompts automatically' },
{ id: 'acceptEdits', label: 'Accept File Edits', description: 'Automatically approves edit-focused tools' },
{ id: 'plan', label: 'Plan Mode', description: 'Analyze without executing tools or edits' },
{ id: 'bypassPermissions', label: 'Bypass', description: 'Skip all permission prompts', isUnattended: true },
];
const OPENCODE_MODES: ProviderMode[] = [
{ id: 'build', label: 'Build', description: 'Allows edits and tool execution' },
{ id: 'plan', label: 'Plan', description: 'Read-only planning mode' },
{ id: 'full-access', label: 'Full Access', description: 'Auto-approves all tool prompts', isUnattended: true },
];
const QWEN_PTY_MODES: ProviderMode[] = [
{ id: 'default', label: 'Default', description: 'Prompt for approval' },
{ id: 'plan', label: 'Plan', description: 'Plan only — no edits' },
{ id: 'auto-edit', label: 'Auto Edit', description: 'Auto-approve edit tools' },
{ id: 'auto', label: 'Auto', description: 'LLM classifier auto-approves safe actions' },
{ id: 'yolo', label: 'YOLO', description: 'Auto-approve all tools', isUnattended: true },
];
const CLAUDE_THINKING = [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' },
{ id: 'max', label: 'Max' },
];
export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
claude: {
defaultModeId: 'default',
modes: CLAUDE_MODES,
thinkingOptions: CLAUDE_THINKING,
},
opencode: {
defaultModeId: 'build',
modes: OPENCODE_MODES,
},
goose: {
defaultModeId: null,
modes: [],
},
qwen: {
defaultModeId: 'default',
modes: QWEN_PTY_MODES,
},
};
export function getManifestModes(provider: string): ProviderMode[] {
return PROVIDER_MANIFEST[provider]?.modes ?? [];
}
export function getManifestDefaultModeId(provider: string): string | null {
return PROVIDER_MANIFEST[provider]?.defaultModeId ?? null;
}
export function isUnattendedMode(provider: string, modeId: string | undefined): boolean {
if (!modeId) return false;
const modes = getManifestModes(provider);
return modes.some((m) => m.id === modeId && m.isUnattended);
}

View File

@@ -0,0 +1,69 @@
export interface ProviderDef {
name: string;
label: string;
transport: 'native' | 'acp' | 'pty';
modelSource: 'llama-swap' | 'static' | 'probe';
staticModels?: Array<{ id: string; label: string }>;
/** Merge llama-swap models into probed list (OpenCode). */
mergeLlamaSwap?: boolean;
}
/**
* Model discovery rules (see provider-snapshot.ts):
* - boocode: llama-swap only
* - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids)
* - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only
* - goose: ACP probe only
* - claude: static manifest models + thinking options
*/
export const PROVIDERS: ProviderDef[] = [
{
name: 'boocode',
label: 'BooCoder',
transport: 'native',
modelSource: 'llama-swap',
},
{
name: 'opencode',
label: 'OpenCode',
transport: 'acp',
modelSource: 'probe',
mergeLlamaSwap: true,
},
{
name: 'goose',
label: 'Goose',
transport: 'acp',
modelSource: 'probe',
},
{
name: 'claude',
label: 'Claude Code',
transport: 'pty',
modelSource: 'static',
// Passed verbatim to `claude --model <id>` (PTY dispatch). The CLI accepts a
// latest-alias ('opus'/'sonnet'/'haiku') or a pinned full name
// ('claude-opus-4-8'). Aliases never go stale; pinned IDs let you select an
// exact version. Extend/replace per-install via data/coder-providers.json
// (models / additionalModels) without a code change.
staticModels: [
{ id: 'opus', label: 'Opus (latest)' },
{ id: 'claude-opus-4-8', label: 'Opus 4.8' },
{ id: 'sonnet', label: 'Sonnet (latest)' },
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
{ id: 'haiku', label: 'Haiku (latest)' },
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
],
},
{
name: 'qwen',
label: 'Qwen Code',
transport: 'acp',
modelSource: 'probe',
},
];
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
/** External agents probed on host (excludes native boocode). */
export const PROBED_AGENT_NAMES = PROVIDERS.filter((p) => p.name !== 'boocode').map((p) => p.name);

View File

@@ -0,0 +1,334 @@
/**
* Provider snapshot cache — cold ACP probe per provider + static manifest merge.
*/
import { homedir } from 'node:os';
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import {
getManifestDefaultModeId,
getManifestModes,
PROVIDER_MANIFEST,
} from './provider-manifest.js';
import { probeAcpProvider } from './acp-probe.js';
import type { ProviderModel, ProviderSnapshotEntry, AgentCommand } from './provider-types.js';
import { getManifestCommands, mergeCommands } from './provider-commands.js';
import { readQwenSettingsModels } from './qwen-settings.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { isCommandAvailable } from './command-availability.js';
import { discoverClaudeCommands } from './claude-command-discovery.js';
interface AgentRow {
name: string;
install_path: string | null;
supports_acp: boolean;
models: ProviderModel[] | null;
commands: AgentCommand[] | null;
label: string | null;
transport: string | null;
last_probed_at: string | Date | null;
}
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
try {
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
if (!res.ok) return [];
const parsed = (await res.json()) as { data?: Array<{ id: string }> };
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
} catch {
return [];
}
}
/** Prefix llama-swap model ids so they don't collide with provider-native models. */
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
return models.map((m) => ({
...m,
id: m.id.startsWith('llama-swap/') ? m.id : `llama-swap/${m.id}`,
}));
}
function attachClaudeThinking(models: ProviderModel[]): ProviderModel[] {
const thinking = PROVIDER_MANIFEST.claude?.thinkingOptions;
if (!thinking?.length) return models;
return models.map((m) => ({
...m,
thinkingOptions: thinking,
defaultThinkingOptionId: 'medium',
}));
}
export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
const seen = new Set<string>();
const out: ProviderModel[] = [];
for (const list of lists) {
for (const m of list) {
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push(m);
}
}
return out;
}
async function buildProviderEntry(
resolved: ResolvedProviderDef,
agentRow: AgentRow | undefined,
llamaModels: ProviderModel[],
cwd: string,
ttlMs: number,
force: boolean,
): Promise<ProviderSnapshotEntry> {
const name = resolved.id;
const isNative = resolved.transport === 'native';
const fallbackModes = getManifestModes(name);
const defaultModeId = getManifestDefaultModeId(name);
const manifestCommands = getManifestCommands(name);
// Manifest + persisted live ACP commands (captured on a prior cold probe), so
// the agent's discovered commands show even when the tier-2 probe is skipped.
const dbCommands = mergeCommands(manifestCommands, agentRow?.commands ?? []);
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
// v2.3: config `models` REPLACES the discovered/static list; `additionalModels`
// MERGES on top. Applied to every ready/installed model list below.
const withConfigModels = (m: ProviderModel[]): ProviderModel[] => {
let out = resolved.configModels && resolved.configModels.length > 0 ? resolved.configModels : m;
if (resolved.configAdditionalModels && resolved.configAdditionalModels.length > 0) {
out = mergeModels(out, resolved.configAdditionalModels);
}
return out;
};
// ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
let transport = resolved.transport;
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
transport = 'pty';
}
// 1. Disabled → unavailable, no probe.
if (!resolved.enabled) {
return {
name, label, ...descr, transport, status: 'unavailable',
enabled: false, installed: false, models: [], modes: fallbackModes,
defaultModeId, commands: manifestCommands,
};
}
// 2. Native boocode → always ready (llama-swap models).
if (isNative) {
return {
name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
defaultModeId: null, commands: manifestCommands,
};
}
// 3. Tier-1 fast availability: installed iff a probed install_path exists or
// the launch binary is on PATH. No spawn beyond a `which` for custom entries.
const fast =
agentRow?.install_path != null ||
(resolved.launchCommand ? await isCommandAvailable(resolved.launchCommand[0]) : false);
if (!fast) {
return {
name, label, ...descr, transport, status: 'unavailable',
enabled: true, installed: false, models: [], modes: fallbackModes,
defaultModeId, commands: manifestCommands,
};
}
// Baseline model precedence (used by claude + non-probe fallbacks).
let models: ProviderModel[] = [];
if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
models = llamaModels;
} else if (agentRow?.models?.length) {
models = agentRow.models;
} else if (resolved.staticModels) {
models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
}
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
if (name === 'claude') {
// claude is PTY (no ACP discovery) — read its enabled commands + plugin
// skills from disk live (the snapshot cache rate-limits the fs reads).
return {
name, label, transport, status: 'ready', enabled: true, installed: true,
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
commands: mergeCommands(manifestCommands, discoverClaudeCommands()),
};
}
const canProbeAcp =
transport === 'acp' &&
((agentRow?.install_path != null && agentRow.supports_acp) ||
(resolved.isCustomAcp && resolved.launchCommand != null));
if (canProbeAcp) {
// Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB
// models. Otherwise serve DB models + manifest modes/commands — no spawn.
const lastProbedMs =
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).getTime() : NaN;
const stale = Number.isNaN(lastProbedMs) || Date.now() - lastProbedMs > ttlMs;
const dbEmpty = !(agentRow?.models && agentRow.models.length > 0);
const runTier2 = force || stale || dbEmpty;
if (!runTier2) {
let skipModels = agentRow?.models ?? [];
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels));
} else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) {
skipModels = llamaModels;
}
return {
name, label, transport, status: 'ready', enabled: true, installed: true,
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: dbCommands,
};
}
const probeTarget =
resolved.isCustomAcp && resolved.launchCommand
? resolved.launchCommand[0]
: agentRow!.install_path!;
const probe = await probeAcpProvider(name, probeTarget, cwd);
let probeModels = probe.models.length > 0 ? probe.models : models;
if (name === 'qwen') {
probeModels = mergeModels(probeModels, await readQwenSettingsModels());
}
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
const nativeModels = probe.models.length > 0 ? probe.models : probeModels;
probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
}
return {
name, label, transport,
status: probe.ok ? 'ready' : 'error',
enabled: true, installed: true,
models: withConfigModels(probeModels),
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
defaultModeId: probe.defaultModeId ?? defaultModeId,
commands: mergeCommands(manifestCommands, probe.commands),
...(probe.error ? { error: probe.error } : {}),
fetchedAt: new Date().toISOString(),
};
}
// PTY-only fallback (e.g. qwen without ACP) — installed + ready.
if (name === 'qwen' && models.length === 0) {
models = await readQwenSettingsModels();
}
return {
name, label, transport, status: 'ready', enabled: true, installed: true,
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: dbCommands,
};
}
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
const CACHE_TTL_MS = 5 * 60_000;
export async function getProviderSnapshot(
sql: Sql,
config: Config,
cwd?: string,
force = false,
): Promise<ProviderSnapshotEntry[]> {
const resolvedCwd = cwd?.trim() || homedir();
const cacheKey = resolvedCwd;
const cached = snapshotCache.get(cacheKey);
if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) {
return cached.entries;
}
const inflight = snapshotInflight.get(cacheKey);
if (!force && inflight) {
return inflight;
}
const build = async (): Promise<ProviderSnapshotEntry[]> => {
const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<AgentRow[]>`
SELECT name, install_path, supports_acp, models, commands, label, transport, last_probed_at FROM available_agents
`;
const agentMap = new Map(agents.map((a) => [a.name, a]));
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
const entries = await Promise.all(
[...getResolvedRegistry().values()].map((resolved) =>
buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force),
),
);
snapshotCache.set(cacheKey, { at: Date.now(), entries });
return entries;
};
const promise = build().finally(() => {
snapshotInflight.delete(cacheKey);
});
snapshotInflight.set(cacheKey, promise);
// Await the build (force or cache-miss) and return terminal entries. The sync
// `loading` return (design §4.4) is DEFERRED until Phase 5 ships the client
// poll that resolves it: without that poll, a single fetch lands on
// installed:false `loading` entries, which AgentComposerBar filters out
// (`e.installed && ...`) → empty picker. Builds stay fast via the tier-2 skip
// once available_agents.models is warm.
return promise;
}
export function clearProviderSnapshotCache(): void {
snapshotCache.clear();
snapshotInflight.clear();
}
/**
* Read-only peek into the warm snapshot cache for one provider (no build, no
* probe). Used by the diagnostic route to report the last computed probe error
* without spawning anything. Returns undefined on a cold cache / unknown name.
*/
export function peekSnapshotEntry(name: string, cwd?: string): ProviderSnapshotEntry | undefined {
const resolvedCwd = cwd?.trim() || homedir();
return snapshotCache.get(resolvedCwd)?.entries.find((e) => e.name === name);
}
/** Persist probed model lists back to available_agents for fast legacy reads. */
export async function persistProbedModels(
sql: Sql,
entries: ProviderSnapshotEntry[],
log: FastifyBaseLogger,
): Promise<void> {
let count = 0;
for (const entry of entries) {
if (entry.name === 'boocode') continue;
let persisted = false;
if (entry.models.length > 0) {
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
await sql`
UPDATE available_agents
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
WHERE name = ${entry.name}
`;
persisted = true;
}
// Persist captured ACP commands so they survive the tier-2 probe skip and
// show without a dispatch. Only when non-empty — never clobber a prior set.
if (entry.commands.length > 0) {
const flatCommands = entry.commands.map((c) => ({
name: c.name,
...(c.description ? { description: c.description } : {}),
}));
await sql`
UPDATE available_agents
SET commands = ${sql.json(flatCommands as never)}, last_probed_at = clock_timestamp()
WHERE name = ${entry.name}
`;
persisted = true;
}
if (persisted) count++;
}
if (count > 0) {
log.info({ count }, 'provider-snapshot: persisted models/commands to available_agents');
}
}

View File

@@ -0,0 +1,61 @@
/** Shared provider / snapshot types (Paseo-shaped, BooCoder-native). */
export interface ProviderMode {
id: string;
label: string;
description?: string;
/** Auto-approve tool permissions when this mode is selected. */
isUnattended?: boolean;
}
export interface ThinkingOption {
id: string;
label: string;
isDefault?: boolean;
}
export interface ProviderModel {
id: string;
label: string;
description?: string;
isDefault?: boolean;
thinkingOptions?: ThinkingOption[];
defaultThinkingOptionId?: string;
}
// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
export interface AgentCommand {
name: string;
description?: string;
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
// Drives the icon split in the coder slash menu. Undefined → command.
kind?: 'command' | 'skill';
}
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
export interface ProviderSnapshotEntry {
name: string;
label: string;
description?: string;
transport: string;
status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean;
models: ProviderModel[];
modes: ProviderMode[];
defaultModeId: string | null;
commands: AgentCommand[];
error?: string;
fetchedAt?: string;
}
export interface AgentSessionConfig {
provider: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
}

View File

@@ -0,0 +1,137 @@
/**
* PTY dispatch — runs external agents directly on the host.
*/
import type { FastifyBaseLogger } from 'fastify';
import { spawn } from 'node:child_process';
export interface DispatchResult {
exitCode: number;
stdout: string;
stderr: string;
}
export interface PtyDispatchOpts {
agent: string;
task: string;
worktreePath: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
installPath?: string;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
interface PtySpawnSpec {
binary: string;
args: string[];
stdin?: string;
}
function buildPtySpawnSpec(
agent: string,
task: string,
model?: string,
modeId?: string,
thinkingOptionId?: string,
installPath?: string,
): PtySpawnSpec | null {
const binary = installPath ?? agent;
switch (agent) {
case 'claude': {
const args = ['-p'];
if (model) args.push('--model', model);
if (modeId) args.push('--permission-mode', modeId);
if (thinkingOptionId) args.push('--effort', thinkingOptionId);
return { binary, args, stdin: task };
}
case 'qwen': {
const args = ['-p', task, '--output-format', 'stream-json'];
if (model) args.push('--model', model);
if (modeId) args.push('--approval-mode', modeId);
return { binary, args };
}
case 'opencode':
return {
binary,
args: model ? ['--model', model] : [],
stdin: task,
};
case 'goose':
return {
binary,
args: model ? ['run', '--text', task, '--model', model] : ['run', '--text', task],
};
default:
return null;
}
}
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
const { agent, task, worktreePath, model, modeId, thinkingOptionId, installPath, signal, log } = opts;
const cmd = buildPtySpawnSpec(agent, task, model, modeId, thinkingOptionId, installPath);
if (!cmd) {
return {
exitCode: 1,
stdout: '',
stderr: `Agent '${agent}' is not yet supported for PTY dispatch.`,
};
}
log.info({ agent, binary: cmd.binary, worktreePath, modeId }, 'pty-dispatch: starting');
return new Promise<DispatchResult>((resolve, reject) => {
const child = spawn(cmd.binary, cmd.args, {
cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
if (cmd.stdin) {
child.stdin!.write(cmd.stdin);
}
child.stdin!.end();
let stdout = '';
let stderr = '';
let killed = false;
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
const cleanup = () => {
if (!killed) {
killed = true;
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 5_000);
}
};
if (signal) {
if (signal.aborted) {
cleanup();
resolve({ exitCode: 130, stdout: '', stderr: 'Aborted before start' });
return;
}
signal.addEventListener('abort', cleanup, { once: true });
}
child.on('close', (code) => {
if (signal) signal.removeEventListener('abort', cleanup);
log.info({ agent, exitCode: code }, 'pty-dispatch: completed');
resolve({ exitCode: code ?? 1, stdout, stderr });
});
child.on('error', (err) => {
if (signal) signal.removeEventListener('abort', cleanup);
log.error({ agent, err: err.message }, 'pty-dispatch: spawn error');
reject(err);
});
});
}

View File

@@ -0,0 +1,21 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import type { ProviderModel } from './provider-types.js';
const QWEN_SETTINGS_PATH = join(homedir(), '.qwen', 'settings.json');
export async function readQwenSettingsModels(): Promise<ProviderModel[]> {
try {
const raw = await readFile(QWEN_SETTINGS_PATH, 'utf8');
if (!raw.trim()) return [];
const settings = JSON.parse(raw) as {
modelProviders?: { openai?: Array<{ id: string }> };
};
const openaiModels = settings?.modelProviders?.openai;
if (!Array.isArray(openaiModels)) return [];
return openaiModels.map((m) => ({ id: m.id, label: m.id }));
} catch {
return [];
}
}

View File

@@ -0,0 +1,30 @@
/**
* Adapts BooCoder write tools (which take ToolContext) into BooChat's ToolDef
* interface (which takes `projectRoot, extraRoots?`).
*
* The adapter reads the module-level inference context at execute time, so the
* wrapping happens at boot (static) — no per-inference re-wrap needed.
*/
import type { ToolDef as ServerToolDef } from '@boocode/server/tools';
import type { ToolDef, ToolContext } from './types.js';
import { getInferenceContext } from './inference_context.js';
/**
* Wrap a BooCoder write tool (execute takes ToolContext) into a BooChat
* ToolDef (execute takes projectRoot + optional extraRoots). The adapter
* builds the ToolContext from the module-level inference context at call time.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function adaptWriteTool(tool: ToolDef<any>): ServerToolDef<any> {
return {
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
jsonSchema: tool.jsonSchema,
async execute(input: unknown, projectRoot: string, _extraRoots?: readonly string[]): Promise<unknown> {
const ctx: ToolContext = getInferenceContext();
return tool.execute(input, projectRoot, ctx);
},
};
}

View File

@@ -0,0 +1,44 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { applyAll } from '../pending_changes.js';
const ApplyPendingInput = z.object({});
type ApplyPendingInputT = z.infer<typeof ApplyPendingInput>;
export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
name: 'apply_pending',
description:
'Apply all pending changes for the current session to disk. ' +
'Each queued create/edit/delete is executed in order.',
inputSchema: ApplyPendingInput,
jsonSchema: {
type: 'function',
function: {
name: 'apply_pending',
description:
'Apply all pending changes for the current session to disk. ' +
'Each queued create/edit/delete is executed in order.',
parameters: {
type: 'object',
properties: {},
required: [],
},
},
},
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
const results = await applyAll(context.sql, context.sessionId, projectRoot);
const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
return {
total: results.length,
succeeded,
failed,
results,
message:
results.length === 0
? 'No pending changes to apply.'
: `Applied ${succeeded}/${results.length} changes.${failed > 0 ? ` ${failed} failed.` : ''}`,
};
},
};

View File

@@ -0,0 +1,50 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
const CheckTaskStatusInput = z.object({
task_id: z.string().uuid().describe('ID of the task to check'),
});
type CheckTaskStatusInputT = z.infer<typeof CheckTaskStatusInput>;
export const checkTaskStatusTool: ToolDef<CheckTaskStatusInputT> = {
name: 'check_task_status',
description: 'Check the status and output of a subtask by ID. Returns state, output_summary, and timing.',
inputSchema: CheckTaskStatusInput,
jsonSchema: {
type: 'function',
function: {
name: 'check_task_status',
description: 'Check the status and output of a subtask by ID.',
parameters: {
type: 'object',
properties: {
task_id: { type: 'string', description: 'ID of the task to check' },
},
required: ['task_id'],
},
},
},
async execute(input: CheckTaskStatusInputT, _projectRoot: string, context: ToolContext): Promise<unknown> {
const { sql } = context;
const [task] = await sql<{ id: string; state: string; output_summary: string | null; started_at: string | null; ended_at: string | null }[]>`
SELECT id, state, output_summary, started_at, ended_at
FROM tasks
WHERE id = ${input.task_id}
`;
if (!task) {
return { error: `Task ${input.task_id} not found` };
}
return {
id: task.id,
state: task.state,
output_summary: task.output_summary,
started_at: task.started_at,
ended_at: task.ended_at,
};
},
};

View File

@@ -0,0 +1,51 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { queueCreate } from '../pending_changes.js';
const CreateFileInput = z.object({
file_path: z.string().min(1),
content: z.string(),
});
type CreateFileInputT = z.infer<typeof CreateFileInput>;
export const createFileTool: ToolDef<CreateFileInputT> = {
name: 'create_file',
description:
'Queue creation of a new file with the given content. ' +
'The change is staged in pending_changes and must be applied explicitly.',
inputSchema: CreateFileInput,
jsonSchema: {
type: 'function',
function: {
name: 'create_file',
description:
'Queue creation of a new file with the given content. ' +
'The change is staged in pending_changes and must be applied explicitly.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path for the new file (relative to project root or absolute)' },
content: { type: 'string', description: 'Full content of the file to create' },
},
required: ['file_path', 'content'],
},
},
},
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
const change = await queueCreate(
context.sql,
context.sessionId,
context.taskId,
input.file_path,
input.content,
projectRoot,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: 'create',
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
};
},
};

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { queueDelete } from '../pending_changes.js';
const DeleteFileInput = z.object({
file_path: z.string().min(1),
});
type DeleteFileInputT = z.infer<typeof DeleteFileInput>;
export const deleteFileTool: ToolDef<DeleteFileInputT> = {
name: 'delete_file',
description:
'Queue deletion of a file. ' +
'The change is staged in pending_changes and must be applied explicitly.',
inputSchema: DeleteFileInput,
jsonSchema: {
type: 'function',
function: {
name: 'delete_file',
description:
'Queue deletion of a file. ' +
'The change is staged in pending_changes and must be applied explicitly.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file to delete (relative to project root or absolute)' },
},
required: ['file_path'],
},
},
},
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
const change = await queueDelete(
context.sql,
context.sessionId,
context.taskId,
input.file_path,
projectRoot,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: 'delete',
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
};
},
};

View File

@@ -0,0 +1,54 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { queueEdit } from '../pending_changes.js';
const EditFileInput = z.object({
file_path: z.string().min(1),
old_string: z.string().min(1),
new_string: z.string(),
});
type EditFileInputT = z.infer<typeof EditFileInput>;
export const editFileTool: ToolDef<EditFileInputT> = {
name: 'edit_file',
description:
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
'The change is staged in pending_changes and must be applied explicitly.',
inputSchema: EditFileInput,
jsonSchema: {
type: 'function',
function: {
name: 'edit_file',
description:
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
'The change is staged in pending_changes and must be applied explicitly.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file to edit (relative to project root or absolute)' },
old_string: { type: 'string', description: 'The exact string to find and replace (must appear in the file)' },
new_string: { type: 'string', description: 'The replacement string' },
},
required: ['file_path', 'old_string', 'new_string'],
},
},
},
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
const change = await queueEdit(
context.sql,
context.sessionId,
context.taskId,
input.file_path,
input.old_string,
input.new_string,
projectRoot,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: 'edit',
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
};
},
};

View File

@@ -0,0 +1,34 @@
import type { ToolDef } from './types.js';
import { editFileTool } from './edit_file.js';
import { createFileTool } from './create_file.js';
import { deleteFileTool } from './delete_file.js';
import { applyPendingTool } from './apply_pending.js';
import { rewindTool } from './rewind.js';
import { newTaskTool } from './new_task.js';
import { listTasksTool } from './list_tasks.js';
import { checkTaskStatusTool } from './check_task_status.js';
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
// All BooCoder write tools. The inference loop (Phase 2B) will combine these
// with BooChat's read-only tools to form the full tool set available to agents.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const WRITE_TOOLS: readonly ToolDef<any>[] = [
applyPendingTool,
createFileTool,
deleteFileTool,
editFileTool,
rewindTool,
// Boomerang subtask tools — orchestrator agents call these to spawn/monitor child tasks.
// An "Orchestrator" agent profile would whitelist [new_task, list_tasks, check_task_status].
newTaskTool,
listTasksTool,
checkTaskStatusTool,
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const WRITE_TOOLS_BY_NAME: ReadonlyMap<string, ToolDef<any>> = new Map(
WRITE_TOOLS.map((t) => [t.name, t]),
);
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool };

View File

@@ -0,0 +1,36 @@
import type { Sql } from '../../db.js';
/**
* Module-level inference context for write tools.
*
* Set via `setInferenceContext()` before each inference run starts.
* Write tools read it via `getInferenceContext()` during execute.
* Same pattern as BooChat's `loadConfig()` singleton — tools need
* ambient state that can't be threaded through the tool-phase execute
* signature (which is `execute(input, projectRoot, extraRoots?)`).
*/
export interface InferenceContext {
sql: Sql;
sessionId: string;
taskId: string | null;
}
let current: InferenceContext | null = null;
export function setInferenceContext(ctx: InferenceContext): void {
current = ctx;
}
export function clearInferenceContext(): void {
current = null;
}
export function getInferenceContext(): InferenceContext {
if (!current) {
throw new Error(
'Write tool called outside inference context — setInferenceContext() was not called before this run',
);
}
return current;
}

View File

@@ -0,0 +1,56 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { getInferenceContext } from './inference_context.js';
const ListTasksInput = z.object({
parent_task_id: z.string().uuid().optional().describe('Filter by parent task ID. Omit to list children of current task.'),
});
type ListTasksInputT = z.infer<typeof ListTasksInput>;
export const listTasksTool: ToolDef<ListTasksInputT> = {
name: 'list_tasks',
description: 'List child tasks of the current task (or a specified parent). Returns id, state, input preview, and output_summary.',
inputSchema: ListTasksInput,
jsonSchema: {
type: 'function',
function: {
name: 'list_tasks',
description: 'List child tasks of the current task (or a specified parent).',
parameters: {
type: 'object',
properties: {
parent_task_id: { type: 'string', description: 'Filter by parent task ID. Omit to list children of current task.' },
},
required: [],
},
},
},
async execute(input: ListTasksInputT, _projectRoot: string, context: ToolContext): Promise<unknown> {
const { sql } = context;
const ctx = getInferenceContext();
const parentId = input.parent_task_id ?? ctx.taskId;
if (!parentId) {
return { tasks: [], note: 'No parent task context — not running inside a task.' };
}
const rows = await sql<{ id: string; state: string; input: string; output_summary: string | null }[]>`
SELECT id, state, input, output_summary
FROM tasks
WHERE parent_task_id = ${parentId}
ORDER BY created_at DESC
LIMIT 50
`;
return {
tasks: rows.map((r) => ({
id: r.id,
state: r.state,
input_preview: r.input.slice(0, 100),
output_summary: r.output_summary,
})),
};
},
};

View File

@@ -0,0 +1,65 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { getInferenceContext } from './inference_context.js';
const NewTaskInput = z.object({
input: z.string().min(1).describe('Task description for the child subtask'),
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
model: z.string().optional().describe('Optional: model override for the subtask'),
});
type NewTaskInputT = z.infer<typeof NewTaskInput>;
export const newTaskTool: ToolDef<NewTaskInputT> = {
name: 'new_task',
description:
'Spawn a subtask that runs in isolation. The subtask gets its own session and ' +
'worktree. Use check_task_status to monitor progress. Only the output_summary is ' +
'accessible to the parent — full isolation (Boomerang pattern).',
inputSchema: NewTaskInput,
jsonSchema: {
type: 'function',
function: {
name: 'new_task',
description:
'Spawn a subtask that runs in isolation. The subtask gets its own session and ' +
'worktree. Use check_task_status to monitor progress.',
parameters: {
type: 'object',
properties: {
input: { type: 'string', description: 'Task description for the child subtask' },
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
model: { type: 'string', description: 'Optional: model override for the subtask' },
},
required: ['input'],
},
},
},
async execute(input: NewTaskInputT, _projectRoot: string, context: ToolContext): Promise<unknown> {
const { sql } = context;
// Get the current task's project_id from the inference context
const ctx = getInferenceContext();
const currentTaskId = ctx.taskId;
// Look up the project_id from the current session
const [session] = await sql<{ project_id: string }[]>`
SELECT project_id FROM sessions WHERE id = ${ctx.sessionId}
`;
if (!session) {
return { error: 'Cannot determine project_id from current session' };
}
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
RETURNING id, state
`;
return {
message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
task_id: task!.id,
state: task!.state,
};
},
};

View File

@@ -0,0 +1,71 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { rewindOne } from '../pending_changes.js';
const RewindInput = z.object({
change_id: z.string().uuid().optional(),
all: z.boolean().optional(),
});
type RewindInputT = z.infer<typeof RewindInput>;
export const rewindTool: ToolDef<RewindInputT> = {
name: 'rewind',
description:
'Revert applied changes. Provide change_id to revert a specific change, ' +
'or set all=true to revert all applied changes for the session (in reverse order).',
inputSchema: RewindInput,
jsonSchema: {
type: 'function',
function: {
name: 'rewind',
description:
'Revert applied changes. Provide change_id to revert a specific change, ' +
'or set all=true to revert all applied changes for the session (in reverse order).',
parameters: {
type: 'object',
properties: {
change_id: { type: 'string', format: 'uuid', description: 'ID of a specific change to revert' },
all: { type: 'boolean', description: 'If true, revert all applied changes for this session' },
},
required: [],
},
},
},
async execute(input: RewindInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (input.change_id) {
const result = await rewindOne(context.sql, input.change_id, projectRoot);
return {
results: [result],
message: result.success
? `Reverted change ${input.change_id} (${result.operation} on ${result.file_path}).`
: `Failed to revert: ${result.error}`,
};
}
if (input.all) {
// Rewind all applied changes for this session in reverse order
const applied = await context.sql<{ id: string }[]>`
SELECT id FROM pending_changes
WHERE session_id = ${context.sessionId} AND status = 'applied'
ORDER BY created_at DESC
`;
const results = [];
for (const row of applied) {
results.push(await rewindOne(context.sql, row.id, projectRoot));
}
const succeeded = results.filter((r) => r.success).length;
return {
total: results.length,
succeeded,
failed: results.length - succeeded,
results,
message:
results.length === 0
? 'No applied changes to revert.'
: `Reverted ${succeeded}/${results.length} changes.`,
};
}
return { error: 'Provide either change_id or all=true.' };
},
};

View File

@@ -0,0 +1,32 @@
import type { z } from 'zod';
import type { Sql } from '../../db.js';
export interface ToolJsonSchema {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
/**
* Context passed to BooCoder tool execute functions.
*
* Unlike BooChat's tools (which only need projectRoot), BooCoder's write tools
* interact with the database (pending_changes table) and need session/task
* context for proper attribution.
*/
export interface ToolContext {
sql: Sql;
sessionId: string;
taskId: string | null;
}
export interface ToolDef<TInput> {
name: string;
description: string;
inputSchema: z.ZodType<TInput>;
jsonSchema: ToolJsonSchema;
execute(input: TInput, projectRoot: string, context: ToolContext): Promise<unknown>;
}

View File

@@ -0,0 +1,118 @@
/**
* Git worktree management for external agent dispatch.
*
* Each dispatched task gets its own git worktree so the external agent
* can modify files freely without touching the main working tree.
* After the agent completes, we diff the worktree against HEAD and
* queue the diff into pending_changes.
*/
import { hostExec } from './host-exec.js';
const WORKTREE_BASE = '/tmp/booworktrees';
/**
* Create a git worktree for a task on the host.
* Returns the absolute path to the worktree directory.
*/
export async function createWorktree(
projectPath: string,
taskId: string,
opts?: { signal?: AbortSignal },
): Promise<string> {
const worktreePath = `${WORKTREE_BASE}/${taskId}`;
const branchName = `task-${taskId}`;
// Ensure the base directory exists
await hostExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
// Create the worktree with a new branch from HEAD
const result = await hostExec(
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
{ signal: opts?.signal, timeoutMs: 30_000 },
);
if (result.exitCode !== 0) {
throw new Error(`Failed to create worktree: ${result.stderr.trim() || result.stdout.trim()}`);
}
return worktreePath;
}
/**
* Get the unified diff of changes made in the worktree vs the parent branch (HEAD).
* Returns an empty string if there are no changes.
*/
export async function diffWorktree(
worktreePath: string,
projectPath: string,
opts?: { signal?: AbortSignal },
): Promise<string> {
// First, commit any uncommitted changes in the worktree so we can diff branches
// Stage all changes
const addResult = await hostExec(
`cd ${shellEscape(worktreePath)} && git add -A`,
{ signal: opts?.signal, timeoutMs: 30_000 },
);
if (addResult.exitCode !== 0) {
throw new Error(`Failed to stage worktree changes: ${addResult.stderr.trim()}`);
}
// Check if there are staged changes
const statusResult = await hostExec(
`cd ${shellEscape(worktreePath)} && git diff --cached --quiet`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
if (statusResult.exitCode === 0) {
// No changes
return '';
}
// Commit staged changes (needed to produce a clean branch diff)
await hostExec(
`cd ${shellEscape(worktreePath)} && git -c user.email=boocoder@local -c user.name=BooCoder commit -m "task changes" --allow-empty`,
{ signal: opts?.signal, timeoutMs: 15_000 },
);
// Diff the worktree branch against the parent commit (HEAD of main tree)
const diffResult = await hostExec(
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
{ signal: opts?.signal, timeoutMs: 60_000 },
);
if (diffResult.exitCode !== 0) {
throw new Error(`Failed to diff worktree: ${diffResult.stderr.trim()}`);
}
return diffResult.stdout;
}
/**
* Remove a worktree and its associated branch.
* Best-effort — does not throw on failure (task may have already been cleaned up).
*/
export async function cleanupWorktree(
projectPath: string,
taskId: string,
): Promise<void> {
const worktreePath = `${WORKTREE_BASE}/${taskId}`;
const branchName = `task-${taskId}`;
// Remove the worktree (--force handles dirty state)
await hostExec(
`git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`,
{ timeoutMs: 15_000 },
).catch(() => {});
// Delete the task branch
await hostExec(
`git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`,
{ timeoutMs: 10_000 },
).catch(() => {});
}
/** Minimal shell escape for paths (single-quote wrapping). */
function shellEscape(s: string): string {
// Replace single quotes with escaped version, wrap in single quotes
return "'" + s.replace(/'/g, "'\\''") + "'";
}

View File

@@ -0,0 +1,77 @@
import { resolve, sep } from 'node:path';
export class WriteGuardError extends Error {
constructor(message: string) {
super(message);
this.name = 'WriteGuardError';
}
}
// Deny list: files that should never be written regardless of path-guard.
// Subset of BooChat's secret_guard.ts — covers the most dangerous patterns.
// Full parity with BooChat's deny list is not needed for write-guard because
// the write tools are intentional (model chose to create/edit); we block only
// files that are unambiguously secrets.
const SECRET_PATTERNS: readonly string[] = [
'.env',
'.env.local',
'.env.production',
'.env.development',
'.env.staging',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'*.pem',
'*.key',
'*.p12',
'*.pfx',
'*.crt',
'credentials.json',
'*.kdbx',
'.netrc',
];
export function isSecretPath(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/');
const segments = normalized.split('/').filter((s) => s.length > 0);
if (segments.length === 0) return false;
const basename = segments[segments.length - 1]!;
return SECRET_PATTERNS.some((pattern) => {
if (pattern.startsWith('*')) {
return basename.endsWith(pattern.slice(1));
}
return basename === pattern;
});
}
/**
* Resolve and validate a write target path.
*
* Key difference from BooChat's pathGuard: no realpath() — the file may not
* exist yet (creates). Uses resolve() to normalize ../ segments and then
* checks the result stays within projectRoot.
*/
export function resolveWritePath(projectRoot: string, filePath: string): string {
if (!filePath || filePath.trim().length === 0) {
throw new WriteGuardError('file path is required');
}
if (filePath.includes('\x00')) {
throw new WriteGuardError('file path contains null byte');
}
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
const normalized = resolve(candidate); // normalizes ../ segments
if (!normalized.startsWith(projectRoot + sep) && normalized !== projectRoot) {
throw new WriteGuardError(`path escapes project root: ${filePath}`);
}
if (isSecretPath(normalized)) {
throw new WriteGuardError(`cannot write to secret file: ${filePath}`);
}
return normalized;
}

15
apps/coder/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022"],
"types": ["node"],
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: false,
include: ['src/**/__tests__/**/*.test.ts'],
},
});

12
apps/coder/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BooCoder</title>
</head>
<body class="bg-zinc-900 text-zinc-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{
"name": "@boocode/coder-web",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --noEmit",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^1.16.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"tailwindcss": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.3.4"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

View File

@@ -0,0 +1,13 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { Home } from './pages/Home';
import { Session } from './pages/Session';
export function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/sessions/:sessionId" element={<Session />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -0,0 +1,101 @@
import type { Project, Session, Chat, Message, PendingChange, AskUserAnswer } from './types';
export class ApiError extends Error {
constructor(
public status: number,
public body: unknown,
) {
super(
typeof body === 'object' && body && 'error' in body
? String((body as { error: unknown }).error)
: `HTTP ${status}`,
);
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
...init,
headers: {
'Content-Type': 'application/json',
...(init.headers ?? {}),
},
});
if (res.status === 204) return undefined as T;
const text = await res.text();
const data = text ? JSON.parse(text) : undefined;
if (!res.ok) throw new ApiError(res.status, data);
return data as T;
}
export const api = {
health: () => request<{ ok: boolean; db: boolean; tools: number }>('/api/health'),
projects: {
list: (params?: { status?: 'open' | 'archived' }) =>
request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
},
sessions: {
listForProject: (projectId: string, status?: 'open' | 'archived') =>
request<Session[]>(
`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`,
),
get: (id: string) => request<Session>(`/api/sessions/${id}`),
},
chats: {
listForSession: (sessionId: string) =>
request<Chat[]>(`/api/sessions/${sessionId}/chats`),
create: (sessionId: string, body?: { name?: string }) =>
request<Chat>(`/api/sessions/${sessionId}/chats`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
request<{ tool_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/answer_user_input`,
{
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
},
),
},
messages: {
send: (sessionId: string, chatId: string, content: string) =>
request<{ user_message_id: string; assistant_message_id: string }>(
`/api/sessions/${sessionId}/messages`,
{
method: 'POST',
body: JSON.stringify({ content, chat_id: chatId }),
},
),
stop: (sessionId: string) =>
request<{ cancelled: boolean }>(`/api/sessions/${sessionId}/stop`, {
method: 'POST',
}),
},
pending: {
list: (sessionId: string) =>
request<PendingChange[]>(`/api/sessions/${sessionId}/pending`),
applyAll: (sessionId: string) =>
request<{ results: Array<{ id: string; success: boolean; error?: string }> }>(
`/api/sessions/${sessionId}/pending/apply`,
{ method: 'POST' },
),
applyOne: (changeId: string) =>
request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/apply`, {
method: 'POST',
}),
rejectOne: (changeId: string) =>
request<{ ok: boolean }>(`/api/pending/${changeId}/reject`, {
method: 'POST',
}),
rewindOne: (changeId: string) =>
request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/rewind`, {
method: 'POST',
}),
},
};

View File

@@ -0,0 +1,110 @@
// Minimal types for the BooCoder frontend.
// Shared DB entities (same schema as BooChat).
export interface Project {
id: string;
name: string;
path: string;
status: 'open' | 'archived';
created_at: string;
updated_at: string;
}
export interface Session {
id: string;
project_id: string;
name: string | null;
model: string | null;
status: 'open' | 'archived';
created_at: string;
updated_at: string;
}
export interface Chat {
id: string;
session_id: string;
name: string | null;
status: 'open' | 'archived';
created_at: string;
updated_at: string;
}
export interface ToolCall {
id: string;
name: string;
args: unknown;
}
export interface ToolResult {
tool_call_id: string;
output: unknown;
truncated?: boolean;
error?: boolean;
}
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
// same order. AskUserInputCard renders questions and POSTs answers.
export type AskUserQuestionType = 'single_select' | 'multi_select';
export interface AskUserQuestion {
question: string;
type: AskUserQuestionType;
options: string[];
}
export interface AskUserAnswer {
question: string;
selected_options: string[];
free_text: string | null;
}
export interface AskUserAnswerSet {
answers: AskUserAnswer[];
}
export interface Message {
id: string;
session_id: string;
chat_id: string;
role: 'user' | 'assistant' | 'tool' | 'system';
content: string;
kind: string;
tool_calls: ToolCall[] | null;
tool_results: ToolResult | null;
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
tokens_used: number | null;
ctx_used: number | null;
ctx_max: number | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
metadata: unknown;
}
export interface PendingChange {
id: string;
session_id: string;
task_id: string | null;
file_path: string;
operation: 'create' | 'edit' | 'delete';
old_string: string | null;
new_string: string | null;
content: string | null;
diff: string | null;
status: 'pending' | 'applied' | 'rejected' | 'reverted';
created_at: string;
applied_at: string | null;
}
// WebSocket frame types (subset of what the coder backend publishes)
export type WsFrame =
| { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; chat_id: string; role: Message['role'] }
| { type: 'delta'; message_id: string; chat_id: string; content: string }
| { type: 'tool_call'; message_id: string; chat_id: string; tool_call: ToolCall }
| { type: 'tool_result'; tool_message_id: string; chat_id: string; tool_call_id: string; output: string; truncated?: boolean; error?: boolean }
| { type: 'message_complete'; message_id: string; chat_id: string; tokens_used?: number; ctx_used?: number; ctx_max?: number; started_at?: string; finished_at?: string; metadata?: unknown }
| { type: 'error'; message_id?: string; error: string; reason?: string }
| { type: 'pending_change_added'; change: PendingChange }
| { type: 'pending_change_updated'; change: PendingChange };

View File

@@ -0,0 +1,323 @@
import { useMemo, useState } from 'react';
import { Check } from 'lucide-react';
import { api } from '@/api/client';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import type {
AskUserAnswer,
AskUserAnswerSet,
AskUserQuestion,
ToolCall,
ToolResult,
} from '@/api/types';
// Batch 9.7: Inline interactive picker. Renders inside MessageList in place of
// the standard ToolCallLine when the assistant emits an ask_user_input tool
// call. While the tool result is null (server pre-stamps a sentinel with
// output=null), shows the form; once the WS tool_result frame arrives with a
// real AnswerSet, flips to read-only review mode.
interface Props {
toolCall: ToolCall;
toolResult: ToolResult | null;
chatId: string;
}
function parseQuestions(raw: unknown): AskUserQuestion[] {
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
const arr = (raw as { questions: unknown }).questions;
if (!Array.isArray(arr)) return [];
const out: AskUserQuestion[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const q = item as { question?: unknown; type?: unknown; options?: unknown };
if (typeof q.question !== 'string') continue;
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
if (!Array.isArray(q.options)) continue;
const opts = q.options.filter((o): o is string => typeof o === 'string');
if (opts.length < 2) continue;
out.push({ question: q.question, type: q.type, options: opts });
}
return out;
}
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
const arr = (raw as { answers: unknown }).answers;
if (!Array.isArray(arr)) return null;
const answers: AskUserAnswer[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
if (typeof a.question !== 'string') continue;
if (!Array.isArray(a.selected_options)) continue;
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
answers.push({
question: a.question,
selected_options: sel,
free_text: (a.free_text as string | null) ?? null,
});
}
return { answers };
}
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
if (questions.length === 0) {
return (
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
ask_user_input: malformed tool args
</div>
);
}
// Tool result with a non-null output means the answer is already submitted.
// The pending sentinel uses output=null, so this branch only triggers after
// the real WS tool_result frame lands.
const answered = toolResult && toolResult.output !== null;
if (answered) {
const answerSet = parseAnswerSet(toolResult!.output);
return <AnsweredView questions={questions} answers={answerSet} />;
}
return (
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
);
}
function PendingView({
questions,
toolCallId,
chatId,
}: {
questions: AskUserQuestion[];
toolCallId: string;
chatId: string;
}) {
// Per-question selections + free text. Selections are option arrays so the
// multi_select case is uniform; single_select just constrains to length 1.
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
const [submitting, setSubmitting] = useState(false);
const singleQuestion = questions.length === 1;
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
// Submit button shows when:
// - more than one question (always batched), OR
// - one question and the user has typed free text (committing it needs an
// explicit Submit so an accidental Tab/click doesn't lose it).
// For one question with no free text, clicking an option submits inline.
const showSubmitButton = !singleQuestion || anyFreeText;
// Every question must have at least one of (option, free text).
const allComplete = questions.every((_, i) => {
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
});
function buildAnswers(): AskUserAnswer[] {
return questions.map((q, i) => {
const freeText = freeTexts[i]!.trim();
return {
question: q.question,
selected_options: selections[i]!,
free_text: freeText.length > 0 ? freeText : null,
};
});
}
async function submit(answers: AskUserAnswer[]) {
if (submitting) return;
setSubmitting(true);
try {
await api.chats.answerUserInput(chatId, toolCallId, answers);
// Card stays mounted; the incoming WS tool_result frame will flip it
// into AnsweredView via the parent prop change.
} catch (err) {
console.error('ask_user_input submit failed:', err instanceof Error ? err.message : err);
setSubmitting(false);
}
}
function pickSingle(qIdx: number, option: string) {
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
// Immediate submit for the single-question single-select shortcut. Only
// fires when no free text exists anywhere — once the user typed, the
// Submit button takes over so the typed text isn't silently dropped.
if (singleQuestion && !anyFreeText) {
const answers: AskUserAnswer[] = [
{
question: questions[0]!.question,
selected_options: [option],
free_text: null,
},
];
void submit(answers);
}
}
function toggleMulti(qIdx: number, option: string) {
setSelections((prev) =>
prev.map((arr, i) => {
if (i !== qIdx) return arr;
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
}),
);
}
function setFreeText(qIdx: number, value: string) {
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
}
return (
<div className="rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-4">
{questions.map((q, i) => (
<div key={i} className="space-y-2">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.type === 'single_select' ? (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={submitting}
className="gap-1.5"
>
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
) : (
<div className="grid gap-1.5">
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
const checked = selections[i]!.includes(opt);
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={submitting}
onChange={() => toggleMulti(i, opt)}
className="mt-1 size-3.5 rounded border-input accent-primary"
/>
<span>{opt}</span>
</label>
);
})}
</div>
)}
<div className="pt-1 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Or type a custom answer
</div>
<input
type="text"
value={freeTexts[i]}
disabled={submitting}
placeholder="Free text…"
onChange={(e) => setFreeText(i, e.target.value)}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
</div>
</div>
))}
</div>
{showSubmitButton && (
<div className="flex justify-end gap-2 border-t px-4 py-2">
<Button
type="button"
size="sm"
disabled={!allComplete || submitting}
onClick={() => void submit(buildAnswers())}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
function AnsweredView({
questions,
answers,
}: {
questions: AskUserQuestion[];
answers: AskUserAnswerSet | null;
}) {
if (!answers) {
return (
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
ask_user_input: answers unavailable
</div>
);
}
return (
<div className="rounded-lg border bg-muted/10 text-sm">
<div className="px-4 py-3 space-y-3">
{questions.map((q, i) => {
const a = answers.answers[i];
if (!a) return null;
return (
<div key={i} className="space-y-1.5">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
<div className="space-y-0.5">
{q.options.map((opt, j) => {
const selected = a.selected_options.includes(opt);
return (
<div
key={j}
className={
selected
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
}
>
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
{selected && <Check className="size-3 text-primary" />}
</span>
<span>{opt}</span>
</div>
);
})}
</div>
{a.free_text && (
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
{a.free_text}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Square } from 'lucide-react';
import type { Message, ToolResult } from '@/api/types';
import { api } from '@/api/client';
import { MessageBubble } from './MessageBubble';
interface Props {
sessionId: string;
chatId: string;
messages: Message[];
isStreaming: boolean;
connected: boolean;
}
export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }: Props) {
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}, [input]);
const handleSend = async () => {
const content = input.trim();
if (!content || sending || isStreaming) return;
setInput('');
setSending(true);
try {
await api.messages.send(sessionId, chatId, content);
} catch (err) {
console.error('send failed:', err);
// Restore input on failure
setInput(content);
} finally {
setSending(false);
}
};
const handleStop = async () => {
try {
await api.messages.stop(sessionId);
} catch (err) {
console.error('stop failed:', err);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Filter out system messages for display (sentinels)
const visibleMessages = messages.filter((m) => m.role !== 'system');
// Build a lookup map from tool_call_id -> ToolResult for all messages
const toolResultsMap: Record<string, ToolResult> = {};
for (const msg of messages) {
if (msg.tool_results) {
toolResultsMap[msg.tool_results.tool_call_id] = msg.tool_results;
}
}
return (
<div className="flex flex-col h-full">
{/* Connection indicator */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800 text-xs text-zinc-500">
<div
className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
/>
<span>{connected ? 'Connected' : 'Disconnected'}</span>
{isStreaming && (
<span className="text-blue-400 ml-auto">Generating...</span>
)}
</div>
{/* Messages list */}
<div className="flex-1 overflow-y-auto px-4 py-4">
{visibleMessages.length === 0 && (
<div className="text-center text-zinc-500 mt-8">
<p className="text-lg font-medium">BooCoder</p>
<p className="text-sm mt-1">Send a message to start coding.</p>
</div>
)}
{visibleMessages.map((msg) => (
<MessageBubble key={msg.id} message={msg} chatId={msg.chat_id} toolResultsMap={toolResultsMap} />
))}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="border-t border-zinc-800 px-4 py-3">
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message BooCoder..."
rows={1}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
disabled={sending}
/>
{isStreaming ? (
<button
onClick={handleStop}
className="p-2 rounded-lg bg-red-600 hover:bg-red-500 text-white transition-colors"
title="Stop generation"
>
<Square size={18} />
</button>
) : (
<button
onClick={handleSend}
disabled={!input.trim() || sending}
className="p-2 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white transition-colors"
title="Send message"
>
<Send size={18} />
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,352 @@
import { useState, useEffect, useCallback } from 'react';
import { Check, X, RotateCcw, FileText, FilePlus, Trash2, RefreshCw } from 'lucide-react';
import type { PendingChange } from '@/api/types';
import { api } from '@/api/client';
interface Props {
sessionId: string;
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
}
export function DiffPane({ sessionId, onPendingChange }: Props) {
const [changes, setChanges] = useState<PendingChange[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const fetchPending = useCallback(async () => {
try {
const result = await api.pending.list(sessionId);
setChanges(result);
} catch (err) {
console.error('fetch pending failed:', err);
} finally {
setLoading(false);
}
}, [sessionId]);
// Initial load
useEffect(() => {
fetchPending();
}, [fetchPending]);
// Listen for WS pending change events
useEffect(() => {
const unsub = onPendingChange((change) => {
setChanges((prev) => {
const idx = prev.findIndex((c) => c.id === change.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = change;
return next;
}
return [...prev, change];
});
});
return unsub;
}, [onPendingChange]);
const pendingChanges = changes.filter((c) => c.status === 'pending');
const resolvedChanges = changes.filter((c) => c.status !== 'pending');
const handleApplyOne = async (id: string) => {
try {
await api.pending.applyOne(id);
setChanges((prev) =>
prev.map((c) => (c.id === id ? { ...c, status: 'applied' as const } : c)),
);
} catch (err) {
console.error('apply failed:', err);
}
};
const handleRejectOne = async (id: string) => {
try {
await api.pending.rejectOne(id);
setChanges((prev) =>
prev.map((c) => (c.id === id ? { ...c, status: 'rejected' as const } : c)),
);
} catch (err) {
console.error('reject failed:', err);
}
};
const handleRewindOne = async (id: string) => {
try {
await api.pending.rewindOne(id);
setChanges((prev) =>
prev.map((c) => (c.id === id ? { ...c, status: 'reverted' as const } : c)),
);
} catch (err) {
console.error('rewind failed:', err);
}
};
const handleApplyAll = async () => {
try {
const result = await api.pending.applyAll(sessionId);
const appliedIds = new Set(
result.results.filter((r) => r.success).map((r) => r.id),
);
setChanges((prev) =>
prev.map((c) =>
appliedIds.has(c.id) ? { ...c, status: 'applied' as const } : c,
),
);
} catch (err) {
console.error('apply all failed:', err);
}
};
const handleRejectAll = async () => {
// Reject each pending change individually (no batch reject endpoint)
for (const c of pendingChanges) {
await handleRejectOne(c.id);
}
};
const OpIcon = ({ op }: { op: PendingChange['operation'] }) => {
switch (op) {
case 'create':
return <FilePlus size={14} className="text-green-400" />;
case 'edit':
return <FileText size={14} className="text-blue-400" />;
case 'delete':
return <Trash2 size={14} className="text-red-400" />;
}
};
const StatusBadge = ({ status }: { status: PendingChange['status'] }) => {
const colors: Record<PendingChange['status'], string> = {
pending: 'bg-yellow-500/20 text-yellow-400',
applied: 'bg-green-500/20 text-green-400',
rejected: 'bg-zinc-500/20 text-zinc-400',
reverted: 'bg-orange-500/20 text-orange-400',
};
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colors[status]}`}>
{status}
</span>
);
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
<h2 className="text-sm font-medium text-zinc-300">
Pending Changes
{pendingChanges.length > 0 && (
<span className="ml-1.5 text-xs text-zinc-500">
({pendingChanges.length})
</span>
)}
</h2>
<div className="flex items-center gap-1">
<button
onClick={fetchPending}
className="p-1 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200"
title="Refresh"
>
<RefreshCw size={14} />
</button>
{pendingChanges.length > 0 && (
<>
<button
onClick={handleApplyAll}
className="text-xs px-2 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white"
>
Apply All
</button>
<button
onClick={handleRejectAll}
className="text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300"
>
Reject All
</button>
</>
)}
</div>
</div>
{/* Changes list */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="text-center text-zinc-500 text-sm py-8">Loading...</div>
)}
{!loading && changes.length === 0 && (
<div className="text-center text-zinc-500 text-sm py-8">
No pending changes yet.
</div>
)}
{/* Pending changes first */}
{pendingChanges.map((change) => (
<ChangeItem
key={change.id}
change={change}
expanded={expandedId === change.id}
onToggle={() =>
setExpandedId((prev) => (prev === change.id ? null : change.id))
}
onApply={() => handleApplyOne(change.id)}
onReject={() => handleRejectOne(change.id)}
OpIcon={OpIcon}
StatusBadge={StatusBadge}
/>
))}
{/* Resolved changes */}
{resolvedChanges.length > 0 && pendingChanges.length > 0 && (
<div className="border-t border-zinc-800 my-1" />
)}
{resolvedChanges.map((change) => (
<ChangeItem
key={change.id}
change={change}
expanded={expandedId === change.id}
onToggle={() =>
setExpandedId((prev) => (prev === change.id ? null : change.id))
}
onRewind={
change.status === 'applied'
? () => handleRewindOne(change.id)
: undefined
}
OpIcon={OpIcon}
StatusBadge={StatusBadge}
/>
))}
</div>
</div>
);
}
interface ChangeItemProps {
change: PendingChange;
expanded: boolean;
onToggle: () => void;
onApply?: () => void;
onReject?: () => void;
onRewind?: () => void;
OpIcon: React.ComponentType<{ op: PendingChange['operation'] }>;
StatusBadge: React.ComponentType<{ status: PendingChange['status'] }>;
}
function ChangeItem({
change,
expanded,
onToggle,
onApply,
onReject,
onRewind,
OpIcon,
StatusBadge,
}: ChangeItemProps) {
const fileName = change.file_path.split('/').pop() || change.file_path;
const dirPath = change.file_path.split('/').slice(0, -1).join('/');
return (
<div className="border-b border-zinc-800/50">
<div
className="flex items-center gap-2 px-4 py-2 hover:bg-zinc-800/50 cursor-pointer"
onClick={onToggle}
>
<OpIcon op={change.operation} />
<div className="flex-1 min-w-0">
<span className="text-sm font-mono text-zinc-200 truncate block">
{fileName}
</span>
{dirPath && (
<span className="text-[11px] text-zinc-500 truncate block">
{dirPath}
</span>
)}
</div>
<StatusBadge status={change.status} />
{change.status === 'pending' && (
<div className="flex items-center gap-1 ml-1">
<button
onClick={(e) => {
e.stopPropagation();
onApply?.();
}}
className="p-1 rounded hover:bg-green-600/30 text-green-400"
title="Apply"
>
<Check size={14} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onReject?.();
}}
className="p-1 rounded hover:bg-red-600/30 text-red-400"
title="Reject"
>
<X size={14} />
</button>
</div>
)}
{change.status === 'applied' && onRewind && (
<button
onClick={(e) => {
e.stopPropagation();
onRewind();
}}
className="p-1 rounded hover:bg-orange-600/30 text-orange-400"
title="Rewind"
>
<RotateCcw size={14} />
</button>
)}
</div>
{expanded && (
<div className="px-4 pb-3">
{change.operation === 'edit' && (
<div className="space-y-2">
{change.old_string && (
<div className="rounded bg-red-950/30 border border-red-900/30 p-2">
<div className="text-[10px] text-red-400 mb-1 font-medium">
Remove
</div>
<pre className="text-xs text-red-200 whitespace-pre-wrap break-all font-mono">
{change.old_string}
</pre>
</div>
)}
{change.new_string && (
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
<div className="text-[10px] text-green-400 mb-1 font-medium">
Add
</div>
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono">
{change.new_string}
</pre>
</div>
)}
</div>
)}
{change.operation === 'create' && change.content && (
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
<div className="text-[10px] text-green-400 mb-1 font-medium">
New file
</div>
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono max-h-60 overflow-y-auto">
{change.content.length > 2000
? change.content.slice(0, 2000) + '\n... (truncated)'
: change.content}
</pre>
</div>
)}
{change.operation === 'delete' && (
<div className="rounded bg-red-950/30 border border-red-900/30 p-2 text-xs text-red-300">
This file will be deleted.
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useState } from 'react';
import { Code2, MessageSquare, GitPullRequest } from 'lucide-react';
interface Props {
chatPane: React.ReactNode;
diffPane: React.ReactNode;
}
export function Layout({ chatPane, diffPane }: Props) {
const [activeTab, setActiveTab] = useState<'chat' | 'diff'>('chat');
return (
<div className="flex flex-col h-screen bg-zinc-900">
{/* Top bar */}
<header className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-900/95">
<Code2 size={20} className="text-blue-400" />
<h1 className="text-sm font-semibold text-zinc-200">BooCoder</h1>
</header>
{/* Mobile tab bar (visible below lg breakpoint) */}
<div className="lg:hidden flex border-b border-zinc-800">
<button
onClick={() => setActiveTab('chat')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
activeTab === 'chat'
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-zinc-500'
}`}
>
<MessageSquare size={14} />
Chat
</button>
<button
onClick={() => setActiveTab('diff')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
activeTab === 'diff'
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-zinc-500'
}`}
>
<GitPullRequest size={14} />
Changes
</button>
</div>
{/* Desktop split layout */}
<div className="flex-1 hidden lg:flex overflow-hidden">
<div className="w-[60%] border-r border-zinc-800 overflow-hidden">
{chatPane}
</div>
<div className="w-[40%] overflow-hidden">
{diffPane}
</div>
</div>
{/* Mobile: show only the active tab */}
<div className="flex-1 lg:hidden overflow-hidden">
{activeTab === 'chat' ? chatPane : diffPane}
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Message, ToolResult } from '@/api/types';
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
import { AskUserInputCard } from './AskUserInputCard';
interface Props {
message: Message;
chatId: string;
toolResultsMap: Record<string, ToolResult>;
}
export function MessageBubble({ message, chatId }: Props) {
if (message.role === 'tool') {
return <ToolResultBubble message={message} />;
}
const isUser = message.role === 'user';
const isStreaming = message.status === 'streaming';
const isFailed = message.status === 'failed';
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
<div
className={`max-w-[85%] rounded-lg px-4 py-2.5 ${
isUser
? 'bg-blue-600 text-white'
: 'bg-zinc-800 text-zinc-100 border border-zinc-700'
}`}
>
{isFailed && (
<div className="flex items-center gap-1.5 text-red-400 text-xs mb-1">
<AlertCircle size={12} />
<span>Failed</span>
</div>
)}
{message.tool_calls && message.tool_calls.length > 0 && (
<div className="mb-2 space-y-1">
{message.tool_calls.map((tc) => {
if (tc.name === 'ask_user_input') {
const result = message.tool_results ?? null;
return (
<AskUserInputCard
key={tc.id}
toolCall={tc}
toolResult={result}
chatId={chatId}
/>
);
}
return (
<div
key={tc.id}
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
>
<Wrench size={11} />
<span className="font-mono">{tc.name}</span>
<span className="text-zinc-500 truncate max-w-[200px]">
{truncateArgs(tc.args)}
</span>
</div>
);
})}
</div>
)}
{message.content.trim() && (
<div className="prose prose-invert prose-sm max-w-none [&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_pre]:overflow-x-auto [&_code]:text-zinc-300 [&_p]:my-1.5">
<Markdown remarkPlugins={[remarkGfm]}>{message.content}</Markdown>
</div>
)}
{isStreaming && !message.content.trim() && (
<div className="flex items-center gap-1.5 text-zinc-400">
<Loader2 size={14} className="animate-spin" />
<span className="text-xs">Thinking...</span>
</div>
)}
{isStreaming && message.content.trim() && (
<span className="inline-block w-1.5 h-4 bg-zinc-400 animate-pulse ml-0.5 align-middle" />
)}
</div>
</div>
);
}
function ToolResultBubble({ message }: { message: Message }) {
const result = message.tool_results;
if (!result) return null;
const isError = result.error;
const output = result.output != null ? String(result.output) : '';
const displayOutput =
output.length > 300 ? output.slice(0, 300) + '...' : output;
return (
<div className="flex justify-start mb-2 ml-6">
<div
className={`max-w-[80%] rounded px-3 py-2 text-xs font-mono border ${
isError
? 'bg-red-950/30 border-red-800/50 text-red-300'
: 'bg-zinc-800/50 border-zinc-700/50 text-zinc-400'
}`}
>
{result.truncated && (
<span className="text-yellow-500 text-[10px] block mb-1">
[truncated]
</span>
)}
<pre className="whitespace-pre-wrap break-all">{displayOutput}</pre>
</div>
</div>
);
}
function truncateArgs(args: unknown): string {
if (!args) return '';
try {
if (typeof args === 'object' && args !== null) {
const obj = args as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) return '';
const first = keys[0]!;
const val = String(obj[first] ?? '');
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
return `${first}: ${display}`;
}
const str = String(args);
return str.length > 50 ? str.slice(0, 50) + '...' : str;
} catch {
return String(args).length > 50 ? String(args).slice(0, 50) + '...' : String(args);
}
}

View File

@@ -0,0 +1,35 @@
import * as React from 'react';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
}
const variantClasses: Record<string, string> = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
};
const sizeClasses: Record<string, string> = {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
const base =
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-60';
const cls = [base, variantClasses[variant] ?? '', sizeClasses[size] ?? '', className ?? ''].join(' ');
return <button className={cls} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button };

Some files were not shown because too many files have changed in this diff Show More