Compare commits

..

1 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
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.
## 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).

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.
- **`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/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`.
### BooCoder (`apps/coder/src/`)
- Write-capable coding agent. Separate Fastify server at port 9502, same docker network (`boocode_net`).
- **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).
- 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 `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/`)
@@ -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).
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
@@ -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).
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. 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`.
- 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).
@@ -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.
- 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.
- **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 { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js';
import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js';
@@ -145,6 +146,7 @@ async function main() {
registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql);
registerProviderRoutes(app, sql, config);
registerWebSocket(app, sql, broker);
// Serve static frontend (built web app). In production, the dist/ is

View File

@@ -6,7 +6,9 @@ import type { WsFrame } from '@boocode/server/ws-frames';
const SendBody = z.object({
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 {
@@ -32,73 +34,104 @@ export function registerMessageRoutes(
}
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
const sessionRows = await sql<{ id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId}
const sessionRows = await sql<{ id: string; project_id: string }[]>`
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
// Validate chat belongs to session and is open
const chatRows = await sql<{ id: string; session_id: string }[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
// Resolve chat_id: use explicit value or find/create a default chat
let chatId: string;
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) {
reply.code(404);
return { error: 'chat not found or not open in this session' };
}
await sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
// 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 + 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
// Publish user message frames
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: result.user_message_id,
message_id: userMsg!.id,
chat_id: chatId,
role: 'user',
} as unknown as WsFrame);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: result.user_message_id,
message_id: userMsg!.id,
chat_id: chatId,
content,
} as unknown as WsFrame);
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: result.user_message_id,
message_id: userMsg!.id,
chat_id: chatId,
} as unknown as WsFrame);
// Enqueue inference — the runner will stream assistant deltas via broker
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
if (isExternal) {
// External provider: create a task for the dispatcher
const projectId = sessionRows[0]!.project_id;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, 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);
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
CREATE OR REPLACE VIEW human_inbox AS
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
-- v2.1.0: provider picker — extend available_agents with model discovery.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';

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
* with the agent subprocess. The SSH tunnel provides stdio transport.
* 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. SSH to host, start `opencode acp` (or `goose acp`) in the worktree
* 2. Wrap SSH child's stdin/stdout into NDJSON streams
* 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)
@@ -28,7 +28,7 @@ import {
type CreateTerminalRequest,
type CreateTerminalResponse,
} from '@agentclientprotocol/sdk';
import { sshSpawn } from './ssh.js';
import { spawn } from 'node:child_process';
export interface AcpDispatchResult {
exitCode: number;
@@ -42,17 +42,17 @@ export interface AcpDispatchOpts {
task: string;
worktreePath: string;
model?: string;
installPath?: string;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
/** Map agent name to the ACP command it exposes. */
function acpCommand(agent: string): string | null {
function acpArgs(agent: string): string[] | null {
switch (agent) {
case 'opencode':
return 'opencode acp';
return ['acp'];
case 'goose':
return 'goose acp';
return ['acp'];
default:
return null;
}
@@ -114,10 +114,10 @@ function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Ui
* all session updates. Returns the collected output and tool calls.
*/
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);
if (!cmd) {
const args = acpArgs(agent);
if (!args) {
return {
exitCode: 1,
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 escapedPath = worktreePath.replace(/'/g, "'\\''");
const fullCommand = `cd '${escapedPath}' && ${cmd}`;
log.info({ agent, worktreePath }, 'acp-dispatch: spawning');
const child = sshSpawn(fullCommand);
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;

View File

@@ -1,69 +1,91 @@
import type { Sql } from '../db.js';
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 }> = [
{ name: 'opencode', supportsAcp: true },
{ name: 'goose', supportsAcp: true },
{ name: 'claude', supportsAcp: false },
{ name: 'pi', supportsAcp: false },
{ name: 'qwen', supportsAcp: false },
];
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 via SSH.
* Probe for available agents on the HOST.
*
* The boocoder container can't run agents locally — they live on the host.
* We SSH to the host (same mechanism BooTerm uses) and check which agent
* binaries are on PATH.
* 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 HOST for known agents via SSH');
log.info('agent-probe: scanning for known agents');
for (const agent of KNOWN_AGENTS) {
try {
// Check if the agent binary is on the host's PATH
const whichResult = await sshExec(`which ${agent.name}`, { timeoutMs: 10_000 });
const installPath = whichResult.stdout.trim();
if (whichResult.exitCode !== 0 || !installPath) continue;
const { stdout: whichOut } = await exec(`which ${agent.name}`, { timeout: 10_000 });
const installPath = whichOut.trim();
if (!installPath) continue;
// Get version
let version: string | null = null;
try {
const verResult = await sshExec(`${agent.name} --version`, { timeoutMs: 15_000 });
if (verResult.exitCode === 0) {
version = verResult.stdout.trim().slice(0, 100);
}
const { stdout: verOut } = await exec(`${agent.name} --version`, { timeout: 15_000 });
version = verOut.trim().slice(0, 100);
} 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;
if (supportsAcp) {
try {
const acpCheck = await sshExec(`${agent.name} acp --help`, { timeoutMs: 10_000 });
supportsAcp = acpCheck.exitCode === 0;
await exec(`${agent.name} acp --help`, { timeout: 10_000 });
} catch {
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`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at)
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp())
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
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) {
// SSH failed or agent not found — skip silently
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;
// Grab one pending task
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null }[]>`
SELECT id, project_id, input, agent, model
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
@@ -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;
// 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 }[]>`
SELECT name, supports_acp FROM available_agents WHERE name = ${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);
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
return;
}
// 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 ───────────────────────────────────────────────
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;
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>─────────────────────────────────
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,
installPath: string | null,
): Promise<void> {
const taskId = task.id;
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)');
// Resolve the project's root path
const [project] = await sql<{ root_path: string | null }[]>`
SELECT root_path FROM projects WHERE id = ${task.project_id}
const [project] = await sql<{ path: string | null }[]>`
SELECT path FROM projects WHERE id = ${task.project_id}
`;
const projectPath = project?.root_path;
const projectPath = project?.path;
if (!projectPath) {
await sql`
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}
`;
return;
@@ -213,30 +214,49 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId}
`;
// Create session + chat for this task (same as Path A — for output tracking)
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
`;
const sessionId = session!.id;
let sessionId: string;
let chatId: string;
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
const chatId = chat!.id;
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;
// Link task to session
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
const [chat] = await sql<{ id: string }[]>`
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`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
`;
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');
@@ -251,6 +271,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
agent,
task: task.input,
worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined,
signal: ac.signal,
log,
@@ -267,6 +288,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
agent,
task: task.input,
worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined,
signal: ac.signal,
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
* non-interactive mode and capture stdout/stderr. The agent runs in a git
* worktree so it can modify files freely.
* 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: `echo <task> | opencode` (stdin pipe — exact flags TBD)
* - qwen: `qwen -p <task> --output-format stream-json` (NDJSON structured output)
* - goose: stub (not yet supported)
* - pi: stub (not yet supported)
* - 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 { sshSpawnWithStdin } from './ssh.js';
import { spawn } from 'node:child_process';
export interface DispatchResult {
exitCode: number;
@@ -26,62 +25,61 @@ export interface PtyDispatchOpts {
task: string;
worktreePath: string;
model?: string;
installPath?: string;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
/**
* Build the shell command that runs the agent non-interactively.
* The command will be executed inside `cd <worktreePath> && ...`.
*/
function buildAgentCommand(agent: string, task: string, model?: string): string | null {
// Escape the task for embedding in a shell command
const escapedTask = task.replace(/'/g, "'\\''");
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':
// Claude Code's print mode: reads prompt from stdin, runs autonomously, prints result
return model
? `echo '${escapedTask}' | claude -p --model '${model}'`
: `echo '${escapedTask}' | claude -p`;
return {
binary,
args: model ? ['-p', '--model', model] : ['-p'],
stdin: task,
};
case 'opencode':
// opencode non-interactive: pipe task via stdin
// NOTE: exact flags may vary — opencode may need --non-interactive or --pipe
return model
? `echo '${escapedTask}' | opencode --model '${model}'`
: `echo '${escapedTask}' | opencode`;
return {
binary,
args: model ? ['--model', model] : [],
stdin: task,
};
case 'qwen':
// Qwen Code: structured JSON output mode for parseable events
return model
? `qwen -p '${escapedTask}' --model '${model}' --output-format stream-json`
: `qwen -p '${escapedTask}' --output-format stream-json`;
return {
binary,
args: model
? ['-p', task, '--model', model, '--output-format', 'stream-json']
: ['-p', task, '--output-format', 'stream-json'],
};
case 'goose':
// Not yet verified for non-interactive use
return null;
case 'pi':
// Not yet verified for non-interactive use
return null;
return {
binary,
args: model
? ['run', '--text', task, '--model', model]
: ['run', '--text', task],
};
default:
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> {
const { agent, task, worktreePath, model, signal, log } = opts;
const { agent, task, worktreePath, model, installPath, signal, log } = opts;
const agentCmd = buildAgentCommand(agent, task, model);
if (!agentCmd) {
const cmd = buildAgentCommand(agent, task, model, installPath);
if (!cmd) {
return {
exitCode: 1,
stdout: '',
@@ -89,22 +87,19 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
};
}
// Wrap in cd to the worktree
const fullCommand = `cd '${worktreePath.replace(/'/g, "'\\''")}' && ${agentCmd}`;
log.info({ agent, worktreePath }, 'pty-dispatch: starting');
log.info({ agent, binary: cmd.binary, worktreePath }, 'pty-dispatch: starting');
return new Promise<DispatchResult>((resolve, reject) => {
const child = sshSpawnWithStdin(fullCommand, '');
// Note: sshSpawnWithStdin already closes stdin. For agents that read from
// stdin via echo piping, the command itself handles the piping on the remote
// side. We just need the SSH tunnel.
const child = spawn(cmd.binary, cmd.args, {
cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
// Actually, re-think: sshSpawnWithStdin writes input and closes stdin on the
// LOCAL ssh process. But the remote command is `echo '...' | agent`, which
// 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.
// This is fine as-is because the echo piping happens WITHIN the remote shell command.
if (cmd.stdin) {
child.stdin!.write(cmd.stdin);
}
child.stdin!.end();
let stdout = '';
let stderr = '';
@@ -117,7 +112,6 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
if (!killed) {
killed = true;
child.kill('SIGTERM');
// Give it a moment then force-kill
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.
*
* 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> {
const sessionRows = await sql<Session[]>`
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}
`;
if (sessionRows.length === 0) return null;

View File

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

View File

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

View File

@@ -206,6 +206,19 @@ export interface ModelInfo {
[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 {
id: 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 { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup';
@@ -142,13 +142,26 @@ function stampCapHits(items: RenderItem[]): RenderItem[] {
});
}
const SCROLL_THRESHOLD_PX = 150;
export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
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(() => {
endRef.current?.scrollIntoView({ block: 'end' });
if (isNearBottomRef.current) {
endRef.current?.scrollIntoView({ block: 'end' });
}
}, [messages]);
if (messages.length === 0) {
@@ -160,7 +173,7 @@ export function MessageList({ messages, sessionChats }: Props) {
}
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">
{renderItems.map((item) => {
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 { Code, Send, Check, X, RefreshCw } from 'lucide-react';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
import { ProviderPicker } from '@/components/ProviderPicker';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
@@ -300,6 +301,8 @@ export function CoderPane({ sessionId }: Props) {
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
const [input, setInput] = useState('');
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 inputRef = useRef<HTMLTextAreaElement>(null);
@@ -331,7 +334,11 @@ export function CoderPane({ sessionId }: Props) {
const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, {
method: 'POST',
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) {
const data = await res.json();
@@ -347,7 +354,7 @@ export function CoderPane({ sessionId }: Props) {
} finally {
setSending(false);
}
}, [input, sending, sessionId, setMessages]);
}, [input, sending, sessionId, provider, model, setMessages]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -363,11 +370,18 @@ export function CoderPane({ sessionId }: Props) {
<div className="flex flex-col h-full bg-background">
{/* Header */}
<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" />
<span className="text-xs font-medium text-muted-foreground">BooCoder</span>
<Code size={14} className="text-muted-foreground shrink-0" />
<ProviderPicker
provider={provider}
model={model}
onChange={(prov, mod) => {
setProvider(prov);
setModel(mod);
}}
/>
<span
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'
)}
title={connected ? 'Connected' : 'Disconnected'}

View File

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