Compare commits

...

8 Commits

Author SHA1 Message Date
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
44 changed files with 3107 additions and 81 deletions

View File

@@ -2,6 +2,14 @@
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. 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.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.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).
## v1.16.0-codesight-merge — 2026-05-24 ## 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. 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.

View File

@@ -66,9 +66,25 @@ Key services:
- **`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. - **`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/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. - **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
- **`services/provider-registry.ts`** — 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).
- **`services/agent-probe.ts`** — 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.
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
- **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`. 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`.
- 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.
### Frontend (`apps/web/src/`) ### Frontend (`apps/web/src/`)
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives. - **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
@@ -105,14 +121,20 @@ Sessions hold 15 panes (chat / empty / placeholder terminal+agent). v1.12.1 m
## Database ## 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`. 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 ## 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; dispatch via SSH). 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 ## Workflow
@@ -123,7 +145,7 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- 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). - Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`. - 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. - 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. 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`. - Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client. - 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). - 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).
@@ -134,6 +156,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- `/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. - `/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. - 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.template` documents recommended ignore patterns; users copy and adapt to project root manually.
- 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: `docker compose build --no-cache codecontext`.
- 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. - `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 ## Conventions
@@ -156,3 +180,6 @@ 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. - 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. - 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). - 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.

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

@@ -0,0 +1,14 @@
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

View File

@@ -23,7 +23,7 @@ RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
FROM node:20-bookworm-slim AS runtime FROM node:20-bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git openssh-client && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=builder /out/coder ./ COPY --from=builder /out/coder ./

View File

@@ -8,19 +8,24 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"", "build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
"start": "node dist/index.js", "start": "node dist/index.js",
"cli": "tsx src/cli.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.22.1",
"@boocode/server": "workspace:*", "@boocode/server": "workspace:*",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@fastify/websocket": "^10.0.1", "@fastify/websocket": "^10.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"ws": "^8.18.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/ws": "^8.5.10",
"tsx": "^4.16.2", "tsx": "^4.16.2",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"

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);
}

View File

@@ -23,6 +23,11 @@ const ConfigSchema = z.object({
GITEA_TOKEN: z.string().optional(), GITEA_TOKEN: z.string().optional(),
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'), GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
MCP_CONFIG_PATH: z.string().optional(), MCP_CONFIG_PATH: z.string().optional(),
// 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>; export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -9,6 +9,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
import { loadConfig } from './config.js'; import { loadConfig } from './config.js';
import { getSql, applySchema, pingDb, closeDb } from './db.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 // v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
// inference loop, broker, and tool registry without duplication. // inference loop, broker, and tool registry without duplication.
import { createInferenceRunner } from '@boocode/server/inference'; import { createInferenceRunner } from '@boocode/server/inference';
@@ -23,9 +24,26 @@ import { setInferenceContext, clearInferenceContext } from './services/tools/inf
// Routes // Routes
import { registerMessageRoutes } from './routes/messages.js'; import { registerMessageRoutes } from './routes/messages.js';
import { registerPendingRoutes } from './routes/pending.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'; import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js';
import { probeAgents } from './services/agent-probe.js';
async function main() { 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 config = loadConfig();
const app = Fastify({ const app = Fastify({
@@ -113,9 +131,22 @@ async function main() {
}); });
}); });
// Phase 4: probe available agents on startup
await probeAgents(sql, app.log);
// 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', () => dispatcher.stop());
// Register routes // Register routes
registerMessageRoutes(app, sql, broker, inferenceApi); registerMessageRoutes(app, sql, broker, inferenceApi);
registerPendingRoutes(app, sql); registerPendingRoutes(app, sql);
registerTaskRoutes(app, sql, inferenceApi);
registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql);
registerProviderRoutes(app, sql, config);
registerWebSocket(app, sql, broker); registerWebSocket(app, sql, broker);
// Serve static frontend (built web app). In production, the dist/ is // Serve static frontend (built web app). In production, the dist/ is

View File

