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
This commit is contained in:
2026-05-25 19:20:53 +00:00
parent e423579e99
commit d8ffee1950
21 changed files with 687 additions and 222 deletions

View File

@@ -2,6 +2,10 @@
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 ## 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). 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).

View File

@@ -66,16 +66,24 @@ 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/`) ### BooCoder (`apps/coder/src/`)
- Write-capable coding agent. Separate Fastify server at port 9502, same docker network (`boocode_net`). - 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 (Dockerfile builds server → coder). - **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. - `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). - 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 `http://boocoder:3000/api/*`. WS connects directly to `:9502`. - 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/`)
@@ -122,7 +130,11 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ...
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). 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`. Same Tailscale IP binding as BooChat. Health reports tool count: `{"ok":true,"db":true,"tools":30}`. 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
@@ -133,7 +145,7 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailsc
- 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).
@@ -169,4 +181,5 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailsc
- 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. - **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.
- **Docker build order for workspace deps**: the Dockerfile must `COPY` + `RUN pnpm build` the provider app BEFORE the consumer app. `apps/coder/Dockerfile` builds `apps/server` first, then `apps/coder`. - **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

@@ -28,6 +28,7 @@ import { registerTaskRoutes } from './routes/tasks.js';
import { registerInboxRoutes } from './routes/inbox.js'; import { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js'; import { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.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 // Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js'; import { createDispatcher } from './services/dispatcher.js';
@@ -145,6 +146,7 @@ async function main() {
registerInboxRoutes(app, sql); registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql); registerStatsRoutes(app, sql);
registerArenaRoutes(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

@@ -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) {
reply.code(404);
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
if (inference.hasActive(chatId)) {
reply.code(409);
return { error: 'inference already running on this chat' };
}
}
// Create user message
const [userMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
RETURNING id
`; `;
if (chatRows.length === 0) { await sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
reply.code(404); await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
return { error: 'chat not found or not open in this session' };
}
// Reject if inference is already running on this chat // Publish user message frames
if (inference.hasActive(chatId)) {
reply.code(409);
return { error: 'inference already running on this chat' };
}
// Create user message + streaming assistant row in a transaction
const result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
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
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

@@ -61,3 +61,8 @@ 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

@@ -1,12 +1,12 @@
/** /**
* ACP dispatch — runs ACP-capable agents (opencode, goose) on the host via SSH. * ACP dispatch — runs ACP-capable agents (opencode, goose) directly on the host.
* *
* Uses the @agentclientprotocol/sdk to establish a structured JSON-RPC session * v2.1.1: BooCoder runs on the host now — agents are spawned directly,
* with the agent subprocess. The SSH tunnel provides stdio transport. * no SSH needed. Uses @agentclientprotocol/sdk for structured JSON-RPC.
* *
* Flow: * Flow:
* 1. SSH to host, start `opencode acp` (or `goose acp`) in the worktree * 1. Spawn `opencode acp` (or `goose acp`) in the worktree
* 2. Wrap SSH child's stdin/stdout into NDJSON streams * 2. Wrap child's stdin/stdout into NDJSON streams
* 3. Create a ClientSideConnection from the SDK * 3. Create a ClientSideConnection from the SDK
* 4. Initialize → newSession → prompt(task) * 4. Initialize → newSession → prompt(task)
* 5. Collect session updates (tool calls, text output) * 5. Collect session updates (tool calls, text output)
@@ -28,7 +28,7 @@ import {
type CreateTerminalRequest, type CreateTerminalRequest,
type CreateTerminalResponse, type CreateTerminalResponse,
} from '@agentclientprotocol/sdk'; } from '@agentclientprotocol/sdk';
import { sshSpawn } from './ssh.js'; import { spawn } from 'node:child_process';
export interface AcpDispatchResult { export interface AcpDispatchResult {
exitCode: number; exitCode: number;
@@ -42,17 +42,17 @@ export interface AcpDispatchOpts {
task: string; task: string;
worktreePath: string; worktreePath: string;
model?: string; model?: string;
installPath?: string;
signal?: AbortSignal; signal?: AbortSignal;
log: FastifyBaseLogger; log: FastifyBaseLogger;
} }
/** Map agent name to the ACP command it exposes. */ function acpArgs(agent: string): string[] | null {
function acpCommand(agent: string): string | null {
switch (agent) { switch (agent) {
case 'opencode': case 'opencode':
return 'opencode acp'; return ['acp'];
case 'goose': case 'goose':
return 'goose acp'; return ['acp'];
default: default:
return null; return null;
} }
@@ -114,10 +114,10 @@ function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Ui
* all session updates. Returns the collected output and tool calls. * all session updates. Returns the collected output and tool calls.
*/ */
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> { export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
const { agent, task, worktreePath, signal, log } = opts; const { agent, task, worktreePath, installPath, signal, log } = opts;
const cmd = acpCommand(agent); const args = acpArgs(agent);
if (!cmd) { if (!args) {
return { return {
exitCode: 1, exitCode: 1,
output: `Agent '${agent}' does not support ACP.`, output: `Agent '${agent}' does not support ACP.`,
@@ -126,12 +126,13 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
}; };
} }
// Spawn SSH with the ACP command running in the worktree const binary = installPath ?? agent;
const escapedPath = worktreePath.replace(/'/g, "'\\''"); log.info({ agent, binary, worktreePath }, 'acp-dispatch: spawning');
const fullCommand = `cd '${escapedPath}' && ${cmd}`; const child = spawn(binary, args, {
cwd: worktreePath,
log.info({ agent, worktreePath }, 'acp-dispatch: spawning'); stdio: ['pipe', 'pipe', 'pipe'],
const child = sshSpawn(fullCommand); env: { ...process.env },
});
// Wire up abort // Wire up abort
let killed = false; let killed = false;

View File

@@ -1,69 +1,91 @@
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { sshExec } from './ssh.js'; import { exec as execCb } from 'node:child_process';
import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME } from './provider-registry.js';
const KNOWN_AGENTS: Array<{ name: string; supportsAcp: boolean }> = [ const exec = promisify(execCb);
{ name: 'opencode', supportsAcp: true },
{ name: 'goose', supportsAcp: true }, const KNOWN_AGENTS = ['opencode', 'goose', 'claude', 'qwen'].map((name) => ({
{ name: 'claude', supportsAcp: false }, name,
{ name: 'pi', supportsAcp: false }, supportsAcp: PROVIDERS_BY_NAME.get(name)?.transport === 'acp',
{ name: 'qwen', supportsAcp: false }, }));
];
/** /**
* Probe for available agents on the HOST via SSH. * Probe for available agents on the HOST.
* *
* The boocoder container can't run agents locally — they live on the host. * v2.1.1: BooCoder runs on the host now — agents are local binaries,
* We SSH to the host (same mechanism BooTerm uses) and check which agent * no SSH needed. Direct `which` / `exec` calls.
* binaries are on PATH.
*/ */
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> { export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
log.info('agent-probe: scanning HOST for known agents via SSH'); log.info('agent-probe: scanning for known agents');
for (const agent of KNOWN_AGENTS) { for (const agent of KNOWN_AGENTS) {
try { try {
// Check if the agent binary is on the host's PATH const { stdout: whichOut } = await exec(`which ${agent.name}`, { timeout: 10_000 });
const whichResult = await sshExec(`which ${agent.name}`, { timeoutMs: 10_000 }); const installPath = whichOut.trim();
const installPath = whichResult.stdout.trim(); if (!installPath) continue;
if (whichResult.exitCode !== 0 || !installPath) continue;
// Get version
let version: string | null = null; let version: string | null = null;
try { try {
const verResult = await sshExec(`${agent.name} --version`, { timeoutMs: 15_000 }); const { stdout: verOut } = await exec(`${agent.name} --version`, { timeout: 15_000 });
if (verResult.exitCode === 0) { version = verOut.trim().slice(0, 100);
version = verResult.stdout.trim().slice(0, 100);
}
} catch { } catch {
// Some agents may not support --version — that's fine // Some agents may not support --version
} }
// For ACP-capable agents, verify ACP mode actually works
let supportsAcp = agent.supportsAcp; let supportsAcp = agent.supportsAcp;
if (supportsAcp) { if (supportsAcp) {
try { try {
const acpCheck = await sshExec(`${agent.name} acp --help`, { timeoutMs: 10_000 }); await exec(`${agent.name} acp --help`, { timeout: 10_000 });
supportsAcp = acpCheck.exitCode === 0;
} catch { } catch {
supportsAcp = false; supportsAcp = false;
} }
} }
// UPSERT into available_agents 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` await sql`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at) INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp()) VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${label}, ${transport})
ON CONFLICT (name) DO UPDATE SET ON CONFLICT (name) DO UPDATE SET
install_path = EXCLUDED.install_path, install_path = EXCLUDED.install_path,
version = EXCLUDED.version, version = EXCLUDED.version,
supports_acp = EXCLUDED.supports_acp, supports_acp = EXCLUDED.supports_acp,
last_probed_at = EXCLUDED.last_probed_at last_probed_at = EXCLUDED.last_probed_at,
models = EXCLUDED.models,
label = EXCLUDED.label,
transport = EXCLUDED.transport
`; `;
log.info({ agent: agent.name, version, installPath, supportsAcp }, 'agent-probe: found on host'); log.info({ agent: agent.name, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
} catch (err) { } catch (err) {
// SSH failed or agent not found — skip silently
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
log.debug({ agent: agent.name, err: msg }, 'agent-probe: not found or SSH failed'); log.debug({ agent: agent.name, err: msg }, 'agent-probe: not found');
} }
} }

View File

@@ -34,8 +34,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
if (running || stopping) return; if (running || stopping) return;
// Grab one pending task // Grab one pending task
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null }[]>` 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 SELECT id, project_id, input, agent, model, session_id
FROM tasks FROM tasks
WHERE state = 'pending' WHERE state = 'pending'
ORDER BY created_at ORDER BY created_at
@@ -51,16 +51,16 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
}); });
} }
async function runTask(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> { 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; const taskId = task.id;
// Determine execution path: if agent is specified AND exists in available_agents → Path B // Determine execution path: if agent is specified AND exists in available_agents → Path B
if (task.agent) { if (task.agent) {
const [agentRow] = await sql<{ name: string; supports_acp: boolean }[]>` const [agentRow] = await sql<{ name: string; supports_acp: boolean; install_path: string | null }[]>`
SELECT name, supports_acp FROM available_agents WHERE name = ${task.agent} SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
`; `;
if (agentRow) { if (agentRow) {
await runExternalAgent(task, agentRow.supports_acp); await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
return; return;
} }
// Agent specified but not available — fall through to Path A with a warning // Agent specified but not available — fall through to Path A with a warning
@@ -73,7 +73,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// ─── Path A: Native Inference ─────────────────────────────────────────────── // ─── Path A: Native Inference ───────────────────────────────────────────────
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> { 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; const taskId = task.id;
log.info({ taskId }, 'dispatcher: starting task (path A — native)'); log.info({ taskId }, 'dispatcher: starting task (path A — native)');
@@ -179,8 +179,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>───────────────────────────────── // ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>─────────────────────────────────
async function runExternalAgent( async function runExternalAgent(
task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }, task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null },
supportsAcp: boolean, supportsAcp: boolean,
installPath: string | null,
): Promise<void> { ): Promise<void> {
const taskId = task.id; const taskId = task.id;
const agent = task.agent!; const agent = task.agent!;
@@ -189,14 +190,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
log.info({ taskId, agent, executionPath }, 'dispatcher: starting task (path B — external)'); log.info({ taskId, agent, executionPath }, 'dispatcher: starting task (path B — external)');
// Resolve the project's root path // Resolve the project's root path
const [project] = await sql<{ root_path: string | null }[]>` const [project] = await sql<{ path: string | null }[]>`
SELECT root_path FROM projects WHERE id = ${task.project_id} SELECT path FROM projects WHERE id = ${task.project_id}
`; `;
const projectPath = project?.root_path; const projectPath = project?.path;
if (!projectPath) { if (!projectPath) {
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no root_path — cannot create worktree' SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
return; return;
@@ -213,30 +214,49 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
// Create session + chat for this task (same as Path A — for output tracking) let sessionId: string;
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`; let chatId: string;
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
`;
const sessionId = session!.id;
const [chat] = await sql<{ id: string }[]>` if (task.session_id) {
INSERT INTO chats (session_id, name, status) sessionId = task.session_id;
VALUES (${sessionId}, 'External agent execution', 'open') const chats = await sql<{ id: string }[]>`
RETURNING id SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
`; `;
const chatId = chat!.id; 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;
// Link task to session const [chat] = await sql<{ id: string }[]>`
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`; INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
// Create user message for the task input await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
await sql` }
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp()) 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 // Step 1: Create worktree
log.info({ taskId, projectPath }, 'dispatcher: creating worktree'); log.info({ taskId, projectPath }, 'dispatcher: creating worktree');
@@ -251,6 +271,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
agent, agent,
task: task.input, task: task.input,
worktreePath, worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined, model: task.model ?? undefined,
signal: ac.signal, signal: ac.signal,
log, log,
@@ -267,6 +288,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
agent, agent,
task: task.input, task: task.input,
worktreePath, worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined, model: task.model ?? undefined,
signal: ac.signal, signal: ac.signal,
log, log,

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

@@ -1,19 +1,18 @@
/** /**
* PTY dispatch — runs external agents on the host via SSH. * PTY dispatch — runs external agents directly on the host.
* *
* For agents without ACP support (claude, pi), we pipe the task into their * v2.1.3: Spawns agent binaries directly (no sh -c wrapper) using the
* non-interactive mode and capture stdout/stderr. The agent runs in a git * install_path from agent-probe. Follows Paseo's pattern: direct binary
* worktree so it can modify files freely. * path + args array + cwd.
* *
* Supported agents: * Supported agents:
* - claude: `claude -p --model <model>` (print mode, reads task from stdin) * - claude: `claude -p --model <model>` (print mode, reads task from stdin)
* - opencode: `echo <task> | opencode` (stdin pipe — exact flags TBD) * - opencode: `opencode --model <model>` (stdin pipe)
* - qwen: `qwen -p <task> --output-format stream-json` (NDJSON structured output) * - qwen: `qwen -p <task> --output-format stream-json`
* - goose: stub (not yet supported) * - goose: `goose run --text <task>`
* - pi: stub (not yet supported)
*/ */
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { sshSpawnWithStdin } from './ssh.js'; import { spawn } from 'node:child_process';
export interface DispatchResult { export interface DispatchResult {
exitCode: number; exitCode: number;
@@ -26,62 +25,61 @@ export interface PtyDispatchOpts {
task: string; task: string;
worktreePath: string; worktreePath: string;
model?: string; model?: string;
installPath?: string;
signal?: AbortSignal; signal?: AbortSignal;
log: FastifyBaseLogger; log: FastifyBaseLogger;
} }
/** interface AgentCommand {
* Build the shell command that runs the agent non-interactively. binary: string;
* The command will be executed inside `cd <worktreePath> && ...`. args: string[];
*/ stdin?: string;
function buildAgentCommand(agent: string, task: string, model?: string): string | null { }
// Escape the task for embedding in a shell command
const escapedTask = task.replace(/'/g, "'\\''"); function buildAgentCommand(agent: string, task: string, model?: string, installPath?: string): AgentCommand | null {
const binary = installPath ?? agent;
switch (agent) { switch (agent) {
case 'claude': case 'claude':
// Claude Code's print mode: reads prompt from stdin, runs autonomously, prints result return {
return model binary,
? `echo '${escapedTask}' | claude -p --model '${model}'` args: model ? ['-p', '--model', model] : ['-p'],
: `echo '${escapedTask}' | claude -p`; stdin: task,
};
case 'opencode': case 'opencode':
// opencode non-interactive: pipe task via stdin return {
// NOTE: exact flags may vary — opencode may need --non-interactive or --pipe binary,
return model args: model ? ['--model', model] : [],
? `echo '${escapedTask}' | opencode --model '${model}'` stdin: task,
: `echo '${escapedTask}' | opencode`; };
case 'qwen': case 'qwen':
// Qwen Code: structured JSON output mode for parseable events return {
return model binary,
? `qwen -p '${escapedTask}' --model '${model}' --output-format stream-json` args: model
: `qwen -p '${escapedTask}' --output-format stream-json`; ? ['-p', task, '--model', model, '--output-format', 'stream-json']
: ['-p', task, '--output-format', 'stream-json'],
};
case 'goose': case 'goose':
// Not yet verified for non-interactive use return {
return null; binary,
args: model
case 'pi': ? ['run', '--text', task, '--model', model]
// Not yet verified for non-interactive use : ['run', '--text', task],
return null; };
default: default:
return null; return null;
} }
} }
/**
* Dispatch a task to an external agent via SSH.
*
* The agent runs in the worktree directory on the host. stdout/stderr are
* captured in full and returned. The SSH process is killed on abort signal.
*/
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> { export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
const { agent, task, worktreePath, model, signal, log } = opts; const { agent, task, worktreePath, model, installPath, signal, log } = opts;
const agentCmd = buildAgentCommand(agent, task, model); const cmd = buildAgentCommand(agent, task, model, installPath);
if (!agentCmd) { if (!cmd) {
return { return {
exitCode: 1, exitCode: 1,
stdout: '', stdout: '',
@@ -89,22 +87,19 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
}; };
} }
// Wrap in cd to the worktree log.info({ agent, binary: cmd.binary, worktreePath }, 'pty-dispatch: starting');
const fullCommand = `cd '${worktreePath.replace(/'/g, "'\\''")}' && ${agentCmd}`;
log.info({ agent, worktreePath }, 'pty-dispatch: starting');
return new Promise<DispatchResult>((resolve, reject) => { return new Promise<DispatchResult>((resolve, reject) => {
const child = sshSpawnWithStdin(fullCommand, ''); const child = spawn(cmd.binary, cmd.args, {
// Note: sshSpawnWithStdin already closes stdin. For agents that read from cwd: worktreePath,
// stdin via echo piping, the command itself handles the piping on the remote stdio: ['pipe', 'pipe', 'pipe'],
// side. We just need the SSH tunnel. env: { ...process.env },
});
// Actually, re-think: sshSpawnWithStdin writes input and closes stdin on the if (cmd.stdin) {
// LOCAL ssh process. But the remote command is `echo '...' | agent`, which child.stdin!.write(cmd.stdin);
// provides its own stdin. So we should use sshSpawn (no local stdin needed) }
// or just let the empty stdin close — the remote shell handles piping internally. child.stdin!.end();
// This is fine as-is because the echo piping happens WITHIN the remote shell command.
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
@@ -117,7 +112,6 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
if (!killed) { if (!killed) {
killed = true; killed = true;
child.kill('SIGTERM'); child.kill('SIGTERM');
// Give it a moment then force-kill
setTimeout(() => child.kill('SIGKILL'), 5_000); setTimeout(() => child.kill('SIGKILL'), 5_000);
} }
}; };

View File

@@ -1,4 +1,7 @@
/** /**
* @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. * SSH helper — spawns commands on the host via SSH.
* *
* BooCode's container cannot directly spawn host processes (opencode, goose, claude, pi). * BooCode's container cannot directly spawn host processes (opencode, goose, claude, pi).

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

@@ -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(() => {
endRef.current?.scrollIntoView({ block: 'end' }); if (isNearBottomRef.current) {
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

@@ -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