diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f642cd..91dbaf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/CLAUDE.md b/CLAUDE.md index 8a7acd3..a73f303 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 "" --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 `. 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` doesn't enforce column coverage. diff --git a/apps/coder/.env.host b/apps/coder/.env.host new file mode 100644 index 0000000..46900c9 --- /dev/null +++ b/apps/coder/.env.host @@ -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 diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index 9b60999..6345e55 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -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 diff --git a/apps/coder/src/routes/messages.ts b/apps/coder/src/routes/messages.ts index 2468f4e..8dc9f06 100644 --- a/apps/coder/src/routes/messages.ts +++ b/apps/coder/src/routes/messages.ts @@ -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 }; }, ); diff --git a/apps/coder/src/routes/providers.ts b/apps/coder/src/routes/providers.ts new file mode 100644 index 0000000..f7bc3b9 --- /dev/null +++ b/apps/coder/src/routes/providers.ts @@ -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 { + 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; + }); +} diff --git a/apps/coder/src/schema.sql b/apps/coder/src/schema.sql index 2b313e7..8473ccd 100644 --- a/apps/coder/src/schema.sql +++ b/apps/coder/src/schema.sql @@ -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'; diff --git a/apps/coder/src/services/acp-dispatch.ts b/apps/coder/src/services/acp-dispatch.ts index 3e05725..a4497f3 100644 --- a/apps/coder/src/services/acp-dispatch.ts +++ b/apps/coder/src/services/acp-dispatch.ts @@ -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 { - 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 = [ - { 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 { - 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'); } } diff --git a/apps/coder/src/services/dispatcher.ts b/apps/coder/src/services/dispatcher.ts index e3c6197..c3bb1c1 100644 --- a/apps/coder/src/services/dispatcher.ts +++ b/apps/coder/src/services/dispatcher.ts @@ -34,8 +34,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise` - 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 { + async function runTask(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise { 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 { + async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise { 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 { const taskId = task.id; const agent = task.agent!; @@ -189,14 +190,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise` - 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` - 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; +} + +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])); diff --git a/apps/coder/src/services/pty-dispatch.ts b/apps/coder/src/services/pty-dispatch.ts index d77ac49..ff07e0b 100644 --- a/apps/coder/src/services/pty-dispatch.ts +++ b/apps/coder/src/services/pty-dispatch.ts @@ -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 ` (print mode, reads task from stdin) - * - opencode: `echo | opencode` (stdin pipe — exact flags TBD) - * - qwen: `qwen -p --output-format stream-json` (NDJSON structured output) - * - goose: stub (not yet supported) - * - pi: stub (not yet supported) + * - opencode: `opencode --model ` (stdin pipe) + * - qwen: `qwen -p --output-format stream-json` + * - goose: `goose run --text ` */ 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 && ...`. - */ -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 { - 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((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 child.kill('SIGKILL'), 5_000); } }; diff --git a/apps/coder/src/services/ssh.ts b/apps/coder/src/services/ssh.ts index 277f007..d457b35 100644 --- a/apps/coder/src/services/ssh.ts +++ b/apps/coder/src/services/ssh.ts @@ -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). diff --git a/apps/server/src/services/inference/payload.ts b/apps/server/src/services/inference/payload.ts index 0e7f56c..602ff50 100644 --- a/apps/server/src/services/inference/payload.ts +++ b/apps/server/src/services/inference/payload.ts @@ -142,7 +142,7 @@ export async function loadContext( ): Promise<{ session: Session; project: Project; history: Message[] } | null> { const sessionRows = await sql` 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; diff --git a/apps/server/src/services/inference/sentinel-summaries.ts b/apps/server/src/services/inference/sentinel-summaries.ts index 29c1564..9890337 100644 --- a/apps/server/src/services/inference/sentinel-summaries.ts +++ b/apps/server/src/services/inference/sentinel-summaries.ts @@ -36,6 +36,8 @@ export async function runCapHitSummary( ): Promise { 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) { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 620891d..1ead2df 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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('/api/models'), + coder: { + providers: () => request('/api/coder/providers'), + }, + agents: { list: (projectId: string) => request(`/api/projects/${projectId}/agents`), diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index ce491ab..eee145e 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -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; diff --git a/apps/web/src/components/MessageList.tsx b/apps/web/src/components/MessageList.tsx index 6335ff6..38ce79a 100644 --- a/apps/web/src/components/MessageList.tsx +++ b/apps/web/src/components/MessageList.tsx @@ -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(null); + const scrollContainerRef = useRef(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 ( -
+
{renderItems.map((item) => { if (item.kind === 'message') { diff --git a/apps/web/src/components/ProviderPicker.tsx b/apps/web/src/components/ProviderPicker.tsx new file mode 100644 index 0000000..3897779 --- /dev/null +++ b/apps/web/src/components/ProviderPicker.tsx @@ -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; +} + +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
{error}
; + } + if (providers === null) { + return
Loading...
; + } + + const singleProvider = providers.length === 1; + + return ( + <> + {providers.map((p) => ( +
+ {!singleProvider && ( +
+ {p.label} +
+ )} + {p.models.map((m) => ( + + ))} +
+ ))} + + ); +} + +export function ProviderPicker({ provider, model, onChange }: Props) { + const { isMobile } = useViewport(); + const [providers, setProviders] = useState(null); + const [error, setError] = useState(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 ( + <> + + setOpen(false)} title="Provider / Model"> +
+ +
+
+ + ); + } + + return ( + + + + + + {error && ( +
{error}
+ )} + {providers === null && !error && ( +
Loading...
+ )} + {providers && providers.map((p) => { + const singleProvider = providers.length === 1; + return ( +
+ {!singleProvider && ( +
+ {p.label} +
+ )} + {p.models.map((m) => ( + handlePick(p.name, m.id)} + className="font-mono text-xs" + > + + {m.label} + + ))} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/panes/CoderPane.tsx b/apps/web/src/components/panes/CoderPane.tsx index 1eadeba..f1365a5 100644 --- a/apps/web/src/components/panes/CoderPane.tsx +++ b/apps/web/src/components/panes/CoderPane.tsx @@ -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(null); const inputRef = useRef(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) {
{/* Header */}
- - BooCoder + + { + setProvider(prov); + setModel(mod); + }} + />