@@ -0,0 +1,122 @@
/**
* 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(),
});
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;
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, arena_id)
VALUES (${project_id}, ${input}, ${contestant.agent ?? null}, ${contestant.model ?? null}, ${arenaId})
RETURNING id, agent, model, state
`;
tasks.push(task!);
}
reply.code(201);
return {
arena_id: arenaId,
tasks: tasks.map(t => ({
id: t.id,
agent: t.agent,
model: t.model,
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, 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,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

@@ -6,7 +6,9 @@ import type { WsFrame } from '@boocode/server/ws-frames';
const SendBody = z.object({ const SendBody = z.object({
content: z.string().min(1).max(64_000), content: z.string().min(1).max(64_000),
chat_id: z.string().uuid(), chat_id: z.string().uuid().optional(),
provider: z.string().max(100).optional(),
model: z.string().max(200).optional(),
}); });
interface InferenceApi { interface InferenceApi {
@@ -32,73 +34,104 @@ export function registerMessageRoutes(
} }
const sessionId = req.params.sessionId; const sessionId = req.params.sessionId;
const { content, chat_id: chatId } = parsed.data; const { content, chat_id: explicitChatId, provider, model } = parsed.data;
const isExternal = provider && provider !== 'boocode';
// Validate session exists // Validate session exists
const sessionRows = await sql<{ id: string }[]>` const sessionRows = await sql<{ id: string; project_id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId} SELECT id, project_id FROM sessions WHERE id = ${sessionId}
`; `;
if (sessionRows.length === 0) { if (sessionRows.length === 0) {
reply.code(404); reply.code(404);
return { error: 'session not found' }; return { error: 'session not found' };
} }
// Validate chat belongs to session and is open // Resolve chat_id: use explicit value or find/create a default chat
const chatRows = await sql<{ id: string; session_id: string }[]>` let chatId: string;
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open' 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) { if (chatRows.length === 0) {
reply.code(404); reply.code(404);
return { error: 'chat not found or not open in this session' }; return { error: 'chat not found or not open in this session' };
} }
chatId = explicitChatId;
} else {
const existing = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at LIMIT 1
`;
if (existing.length > 0) {
chatId = existing[0]!.id;
} else {
const [newChat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'Chat', 'open')
RETURNING id
`;
chatId = newChat!.id;
}
}
if (!isExternal) {
// Reject if inference is already running on this chat // Reject if inference is already running on this chat
if (inference.hasActive(chatId)) { if (inference.hasActive(chatId)) {
reply.code(409); reply.code(409);
return { error: 'inference already running on this chat' }; return { error: 'inference already running on this chat' };
} }
}
// Create user message + streaming assistant row in a transaction // Create user message
const result = await sql.begin(async (tx) => { const [userMsg] = await sql<{ id: string }[]>`
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp()) VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
RETURNING id RETURNING id
`; `;
const [assistantMsg] = await tx<{ id: string }[]>` await sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
INSERT INTO messages (session_id, chat_id, role, content, status, created_at) await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
VALUES (${sessionId}, ${chatId}, '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 = ${chatId}`;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
});
// Publish user message frames so WS subscribers see it immediately // Publish user message frames
broker.publishFrame(sessionId, { broker.publishFrame(sessionId, {
type: 'message_started', type: 'message_started',
message_id: result.user_message_id, message_id: userMsg!.id,
chat_id: chatId, chat_id: chatId,
role: 'user', role: 'user',
} as unknown as WsFrame); } as unknown as WsFrame);
broker.publishFrame(sessionId, { broker.publishFrame(sessionId, {
type: 'delta', type: 'delta',
message_id: result.user_message_id, message_id: userMsg!.id,
chat_id: chatId, chat_id: chatId,
content, content,
} as unknown as WsFrame); } as unknown as WsFrame);
broker.publishFrame(sessionId, { broker.publishFrame(sessionId, {
type: 'message_complete', type: 'message_complete',
message_id: result.user_message_id, message_id: userMsg!.id,
chat_id: chatId, chat_id: chatId,
} as unknown as WsFrame); } as unknown as WsFrame);
// Enqueue inference — the runner will stream assistant deltas via broker if (isExternal) {
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default'); // 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, session_id)
VALUES (${projectId}, ${content}, ${provider}, ${model ?? 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); reply.code(202);
return result; return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
}, },
); );

View File

@@ -0,0 +1,80 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import { PROVIDERS } from '../services/provider-registry.js';
interface ProviderModel {
id: string;
label: string;
}
interface ProviderResponse {
name: string;
label: string;
transport: string;
installed: boolean;
models: ProviderModel[];
}
interface LlamaSwapModel {
id: string;
[key: string]: unknown;
}
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?: LlamaSwapModel[] };
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
} catch {
return [];
}
}
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
app.get('/api/providers', async (_req, _reply) => {
const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<{ name: string; models: ProviderModel[]; label: string | null; transport: string | null; supports_acp: boolean }[]>`
SELECT name, models, label, transport, supports_acp FROM available_agents
`;
const agentMap = new Map(agents.map((a) => [a.name, a]));
const result: ProviderResponse[] = [];
for (const provider of PROVIDERS) {
const isNative = provider.name === 'boocode';
const agentRow = agentMap.get(provider.name);
const installed = isNative || !!agentRow;
if (!installed) continue;
let models: ProviderModel[];
if (provider.modelSource === 'llama-swap') {
models = llamaModels;
} else if (agentRow?.models && agentRow.models.length > 0) {
models = agentRow.models;
} else if (provider.staticModels) {
models = provider.staticModels;
} else {
models = [];
}
let transport: string = provider.transport;
if (agentRow) {
transport = provider.transport === 'acp' && !agentRow.supports_acp ? 'pty' : provider.transport;
}
result.push({
name: provider.name,
label: agentRow?.label ?? provider.label,
transport,
installed,
models,
});
}
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,138 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.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(),
});
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 } = parsed.data;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model)
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? 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') {
reply.code(409);
return { error: `cannot cancel task in state '${task.state}'` };
}
// If running, try to cancel inference
if (task.state === 'running' && 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')
`;
return { cancelled: true };
});
}

View File

@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS tasks (
ended_at TIMESTAMPTZ, ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')), 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')) 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 ( CREATE TABLE IF NOT EXISTS available_agents (
@@ -43,6 +43,26 @@ CREATE TABLE IF NOT EXISTS available_agents (
last_probed_at TIMESTAMPTZ 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 -- Human inbox: tasks needing attention
CREATE OR REPLACE VIEW human_inbox AS CREATE OR REPLACE VIEW human_inbox AS
SELECT * FROM tasks WHERE state IN ('blocked', 'failed'); 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';

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,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,272 @@
/**
* ACP dispatch — runs ACP-capable agents (opencode, goose) directly on the host.
*
* v2.1.1: BooCoder runs on the host now — agents are spawned directly,
* no SSH needed. Uses @agentclientprotocol/sdk for structured JSON-RPC.
*
* Flow:
* 1. Spawn `opencode acp` (or `goose acp`) in the worktree
* 2. Wrap child's stdin/stdout into NDJSON streams
* 3. Create a ClientSideConnection from the SDK
* 4. Initialize → newSession → prompt(task)
* 5. Collect session updates (tool calls, text output)
* 6. On prompt completion → return collected output
*/
import type { FastifyBaseLogger } from 'fastify';
import { Readable, Writable } from 'node:stream';
import {
ClientSideConnection,
ndJsonStream,
type Client,
type SessionNotification,
type RequestPermissionRequest,
type RequestPermissionResponse,
type ReadTextFileRequest,
type ReadTextFileResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
type CreateTerminalRequest,
type CreateTerminalResponse,
} from '@agentclientprotocol/sdk';
import { spawn } from 'node:child_process';
export interface AcpDispatchResult {
exitCode: number;
output: string;
toolCalls: Array<{ title: string; input: unknown; output?: unknown }>;
stopReason: string;
}
export interface AcpDispatchOpts {
agent: string;
task: string;
worktreePath: string;
model?: string;
installPath?: string;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
function acpArgs(agent: string): string[] | null {
switch (agent) {
case 'opencode':
return ['acp'];
case 'goose':
return ['acp'];
default:
return null;
}
}
/**
* Convert a Node.js Readable stream to a web ReadableStream<Uint8Array>.
*/
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();
}
},
});
}
/**
* Convert a Node.js Writable stream to a web WritableStream<Uint8Array>.
*/
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();
},
});
}
/**
* Dispatch a task to an ACP-capable agent via SSH.
*
* Opens a structured ACP session, sends the task as a prompt, and collects
* all session updates. Returns the collected output and tool calls.
*/
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
const { agent, task, worktreePath, installPath, signal, log } = opts;
const args = acpArgs(agent);
if (!args) {
return {
exitCode: 1,
output: `Agent '${agent}' does not support ACP.`,
toolCalls: [],
stopReason: 'error',
};
}
const binary = installPath ?? agent;
log.info({ agent, binary, worktreePath }, 'acp-dispatch: spawning');
const child = spawn(binary, args, {
cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
// Wire up abort
let killed = false;
const cleanup = () => {
if (!killed) {
killed = true;
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 5_000);
}
};
if (signal) {
if (signal.aborted) {
cleanup();
return { exitCode: 130, output: 'Aborted before start', toolCalls: [], stopReason: 'cancelled' };
}
signal.addEventListener('abort', cleanup, { once: true });
}
try {
// Create web streams from the child process stdio
const inputStream = nodeReadableToWeb(child.stdout!);
const outputStream = nodeWritableToWeb(child.stdin!);
// Create the NDJSON ACP stream
const stream = ndJsonStream(outputStream, inputStream);
// Collected session updates
const textChunks: string[] = [];
const toolCalls: Array<{ title: string; input: unknown; output?: unknown }> = [];
// Create client-side connection — we are the "client" (editor), the agent is remote
const connection = new ClientSideConnection(
(_agentInterface): Client => ({
// Handle session updates from the agent
async sessionUpdate(params: SessionNotification): Promise<void> {
const update = params.update;
if (update.sessionUpdate === 'agent_message_chunk') {
// ContentChunk with content: ContentBlock
const content = update.content;
if (content.type === 'text' && 'text' in content) {
textChunks.push((content as { text: string }).text);
}
} else if (update.sessionUpdate === 'tool_call') {
toolCalls.push({
title: update.title,
input: update.rawInput,
});
} else if (update.sessionUpdate === 'tool_call_update') {
const last = toolCalls[toolCalls.length - 1];
if (last && update.rawOutput !== undefined) {
last.output = update.rawOutput;
}
}
},
// Permission requests — auto-approve by selecting the first option (worktree is isolated)
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
// Select the first available option to auto-approve
const firstOption = params.options[0];
if (firstOption) {
return {
outcome: { outcome: 'selected', optionId: firstOption.optionId },
};
}
// No options available — cancel
return { outcome: { outcome: 'cancelled' } };
},
// File system operations — let the agent handle them directly in the worktree
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,
);
// Initialize the connection
// ProtocolVersion is a number in this SDK version
const initResult = await connection.initialize({
protocolVersion: 1,
clientInfo: { name: 'boocoder', version: '2.0.1' },
clientCapabilities: {},
});
log.info({ agentInfo: initResult.agentInfo }, 'acp-dispatch: initialized');
// Create a new session
const session = await connection.newSession({
cwd: worktreePath,
mcpServers: [],
});
log.info({ sessionId: session.sessionId }, 'acp-dispatch: session created');
// Send the prompt
const promptResult = await connection.prompt({
sessionId: session.sessionId,
prompt: [{ type: 'text', text: task }],
});
const stopReason = promptResult.stopReason ?? 'end_turn';
log.info({ agent, stopReason, toolCallCount: toolCalls.length }, 'acp-dispatch: prompt completed');
// Clean shutdown
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
return {
exitCode: 0,
output: textChunks.join(''),
toolCalls,
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,
toolCalls: [],
stopReason: 'error',
};
} finally {
if (signal) signal.removeEventListener('abort', cleanup);
cleanup();
// Wait for child to exit
await new Promise<void>((resolve) => {
child.on('close', resolve);
setTimeout(resolve, 3_000);
});
}
}

View File

@@ -0,0 +1,93 @@
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
import { exec as execCb } from 'node:child_process';
import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME } from './provider-registry.js';
const exec = promisify(execCb);
const KNOWN_AGENTS = ['opencode', 'goose', 'claude', 'qwen'].map((name) => ({
name,
supportsAcp: PROVIDERS_BY_NAME.get(name)?.transport === 'acp',
}));
/**
* Probe for available agents on the HOST.
*
* v2.1.1: BooCoder runs on the host now — agents are local binaries,
* no SSH needed. Direct `which` / `exec` calls.
*/
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
log.info('agent-probe: scanning for known agents');
for (const agent of KNOWN_AGENTS) {
try {
const { stdout: whichOut } = await exec(`which ${agent.name}`, { timeout: 10_000 });
const installPath = whichOut.trim();
if (!installPath) continue;
let version: string | null = null;
try {
const { stdout: verOut } = await exec(`${agent.name} --version`, { timeout: 15_000 });
version = verOut.trim().slice(0, 100);
} catch {
// Some agents may not support --version
}
let supportsAcp = agent.supportsAcp;
if (supportsAcp) {
try {
await exec(`${agent.name} acp --help`, { timeout: 10_000 });
} catch {
supportsAcp = false;
}
}
let models: Array<{ id: string; label: string }> = [];
const providerDef = PROVIDERS_BY_NAME.get(agent.name);
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
models = providerDef.staticModels;
}
if (agent.name === 'qwen') {
try {
const { stdout: catOut } = await exec('cat ~/.qwen/settings.json', { timeout: 10_000 });
if (catOut.trim()) {
const settings = JSON.parse(catOut) as {
modelProviders?: { openai?: Array<{ id: string }> };
};
const openaiModels = settings?.modelProviders?.openai;
if (Array.isArray(openaiModels)) {
models = openaiModels.map((m) => ({ id: m.id, label: m.id }));
}
}
} catch {
// ~/.qwen/settings.json missing or unparseable
}
}
const label = providerDef?.label ?? agent.name;
const transport = providerDef?.transport ?? 'pty';
await sql`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
VALUES (${agent.name}, ${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: agent.name, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.debug({ agent: agent.name, err: msg }, 'agent-probe: not found');
}
}
log.info('agent-probe: scan complete');
}

View File

@@ -0,0 +1,406 @@
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
import type { Broker } from '@boocode/server/broker';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
import { dispatchViaAcp } from './acp-dispatch.js';
import { dispatchViaPty } from './pty-dispatch.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;
}
const POLL_INTERVAL_MS = 5_000;
const COMPLETION_POLL_MS = 2_000;
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
const { sql, inference, log, config } = deps;
let timer: ReturnType<typeof setInterval> | null = null;
let running = false;
let stopping = false;
let inflightPromise: Promise<void> | null = null;
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; session_id: string | null }[]>`
SELECT id, project_id, input, agent, model, 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; 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; 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;
if (supportsAcp) {
const result = await dispatchViaAcp({
agent,
task: task.input,
worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined,
signal: ac.signal,
log,
});
outputSummary = result.output.slice(0, 500);
// Store agent output as an assistant message
await sql`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', ${result.output.slice(0, 50_000)}, 'complete', clock_timestamp())
`;
} else {
const result = await dispatchViaPty({
agent,
task: task.input,
worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined,
signal: ac.signal,
log,
});
outputSummary = (result.stdout || result.stderr).slice(0, 500);
// Store agent output as an assistant message
const content = result.stdout || result.stderr || '(no output)';
await sql`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', ${content.slice(0, 50_000)}, 'complete', clock_timestamp())
`;
}
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)');
} 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);
}
}
// ─── 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');
timer = setInterval(() => {
poll().catch((err) => {
log.error({ err }, 'dispatcher: poll error');
});
}, POLL_INTERVAL_MS);
},
async stop() {
stopping = true;
if (timer) {
clearInterval(timer);
timer = null;
}
if (inflightPromise) {
log.info('dispatcher: waiting for in-flight task');
await inflightPromise;
}
log.info('dispatcher: stopped');
},
};
}

View File

@@ -0,0 +1,201 @@
/**
* 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)'),
},
async (args) => {
const [row] = await sql<TaskRow[]>`
INSERT INTO tasks (project_id, input, agent, model, state)
VALUES (${args.project_id}, ${args.input}, ${args.agent ?? null}, ${args.model ?? null}, 'pending')
RETURNING id, state
`;
return textResult({ task_id: row!.id, state: row!.state });
},
);
// 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)'),
},
async (args) => {
const [row] = await sql<TaskRow[]>`
INSERT INTO tasks (project_id, input, agent, model, state)
VALUES (${args.project_id}, ${args.input}, ${args.agent}, ${args.model ?? 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 });
},
);
// 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,46 @@
export interface ProviderDef {
name: string;
label: string;
transport: 'native' | 'acp' | 'pty';
modelSource: 'llama-swap' | 'static';
staticModels?: Array<{ id: string; label: string }>;
}
export const PROVIDERS: ProviderDef[] = [
{
name: 'boocode',
label: 'BooCoder',
transport: 'native',
modelSource: 'llama-swap',
},
{
name: 'opencode',
label: 'OpenCode',
transport: 'acp',
modelSource: 'llama-swap',
},
{
name: 'goose',
label: 'Goose',
transport: 'acp',
modelSource: 'llama-swap',
},
{
name: 'claude',
label: 'Claude Code',
transport: 'pty',
modelSource: 'static',
staticModels: [
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
],
},
{
name: 'qwen',
label: 'Qwen Code',
transport: 'pty',
modelSource: 'static',
},
];
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));

View File

@@ -0,0 +1,140 @@
/**
* PTY dispatch — runs external agents directly on the host.
*
* v2.1.3: Spawns agent binaries directly (no sh -c wrapper) using the
* install_path from agent-probe. Follows Paseo's pattern: direct binary
* path + args array + cwd.
*
* Supported agents:
* - claude: `claude -p --model <model>` (print mode, reads task from stdin)
* - opencode: `opencode --model <model>` (stdin pipe)
* - qwen: `qwen -p <task> --output-format stream-json`
* - goose: `goose run --text <task>`
*/
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;
installPath?: string;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
interface AgentCommand {
binary: string;
args: string[];
stdin?: string;
}
function buildAgentCommand(agent: string, task: string, model?: string, installPath?: string): AgentCommand | null {
const binary = installPath ?? agent;
switch (agent) {
case 'claude':
return {
binary,
args: model ? ['-p', '--model', model] : ['-p'],
stdin: task,
};
case 'opencode':
return {
binary,
args: model ? ['--model', model] : [],
stdin: task,
};
case 'qwen':
return {
binary,
args: model
? ['-p', task, '--model', model, '--output-format', 'stream-json']
: ['-p', task, '--output-format', 'stream-json'],
};
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, installPath, signal, log } = opts;
const cmd = buildAgentCommand(agent, task, model, installPath);
if (!cmd) {
return {
exitCode: 1,
stdout: '',
stderr: `Agent '${agent}' is not yet supported for PTY dispatch.`,
};
}
log.info({ agent, binary: cmd.binary, worktreePath }, '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,129 @@
/**
* @deprecated v2.1.1 — BooCoder runs on the host now. Use direct spawn/exec instead.
* Kept for one release cycle in case of rollback.
*
* SSH helper — spawns commands on the host via SSH.
*
* BooCode's container cannot directly spawn host processes (opencode, goose, claude, pi).
* They live on the HOST at /usr/local/bin/ or Sam's PATH. We SSH to the host over the
* Tailscale IP (same mechanism BooTerm uses: samkintop@100.114.205.53).
*/
import { spawn, type ChildProcess } from 'node:child_process';
export const SSH_HOST = process.env.BOOCODER_SSH_HOST ?? '100.114.205.53';
export const SSH_USER = process.env.BOOCODER_SSH_USER ?? 'samkintop';
/** Common SSH args — strict host checking disabled for container-to-host trust. */
const SSH_BASE_ARGS = [
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'BatchMode=yes',
];
export interface SshExecResult {
exitCode: number;
stdout: string;
stderr: string;
}
/**
* Execute a command on the host via SSH, collecting all output.
* Returns when the remote process exits.
*/
export async function sshExec(
command: string,
opts?: { signal?: AbortSignal; timeoutMs?: number },
): Promise<SshExecResult> {
return new Promise<SshExecResult>((resolve, reject) => {
const child = spawn('ssh', [
...SSH_BASE_ARGS,
`${SSH_USER}@${SSH_HOST}`,
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');
}
};
// Abort signal
if (opts?.signal) {
if (opts.signal.aborted) {
cleanup();
reject(new Error('SSH exec aborted before start'));
return;
}
opts.signal.addEventListener('abort', cleanup, { once: true });
}
// Timeout
let timer: ReturnType<typeof setTimeout> | undefined;
if (opts?.timeoutMs) {
timer = setTimeout(() => {
cleanup();
reject(new Error(`SSH 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);
});
// Close stdin immediately — we're not sending input via sshExec
child.stdin!.end();
});
}
/**
* Spawn an SSH child process with a command on the host.
* Returns the raw ChildProcess for callers that need streaming I/O (ACP, PTY).
*/
export function sshSpawn(command: string): ChildProcess {
return spawn('ssh', [
...SSH_BASE_ARGS,
`${SSH_USER}@${SSH_HOST}`,
command,
], {
stdio: ['pipe', 'pipe', 'pipe'],
});
}
/**
* Spawn an SSH child process that pipes stdin through.
* Used for agents that read a task from stdin (e.g. `echo "task" | claude -p`).
*/
export function sshSpawnWithStdin(command: string, input: string): ChildProcess {
const child = spawn('ssh', [
...SSH_BASE_ARGS,
`${SSH_USER}@${SSH_HOST}`,
command,
], {
stdio: ['pipe', 'pipe', 'pipe'],
});
// Write the input and close stdin
child.stdin!.write(input);
child.stdin!.end();
return child;
}

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

@@ -4,6 +4,9 @@ import { createFileTool } from './create_file.js';
import { deleteFileTool } from './delete_file.js'; import { deleteFileTool } from './delete_file.js';
import { applyPendingTool } from './apply_pending.js'; import { applyPendingTool } from './apply_pending.js';
import { rewindTool } from './rewind.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'; export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
@@ -16,6 +19,11 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
deleteFileTool, deleteFileTool,
editFileTool, editFileTool,
rewindTool, 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -23,4 +31,4 @@ export const WRITE_TOOLS_BY_NAME: ReadonlyMap<string, ToolDef<any>> = new Map(
WRITE_TOOLS.map((t) => [t.name, t]), WRITE_TOOLS.map((t) => [t.name, t]),
); );
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool }; export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool };

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,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 { sshExec } from './ssh.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 sshExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
// Create the worktree with a new branch from HEAD
const result = await sshExec(
`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 sshExec(
`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 sshExec(
`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 sshExec(
`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 sshExec(
`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 sshExec(
`git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`,
{ timeoutMs: 15_000 },
).catch(() => {});
// Delete the task branch
await sshExec(
`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

@@ -54,10 +54,14 @@ export function isSecretPath(filePath: string): boolean {
* checks the result stays within projectRoot. * checks the result stays within projectRoot.
*/ */
export function resolveWritePath(projectRoot: string, filePath: string): string { export function resolveWritePath(projectRoot: string, filePath: string): string {
if (!filePath || filePath.length === 0) { if (!filePath || filePath.trim().length === 0) {
throw new WriteGuardError('file path is required'); 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 candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
const normalized = resolve(candidate); // normalizes ../ segments const normalized = resolve(candidate); // normalizes ../ segments

View File

@@ -22,6 +22,9 @@ const ConfigSchema = z.object({
// v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json // v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json
// (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in). // (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in).
MCP_CONFIG_PATH: z.string().optional(), MCP_CONFIG_PATH: z.string().optional(),
// v2.0.5: cheaper model for titles, summaries, labeling. Falls back to
// session model (auto_name) or DEFAULT_MODEL when unset.
FAST_MODEL: z.string().optional(),
}); });
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -67,7 +67,8 @@ export async function maybeAutoNameChat(
const sessionRows = await ctx.sql<{ model: string }[]>` const sessionRows = await ctx.sql<{ model: string }[]>`
SELECT model FROM sessions WHERE id = ${sessionId} SELECT model FROM sessions WHERE id = ${sessionId}
`; `;
const model = sessionRows[0]?.model; // v2.0.5: prefer FAST_MODEL for cheap LLM calls (titles, summaries).
const model = ctx.config.FAST_MODEL ?? sessionRows[0]?.model;
if (!model) return; if (!model) return;
const assistantMsg = await ctx.sql<{ content: string }[]>` const assistantMsg = await ctx.sql<{ content: string }[]>`

View File

@@ -20,3 +20,5 @@ export type {
export type { ToolPhaseResult } from './tool-phase.js'; export type { ToolPhaseResult } from './tool-phase.js';
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js'; export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
export { buildMessagesPayload } from './payload.js'; export { buildMessagesPayload } from './payload.js';
export { generateToolUseSummary } from './tool-summaries.js';
export type { ToolInfo } from './tool-summaries.js';

View File

@@ -142,7 +142,7 @@ export async function loadContext(
): Promise<{ session: Session; project: Project; history: Message[] } | null> { ): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>` const sessionRows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled agent_id, web_search_enabled, allowed_read_paths
FROM sessions WHERE id = ${sessionId} FROM sessions WHERE id = ${sessionId}
`; `;
if (sessionRows.length === 0) return null; if (sessionRows.length === 0) return null;

View File

@@ -36,6 +36,8 @@ export async function runCapHitSummary(
): Promise<void> { ): Promise<void> {
const { sessionId, chatId, assistantMessageId, signal } = args; const { sessionId, chatId, assistantMessageId, signal } = args;
await insertCapHitSentinel(ctx, sessionId, chatId, agent, budget);
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log); const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
messages.push({ role: 'system', content: CAP_HIT_SUMMARY_NOTE(budget) }); messages.push({ role: 'system', content: CAP_HIT_SUMMARY_NOTE(budget) });
@@ -195,8 +197,6 @@ export async function runCapHitSummary(
updated_at: sessRow!.updated_at, updated_at: sessRow!.updated_at,
}); });
await insertCapHitSentinel(ctx, sessionId, chatId, agent, budget);
// Status frame fires last so the dot color reflects the terminal state. // Status frame fires last so the dot color reflects the terminal state.
// Success → idle, abort → idle (user-driven stop), error → error+reason. // Success → idle, abort → idle (user-driven stop), error → error+reason.
if (summaryOk) { if (summaryOk) {

View File

@@ -0,0 +1,81 @@
/**
* v2.0.5: Tool-use summary generation.
*
* After a batch of tool calls completes, fire a cheap LLM call to generate
* a "git-commit-subject-style" one-liner label describing what the tools
* accomplished. Ported from the Qwen Code source recon.
*/
import type { FastifyBaseLogger } from 'fastify';
const TOOL_SUMMARY_SYSTEM_PROMPT = `Write a short summary label describing what these tool calls accomplished. Think git-commit-subject, not sentence. Past tense, most distinctive noun. Max 30 characters. Output ONLY the label.
Examples:
- Searched in auth/
- Fixed NPE in UserService
- Created signup endpoint
- Read config.json
- Ran failing tests`;
const INPUT_TRUNCATE = 300;
const MAX_SUMMARY_LENGTH = 100;
export interface ToolInfo {
name: string;
input: string;
output: string;
}
export async function generateToolUseSummary(opts: {
tools: ToolInfo[];
llamaSwapUrl: string;
model: string;
log: FastifyBaseLogger;
signal?: AbortSignal;
}): Promise<string | null> {
const { tools, llamaSwapUrl, model, log, signal } = opts;
if (tools.length === 0) return null;
if (signal?.aborted) return null;
const toolText = tools
.map(t => `Tool: ${t.name}\nInput: ${t.input.slice(0, INPUT_TRUNCATE)}\nOutput: ${t.output.slice(0, INPUT_TRUNCATE)}`)
.join('\n\n');
try {
const res = await fetch(`${llamaSwapUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: TOOL_SUMMARY_SYSTEM_PROMPT },
{ role: 'user', content: toolText },
],
max_tokens: 30,
temperature: 0.2,
stream: false,
chat_template_kwargs: { enable_thinking: false },
}),
signal,
});
if (!res.ok) {
log.debug({ status: res.status }, 'tool-summary: LLM request failed');
return null;
}
const data = await res.json() as { choices?: Array<{ message?: { content?: string } }> };
const raw = data.choices?.[0]?.message?.content?.trim() ?? '';
if (!raw) return null;
// Clean: strip quotes, "Label:" prefix, cap length
let cleaned = raw.split('\n')[0]?.trim() ?? '';
cleaned = cleaned
.replace(/^[-*•]\s+/, '')
.replace(/^["'`‘’“”]|["'`‘’“”]$/g, '')
.replace(/^(label|summary)\s*:\s*/i, '')
.trim();
return cleaned.length > MAX_SUMMARY_LENGTH
? cleaned.slice(0, MAX_SUMMARY_LENGTH).trim()
: cleaned || null;
} catch (err) {
log.debug({ err: err instanceof Error ? err.message : String(err) }, 'tool-summary: error');
return null;
}
}

View File

@@ -13,6 +13,7 @@ import type {
Skill, Skill,
AskUserAnswer, AskUserAnswer,
ToolCostStat, ToolCostStat,
Provider,
} from './types'; } from './types';
export class ApiError extends Error { export class ApiError extends Error {
@@ -298,6 +299,10 @@ export const api = {
models: () => request<ModelInfo[]>('/api/models'), models: () => request<ModelInfo[]>('/api/models'),
coder: {
providers: () => request<Provider[]>('/api/coder/providers'),
},
agents: { agents: {
list: (projectId: string) => list: (projectId: string) =>
request<AgentsResponse>(`/api/projects/${projectId}/agents`), request<AgentsResponse>(`/api/projects/${projectId}/agents`),

View File

@@ -206,6 +206,19 @@ export interface ModelInfo {
[key: string]: unknown; [key: string]: unknown;
} }
export interface ProviderModel {
id: string;
label: string;
}
export interface Provider {
name: string;
label: string;
transport: string;
installed: boolean;
models: ProviderModel[];
}
export interface SidebarSession { export interface SidebarSession {
id: string; id: string;
name: string; name: string;

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { Chat, Message } from '@/api/types'; import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble'; import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup'; import { ToolCallGroup } from './ToolCallGroup';
@@ -142,13 +142,26 @@ function stampCapHits(items: RenderItem[]): RenderItem[] {
}); });
} }
const SCROLL_THRESHOLD_PX = 150;
export function MessageList({ messages, sessionChats }: Props) { export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null); const endRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]); const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
isNearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
}, []);
useEffect(() => { useEffect(() => {
if (isNearBottomRef.current) {
endRef.current?.scrollIntoView({ block: 'end' }); endRef.current?.scrollIntoView({ block: 'end' });
}
}, [messages]); }, [messages]);
if (messages.length === 0) { if (messages.length === 0) {
@@ -160,7 +173,7 @@ export function MessageList({ messages, sessionChats }: Props) {
} }
return ( return (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto" ref={scrollContainerRef} onScroll={handleScroll}>
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4"> <div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{renderItems.map((item) => { {renderItems.map((item) => {
if (item.kind === 'message') { if (item.kind === 'message') {

View File

@@ -0,0 +1,178 @@
import { useEffect, useState } from 'react';
import { Check, ChevronDown, Cpu } from 'lucide-react';
import { api } from '@/api/client';
import type { Provider } from '@/api/types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { BottomSheet } from '@/components/BottomSheet';
import { useViewport } from '@/hooks/useViewport';
interface Props {
provider: string;
model: string;
onChange: (provider: string, model: string) => void | Promise<void>;
}
function ProviderModelList({
providers,
error,
currentProvider,
currentModel,
onPick,
}: {
providers: Provider[] | null;
error: string | null;
currentProvider: string;
currentModel: string;
onPick: (provider: string, model: string) => void;
}) {
if (error) {
return <div className="px-2 py-1.5 text-xs text-destructive">{error}</div>;
}
if (providers === null) {
return <div className="px-2 py-1.5 text-xs text-muted-foreground">Loading...</div>;
}
const singleProvider = providers.length === 1;
return (
<>
{providers.map((p) => (
<div key={p.name}>
{!singleProvider && (
<div className="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
{p.label}
</div>
)}
{p.models.map((m) => (
<button
key={`${p.name}:${m.id}`}
type="button"
onClick={() => onPick(p.name, m.id)}
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
>
<Check
className={`size-3 shrink-0 ${
p.name === currentProvider && m.id === currentModel
? 'opacity-100'
: 'opacity-0'
}`}
/>
<span className="truncate">{m.label}</span>
</button>
))}
</div>
))}
</>
);
}
export function ProviderPicker({ provider, model, onChange }: Props) {
const { isMobile } = useViewport();
const [providers, setProviders] = useState<Provider[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open || providers !== null) return;
api.coder
.providers()
.then(setProviders)
.catch((err) =>
setError(err instanceof Error ? err.message : 'failed to load providers'),
);
}, [open, providers]);
function handlePick(prov: string, mod: string) {
setOpen(false);
void onChange(prov, mod);
}
const currentProviderLabel =
providers?.find((p) => p.name === provider)?.label ?? provider;
const triggerText = providers && providers.length > 1
? `${currentProviderLabel} / ${model}`
: model;
if (isMobile) {
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`Provider: ${currentProviderLabel}, Model: ${model}`}
title={`${currentProviderLabel} / ${model}`}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
>
<Cpu className="size-4" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title="Provider / Model">
<div className="px-2 py-2 space-y-1">
<ProviderModelList
providers={providers}
error={error}
currentProvider={provider}
currentModel={model}
onPick={handlePick}
/>
</div>
</BottomSheet>
</>
);
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 max-w-[260px]"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto min-w-[200px]">
{error && (
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
)}
{providers === null && !error && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">Loading...</div>
)}
{providers && providers.map((p) => {
const singleProvider = providers.length === 1;
return (
<div key={p.name}>
{!singleProvider && (
<div className="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70 select-none">
{p.label}
</div>
)}
{p.models.map((m) => (
<DropdownMenuItem
key={`${p.name}:${m.id}`}
onSelect={() => handlePick(p.name, m.id)}
className="font-mono text-xs"
>
<Check
className={`size-3 shrink-0 ${
p.name === provider && m.id === model
? 'opacity-100'
: 'opacity-0'
}`}
/>
{m.label}
</DropdownMenuItem>
))}
</div>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -10,6 +10,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Code, Send, Check, X, RefreshCw } from 'lucide-react'; import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { MarkdownRenderer } from '@/components/MarkdownRenderer';
import { ProviderPicker } from '@/components/ProviderPicker';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -300,6 +301,8 @@ export function CoderPane({ sessionId }: Props) {
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId); const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [provider, setProvider] = useState('boocode');
const [model, setModel] = useState('qwen3.6-35b-a3b-mxfp4');
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -331,7 +334,11 @@ export function CoderPane({ sessionId }: Props) {
const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, { const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content: text }), body: JSON.stringify({
content: text,
provider: provider !== 'boocode' ? provider : undefined,
model: model || undefined,
}),
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
@@ -347,7 +354,7 @@ export function CoderPane({ sessionId }: Props) {
} finally { } finally {
setSending(false); setSending(false);
} }
}, [input, sending, sessionId, setMessages]); }, [input, sending, sessionId, provider, model, setMessages]);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@@ -363,11 +370,18 @@ export function CoderPane({ sessionId }: Props) {
<div className="flex flex-col h-full bg-background"> <div className="flex flex-col h-full bg-background">
{/* Header */} {/* Header */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/30 shrink-0"> <div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/30 shrink-0">
<Code size={14} className="text-muted-foreground" /> <Code size={14} className="text-muted-foreground shrink-0" />
<span className="text-xs font-medium text-muted-foreground">BooCoder</span> <ProviderPicker
provider={provider}
model={model}
onChange={(prov, mod) => {
setProvider(prov);
setModel(mod);
}}
/>
<span <span
className={cn( className={cn(
'inline-block w-1.5 h-1.5 rounded-full ml-auto', 'inline-block w-1.5 h-1.5 rounded-full ml-auto shrink-0',
connected ? 'bg-green-500' : 'bg-red-500' connected ? 'bg-green-500' : 'bg-red-500'
)} )}
title={connected ? 'Connected' : 'Disconnected'} title={connected ? 'Connected' : 'Disconnected'}

View File

@@ -312,6 +312,8 @@ Independent batch — ships clean any time after v1.13. Low leverage unless Sam
**Estimated:** ~1500 LoC for Path A + Path B + shared schema, plus ~400 LoC for the MCP-server role, plus ~300 LoC for the ACP-client role. Multiple sub-versions: v2.0.0 native + ACP, v2.0.1 MCP server, v2.0.2 polish. **Estimated:** ~1500 LoC for Path A + Path B + shared schema, plus ~400 LoC for the MCP-server role, plus ~300 LoC for the ACP-client role. Multiple sub-versions: v2.0.0 native + ACP, v2.0.1 MCP server, v2.0.2 polish.
**Retrospective (2026-05-25):** All 8 phases shipped. v2.0.0-alpha through v2.0.4-hardening. The full BooCoder line is complete: write tools with pending-changes queue, dispatcher with ACP/PTY dual paths, MCP server (6 tools, stdio transport, 10-question eval passed), CLI client, human inbox, Boomerang `new_task` orchestration, and path-guard fuzz suite (34 traversal-attack tests). Runtime isolation (v2.1) remains optional pending production bake.
----- -----
## v2.1 — BooCoder runtime isolation (optional) ## v2.1 — BooCoder runtime isolation (optional)

View File

@@ -10,6 +10,7 @@ services:
CODECONTEXT_URL: http://codecontext:8080 CODECONTEXT_URL: http://codecontext:8080
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
BOOCODER_URL: http://100.114.205.53:9502
volumes: volumes:
- /opt:/opt - /opt:/opt
- /opt/projects:/opt/projects:rw - /opt/projects:/opt/projects:rw
@@ -50,27 +51,29 @@ services:
networks: networks:
- boocode_net - boocode_net
boocoder: # v2.1.1: boocoder moved to systemd service on host (boocoder.service).
build: # Kept commented for rollback reference.
context: . # boocoder:
dockerfile: apps/coder/Dockerfile # build:
container_name: boocoder # context: .
restart: unless-stopped # dockerfile: apps/coder/Dockerfile
ports: # container_name: boocoder
- "100.114.205.53:9502:3000" # restart: unless-stopped
env_file: .env # ports:
environment: # - "100.114.205.53:9502:3000"
CONTAINER_GUIDANCE_FILE: /app/BOOCODER.md # env_file: .env
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat # environment:
volumes: # CONTAINER_GUIDANCE_FILE: /app/BOOCODER.md
- /opt:/opt:rw # DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
- /opt/projects:/opt/projects:rw # volumes:
- ./data:/data # - /opt:/opt:rw
- /opt/boocode/BOOCODER.md:/app/BOOCODER.md:ro # - /opt/projects:/opt/projects:rw
depends_on: # - ./data:/data
- boocode_db # - /opt/boocode/BOOCODER.md:/app/BOOCODER.md:ro
networks: # depends_on:
- boocode_net # - boocode_db
# networks:
# - boocode_net
boocode_db: boocode_db:
image: postgres:16-alpine image: postgres:16-alpine

21
pnpm-lock.yaml generated
View File

@@ -48,6 +48,9 @@ importers:
apps/coder: apps/coder:
dependencies: dependencies:
'@agentclientprotocol/sdk':
specifier: ^0.22.1
version: 0.22.1(zod@3.25.76)
'@boocode/server': '@boocode/server':
specifier: workspace:* specifier: workspace:*
version: link:../server version: link:../server
@@ -57,12 +60,18 @@ importers:
'@fastify/websocket': '@fastify/websocket':
specifier: ^10.0.1 specifier: ^10.0.1
version: 10.0.1 version: 10.0.1
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.29.0(zod@3.25.76)
fastify: fastify:
specifier: ^4.28.1 specifier: ^4.28.1
version: 4.29.1 version: 4.29.1
postgres: postgres:
specifier: ^3.4.4 specifier: ^3.4.4
version: 3.4.9 version: 3.4.9
ws:
specifier: ^8.18.0
version: 8.20.1
zod: zod:
specifier: ^3.23.8 specifier: ^3.23.8
version: 3.25.76 version: 3.25.76
@@ -70,6 +79,9 @@ importers:
'@types/node': '@types/node':
specifier: ^20.14.10 specifier: ^20.14.10
version: 20.19.41 version: 20.19.41
'@types/ws':
specifier: ^8.5.10
version: 8.18.1
tsx: tsx:
specifier: ^4.16.2 specifier: ^4.16.2
version: 4.22.0 version: 4.22.0
@@ -268,6 +280,11 @@ importers:
packages: packages:
'@agentclientprotocol/sdk@0.22.1':
resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@ai-sdk/gateway@3.0.119': '@ai-sdk/gateway@3.0.119':
resolution: {integrity: sha512-VAhfRWC+JexZakkVfmjaJKaTj00x7/UHdE8kMWL3NhuQAlf8oXtg9r4dfvFZrByXxchGRBvYE3biEUyibkg0xg==} resolution: {integrity: sha512-VAhfRWC+JexZakkVfmjaJKaTj00x7/UHdE8kMWL3NhuQAlf8oXtg9r4dfvFZrByXxchGRBvYE3biEUyibkg0xg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4097,6 +4114,10 @@ packages:
snapshots: snapshots:
'@agentclientprotocol/sdk@0.22.1(zod@3.25.76)':
dependencies:
zod: 3.25.76
'@ai-sdk/gateway@3.0.119(zod@3.25.76)': '@ai-sdk/gateway@3.0.119(zod@3.25.76)':
dependencies: dependencies:
'@ai-sdk/provider': 3.0.10 '@ai-sdk/provider': 3.0.10