v2.2-paseo-providers: Paseo provider stack + v2.2.1 pane-scoped chat fixes

Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch
rewrite with streaming/persist, permission prompts, and agent commands.
Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline,
WS user-delta replace, and inference orphan tool_call stripping.
Archive openspec v2-2; update CHANGELOG and CURRENT.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 15:18:31 +00:00
parent 04673eaf59
commit 93d3f86c2b
96 changed files with 6694 additions and 1329 deletions

View File

@@ -1,6 +1,6 @@
NODE_ENV=production NODE_ENV=production
PORT=3000 PORT=3000
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boochat
LLAMA_SWAP_URL=http://100.101.41.16:8401 LLAMA_SWAP_URL=http://100.101.41.16:8401
PROJECT_ROOT_WHITELIST=/opt PROJECT_ROOT_WHITELIST=/opt
BOOTSTRAP_ROOT=/opt/projects BOOTSTRAP_ROOT=/opt/projects

5
.gitignore vendored
View File

@@ -1,6 +1,11 @@
node_modules node_modules
dist dist
.env .env
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
.claude/
.cursor/
.cursorignore
CLAUDE.local.md CLAUDE.local.md
*.log *.log
.DS_Store .DS_Store

109
AGENTS.md Normal file
View File

@@ -0,0 +1,109 @@
# Agent navigation
Cursor/agent entry point for the BooCode monorepo. **Deep engineering reference:** `CLAUDE.md` (Claude Code). This file is navigation + task routing only.
Last updated: 2026-05-25
## Doc map
| Need | Read |
|------|------|
| Commands, gotchas, inference, DB, env | `CLAUDE.md` |
| Read-only chat behavior | `BOOCHAT.md` |
| Write tools, dispatch, pending changes | `BOOCODER.md` |
| Shipped vs planned, version order | `boocode_roadmap.md` |
| Latest release truth | `CHANGELOG.md` (top entry = current) |
| System diagram + data flow | `docs/ARCHITECTURE.md` |
| Current focus / blockers | `CURRENT.md` |
| Batch convention | `openspec/README.md` |
| Shipped batch snapshots | `openspec/changes/archived/` |
| Chat agent personas + tool lists | `data/AGENTS.md` |
| External repo lift inventory | `boocode_code_review.md` |
## Monorepo layout (actual)
Three **surfaces**, four **packages**. There is no `apps/chat/` directory.
| Surface | Packages | Port | Deploy |
|---------|----------|------|--------|
| **BooChat** | `apps/server` (API + inference) + `apps/web` (SPA) | `100.114.205.53:9500` | Docker `boocode` container |
| **BooTerm** | `apps/booterm` | `100.114.205.53:9501` | Docker `booterm` container |
| **BooCoder** | `apps/coder` | host `:9502` | systemd `boocoder.service` (not Docker since v2.1.0) |
Shared: Postgres 16 — Docker service `boocode_db`, **database name `boochat`**, host port `127.0.0.1:5500`.
## Task routing
| Task type | Start here |
|-----------|------------|
| Chat inference / tools / compaction | `apps/server/src/services/inference/` |
| WS frames | `apps/server/src/types/ws-frames.ts` + `apps/web/src/api/ws-frames.ts` (keep in sync) |
| Frontend chat UI | `apps/web/src/components/`, hooks in `apps/web/src/hooks/` |
| BooCoder write tools / dispatch | `apps/coder/src/` — build server first (`pnpm -C apps/server build`) |
| Provider picker / external agents | `apps/coder/src/services/provider-registry.ts`, `dispatcher.ts`, `agent-probe.ts` |
| Terminal panes | `apps/booterm/src/`, frontend `TerminalPane.tsx` |
| Schema changes | `apps/server/src/schema.sql` + sync `*_STATUSES` in `apps/server/src/types/api.ts` |
| New batch / feature | `openspec/changes/<slug>/proposal.md` + `tasks.md` (see below) |
## Verification (before claiming done)
```bash
pnpm -C apps/server test && pnpm -C apps/server build
npx tsc -p apps/web/tsconfig.app.json --noEmit # root tsc can miss web errors
curl http://100.114.205.53:9500/api/health # Tailscale IP, not localhost:9500
curl http://100.114.205.53:9502/api/health # BooCoder on host
```
Deploy truth beats source-only reads — check running health + `git log --oneline -3`.
## Hard rules (from CLAUDE.md)
- **Do not commit or push** unless Sam explicitly asks.
- **No app-layer auth** — Authelia at the reverse proxy.
- **Parts table is source of truth** — read message tool fields from `messages_with_parts` view, write via `insertParts`.
- **New WS frame type** — update server + web schemas; publish via `publishFrame` / `publishUserFrame` only.
- **New tool** — own file in `services/`, register in `tools.ts` `ALL_TOOLS`; whitelists derive from there, never hardcoded.
- **Typecheck web with per-app tsconfig** — root `tsc --noEmit` uses project references and can miss errors.
- **`includeUsage: true`** on `createOpenAICompatible` in `provider.ts` — do not remove.
- **Agent dispatch** — direct `spawn`/`exec` on host via `install_path` (v2.1.0+); SSH helpers deprecated.
- **Event dedup** — server publishes via broker; frontend must not duplicate `sessionEvents.emit` after API calls that already WS-broadcast.
## Using openspec with Cursor
Openspec is a **folder convention**, not a CLI. Use it to give agents a scoped brief before coding.
### When starting a batch
1. Create `openspec/changes/<slug>/` (lowercase-hyphenated, e.g. `v2-2-arena-ui`).
2. Write `proposal.md` — why, scope, non-goals, dependencies.
3. Write `tasks.md` — numbered checkbox steps (build + smoke).
4. Optional `design.md` — schema/API decisions that outlive the batch.
See `openspec/README.md` for the full shape. Shipped pre-v1.13.15 batches live in `openspec/changes/archived/` as snapshots only.
### Prompting an agent
```
@openspec/changes/<slug>/proposal.md @openspec/changes/<slug>/tasks.md
Implement tasks 13. Server tests must pass. Do not commit.
```
Attach the spec files with `@` so they load into context. Point at specific code paths when known:
```
@openspec/changes/v2-x/proposal.md
Extend apps/coder/src/routes/providers.ts — follow provider-registry.ts patterns.
```
### After shipping
- Tag: `vMAJOR.MINOR.PATCH-slug`
- Add entry to top of `CHANGELOG.md`
- Move or snapshot the openspec folder to `archived/` if you want history preserved
- Update `CURRENT.md` and `boocode_roadmap.md` shipped table if the batch was roadmap-tracked
### What not to use openspec for
- One-line bug fixes — just describe the bug + file.
- Exploratory questions — Ask mode + `@CLAUDE.md` is enough.
- Duplicating `CLAUDE.md` — openspec is per-batch scope, not permanent conventions.

View File

@@ -2,6 +2,18 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.2.1-pane-scoped-chats — 2026-05-26
Follow-up fixes on the v2.2 Paseo provider stack. Pane-scoped chat resolution: `resolveChatId(sql, sessionId, paneId)` reads `sessions.workspace_panes`, requires `pane_id` on coder POST routes, and creates a scoped chat per coder/terminal pane instead of falling back to the session's first open chat (which fused BooCoder writes into the BooChat pane). Client `useWorkspacePanes` seeds new coder/terminal panes with dedicated chats on create, hydrate, and workspace sync; `CoderPane` blocks send until seeded and filters WS frames + `GET /messages?chat_id=` to that chat. External-agent tool UI: new `CoderMessageList` renders BooChat-style `ToolCallLine` timeline (tools before answer text on combined ACP rows). WS user-delta handling replaces content instead of appending (fixes garbled duplicate user messages when optimistic UI met full-body deltas). BooChat inference: `buildMessagesPayload` strips orphan assistant `tool_calls` without matching `tool` rows and skips stray tool rows when the owning assistant turn is incomplete (fixes "Tool results are missing for tool calls" on shared chats with ACP history). Pairs with `v2.2-paseo-providers`.
## v2.2-paseo-providers — 2026-05-26
Paseo-equivalent provider stack for BooCoder. Seven providers (boocode, cursor, claude, opencode, goose, qwen, copilot) with snapshot API (`provider-snapshot.ts`, ACP cold probe, per-provider model merge, cursor models from ACP). Frontend `AgentComposerBar` replaces `ProviderPicker` — provider / mode / model / thinking in the coder composer; `SlashCommandPicker` + `useProviderSnapshot` hook. ACP dispatch rewritten (`acp-dispatch.ts`, `acp-stream.ts`, `acp-spawn.ts`, `agent-turn-persist.ts`, `acp-tool-snapshot.ts`) with Paseo merge/stream/persist pattern, inline `PermissionCard` prompts, and `reasoning_delta` WS frames. Agent slash-command hints via ACP `available_commands_update` cached in `agent-commands-cache.ts` + `AgentCommandsHint`. Arena and MCP entry points accept `mode_id` / `thinking_option_id`. SSH helpers removed; all host exec via `host-exec.ts` direct spawn. Server adds coder proxy route + shared skill invoke. New tests: acp-derive, acp-tool-snapshot, cursor-models, provider-commands, provider-snapshot, agents. Docs: `AGENTS.md`, `docs/ARCHITECTURE.md`, openspec `v2-2-paseo-providers`.
## v2.1.1-roadmap-cleanup — 2026-05-25
Roadmap reconciliation, README updates, and openspec archive housekeeping. No runtime behavior changes.
## v2.1.0-provider-picker — 2026-05-25 ## 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. 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.

View File

@@ -2,6 +2,8 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Cursor agents:** start with `AGENTS.md` (navigation) and `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference.
## What is BooCode ## What is BooCode
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side). Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
@@ -133,7 +135,7 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
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}`. 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. - `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. - Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 required on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — non-interactive mode (`-p`) runs autonomously without approval prompts. ACP bridge is HTTP daemon (not stdio); use PTY dispatch.
- Arena (v2.0.5): `POST /api/arena {project_id, input, contestants: [{agent?, model?}]}` dispatches the same task to N models/agents in parallel. Each contestant gets its own task + worktree. `GET /api/arena/:id` for results. `POST /api/arena/:id/select/:task_id` picks winner. - Arena (v2.0.5): `POST /api/arena {project_id, input, contestants: [{agent?, model?}]}` dispatches the same task to N models/agents in parallel. Each contestant gets its own task + worktree. `GET /api/arena/:id` for results. `POST /api/arena/:id/select/:task_id` picks winner.
## Workflow ## Workflow

10
CURRENT.md Normal file
View File

@@ -0,0 +1,10 @@
# Current focus
Last updated: 2026-05-26
- **Batch:** v2.3-provider-lifecycle (openspec drafted; not started)
- **Branch:** `main`
- **Blockers:** none
- **Last shipped:** `v2.2.1-pane-scoped-chats` (pairs with `v2.2-paseo-providers` on same commit)
Update this file when starting or finishing a batch. Agents: read this first for session intent; if stale vs `CHANGELOG.md`, trust CHANGELOG for shipped state.

View File

@@ -2,6 +2,8 @@
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals). Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals).
**Agent navigation:** [`AGENTS.md`](AGENTS.md) · **Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md)
## Stack ## Stack
- Node 20, Fastify, postgres (porsager/postgres), ws, zod - Node 20, Fastify, postgres (porsager/postgres), ws, zod
@@ -30,7 +32,7 @@ cp .env.example .env
docker compose up -d boocode_db docker compose up -d boocode_db
# run server (port 3000) and web (port 5173) in two shells # run server (port 3000) and web (port 5173) in two shells
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boocode \ DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat \
LLAMA_SWAP_URL=http://100.101.41.16:8401 \ LLAMA_SWAP_URL=http://100.101.41.16:8401 \
pnpm dev:server pnpm dev:server
@@ -58,8 +60,8 @@ upstream and inject `Remote-User`. Postgres binds loopback only.
|BooChat|`100.114.205.53:9500`|Read-only chat + SPA | |BooChat|`100.114.205.53:9500`|Read-only chat + SPA |
|BooTerm|`100.114.205.53:9501`|PTY/tmux terminal panes | |BooTerm|`100.114.205.53:9501`|PTY/tmux terminal panes |
|BooCoder|host:9502|Write tools + agent dispatch + MCP server (systemd service, not Docker) | |BooCoder|host:9502|Write tools + agent dispatch + MCP server (systemd service, not Docker) |
|Postgres|`127.0.0.1:5500`|Shared database (`boochat_db`) | |Postgres|`127.0.0.1:5500`|Shared database (`boochat`; Docker service `boocode_db`) |
|codecontext|`:8765` (internal)|MCP server for architect tools | |codecontext|internal `:8080`|Code graph sidecar (Docker network only) |
## What's shipped ## What's shipped

View File

@@ -12,3 +12,4 @@ GITEA_BASE_URL=https://git.indifferentketchup.com
GITEA_USER=indifferentketchup GITEA_USER=indifferentketchup
GITEA_SSH_HOST=100.114.205.53:2222 GITEA_SSH_HOST=100.114.205.53:2222
MCP_CONFIG_PATH=/data/mcp.json MCP_CONFIG_PATH=/data/mcp.json
SKILLS_ROOT=/opt/boocode/data/skills

View File

@@ -23,6 +23,7 @@ import { adaptWriteTool } from './services/tools/adapter.js';
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js'; import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
// Routes // Routes
import { registerMessageRoutes } from './routes/messages.js'; import { registerMessageRoutes } from './routes/messages.js';
import { registerSkillRoutes } from './routes/skills.js';
import { registerPendingRoutes } from './routes/pending.js'; import { registerPendingRoutes } from './routes/pending.js';
import { registerTaskRoutes } from './routes/tasks.js'; import { registerTaskRoutes } from './routes/tasks.js';
import { registerInboxRoutes } from './routes/inbox.js'; import { registerInboxRoutes } from './routes/inbox.js';
@@ -33,6 +34,9 @@ import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe // Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js'; import { createDispatcher } from './services/dispatcher.js';
import { probeAgents } from './services/agent-probe.js'; import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
import { setPermissionHooks } from './services/permission-waiter.js';
import { homedir } from 'node:os';
async function main() { async function main() {
// MCP mode: stdio transport, no HTTP server // MCP mode: stdio transport, no HTTP server
@@ -72,6 +76,31 @@ async function main() {
// Broker: in-memory pub/sub for session + user channel streaming. // Broker: in-memory pub/sub for session + user channel streaming.
const broker = createBroker(app.log); const broker = createBroker(app.log);
setPermissionHooks({
onPrompt: async (prompt) => {
await sql`
UPDATE tasks SET state = 'blocked' WHERE id = ${prompt.taskId} AND state = 'running'
`;
broker.publishFrame(prompt.sessionId, {
type: 'permission_requested',
task_id: prompt.taskId,
session_id: prompt.sessionId,
tool_title: prompt.toolTitle,
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
} as WsFrame);
},
onResolved: async (taskId, sessionId) => {
await sql`
UPDATE tasks SET state = 'running' WHERE id = ${taskId} AND state = 'blocked'
`;
broker.publishFrame(sessionId, {
type: 'permission_resolved',
task_id: taskId,
session_id: sessionId,
} as WsFrame);
},
});
// --- Tool registry extension --- // --- Tool registry extension ---
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to // Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds // the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
@@ -134,6 +163,16 @@ async function main() {
// Phase 4: probe available agents on startup // Phase 4: probe available agents on startup
await probeAgents(sql, app.log); await probeAgents(sql, app.log);
// Warm provider snapshot in background (ACP cold probes + model merges)
void getProviderSnapshot(sql, config, homedir(), true)
.then((entries) => persistProbedModels(sql, entries, app.log))
.catch((err) => {
app.log.warn(
{ err: err instanceof Error ? err.message : String(err) },
'provider-snapshot: warm failed',
);
});
// Phase 4: dispatcher — polls tasks table and runs inference // Phase 4: dispatcher — polls tasks table and runs inference
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config }); const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
dispatcher.start(); dispatcher.start();
@@ -141,6 +180,7 @@ async function main() {
// Register routes // Register routes
registerMessageRoutes(app, sql, broker, inferenceApi); registerMessageRoutes(app, sql, broker, inferenceApi);
registerSkillRoutes(app, sql, broker, inferenceApi);
registerPendingRoutes(app, sql); registerPendingRoutes(app, sql);
registerTaskRoutes(app, sql, inferenceApi); registerTaskRoutes(app, sql, inferenceApi);
registerInboxRoutes(app, sql); registerInboxRoutes(app, sql);

View File

@@ -12,6 +12,8 @@ import type { Sql } from '../db.js';
const ContestantSchema = z.object({ const ContestantSchema = z.object({
agent: z.string().max(100).optional(), agent: z.string().max(100).optional(),
model: z.string().max(200).optional(), model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
}); });
const CreateArenaBody = z.object({ const CreateArenaBody = z.object({
@@ -24,6 +26,8 @@ interface TaskRow {
id: string; id: string;
agent: string | null; agent: string | null;
model: string | null; model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
state: string; state: string;
} }
@@ -42,9 +46,17 @@ export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
const tasks: TaskRow[] = []; const tasks: TaskRow[] = [];
for (const contestant of contestants) { for (const contestant of contestants) {
const [task] = await sql<TaskRow[]>` const [task] = await sql<TaskRow[]>`
INSERT INTO tasks (project_id, input, agent, model, arena_id) INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, arena_id)
VALUES (${project_id}, ${input}, ${contestant.agent ?? null}, ${contestant.model ?? null}, ${arenaId}) VALUES (
RETURNING id, agent, model, state ${project_id},
${input},
${contestant.agent ?? null},
${contestant.model ?? null},
${contestant.mode_id ?? null},
${contestant.thinking_option_id ?? null},
${arenaId}
)
RETURNING id, agent, model, mode_id, thinking_option_id, state
`; `;
tasks.push(task!); tasks.push(task!);
} }
@@ -52,10 +64,12 @@ export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
reply.code(201); reply.code(201);
return { return {
arena_id: arenaId, arena_id: arenaId,
tasks: tasks.map(t => ({ tasks: tasks.map((t) => ({
id: t.id, id: t.id,
agent: t.agent, agent: t.agent,
model: t.model, model: t.model,
mode_id: t.mode_id,
thinking_option_id: t.thinking_option_id,
state: t.state, state: t.state,
})), })),
}; };
@@ -73,7 +87,7 @@ export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
} }
const tasks = await sql` const tasks = await sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at, arena_id SELECT id, project_id, state, input, output_summary, agent, model, mode_id, thinking_option_id, execution_path, session_id, started_at, ended_at, created_at, arena_id
FROM tasks FROM tasks
WHERE arena_id = ${arena_id} WHERE arena_id = ${arena_id}
ORDER BY created_at ORDER BY created_at

View File

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

View File

@@ -3,12 +3,16 @@ import { z } from 'zod';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames'; import type { WsFrame } from '@boocode/server/ws-frames';
import { resolveChatId } from './chat-resolve.js';
const SendBody = z.object({ const SendBody = z.object({
content: z.string().min(1).max(64_000), content: z.string().min(1).max(64_000),
pane_id: z.string().min(1).max(200),
chat_id: z.string().uuid().optional(), chat_id: z.string().uuid().optional(),
provider: z.string().max(100).optional(), provider: z.string().max(100).optional(),
model: z.string().max(200).optional(), model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
}); });
interface InferenceApi { interface InferenceApi {
@@ -17,12 +21,100 @@ interface InferenceApi {
hasActive: (chatId: string) => boolean; hasActive: (chatId: string) => boolean;
} }
interface MessageRow {
id: string;
role: string;
content: string | null;
status: string | null;
tool_calls: Array<{ id: string; name: string; args?: Record<string, unknown> }> | null;
tool_results: {
tool_call_id: string;
output: unknown;
truncated?: boolean;
error?: string;
} | null;
reasoning_parts: Array<{ text?: string }> | null;
}
function mapCoderMessageRow(row: MessageRow) {
if (row.role === 'tool') {
if (!row.tool_results?.tool_call_id) return null;
return {
id: row.id,
role: 'tool' as const,
tool_results: row.tool_results,
};
}
if (row.role !== 'user' && row.role !== 'assistant' && row.role !== 'system') {
return null;
}
const tool_calls = row.tool_calls?.map((tc) => ({
id: tc.id,
function: {
name: tc.name,
arguments: JSON.stringify(tc.args ?? {}),
},
}));
const reasoningText = row.reasoning_parts?.map((p) => p.text ?? '').join('') ?? '';
return {
id: row.id,
role: row.role as 'user' | 'assistant' | 'system',
content: row.content ?? '',
status: (row.status ?? 'complete') as 'streaming' | 'complete' | 'failed',
...(reasoningText ? { reasoning_text: reasoningText } : {}),
...(tool_calls?.length ? { tool_calls } : {}),
};
}
export function registerMessageRoutes( export function registerMessageRoutes(
app: FastifyInstance, app: FastifyInstance,
sql: Sql, sql: Sql,
broker: Broker, broker: Broker,
inference: InferenceApi, inference: InferenceApi,
): void { ): void {
// GET /api/sessions/:sessionId/messages — hydrate CoderPane on load / reconnect
app.get<{ Params: { sessionId: string }; Querystring: { chat_id?: string } }>(
'/api/sessions/:sessionId/messages',
async (req, reply) => {
const sessionId = req.params.sessionId;
const chatId = req.query.chat_id;
const sessionRows = await sql<{ id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
if (chatId) {
const chatRows = await sql<{ id: string }[]>`
SELECT id FROM chats
WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found or not open in this session' };
}
}
const rows = chatId
? await sql<MessageRow[]>`
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
FROM messages_with_parts
WHERE session_id = ${sessionId} AND chat_id = ${chatId}
ORDER BY created_at ASC, id ASC
`
: await sql<MessageRow[]>`
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
FROM messages_with_parts
WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC
`;
return rows.map(mapCoderMessageRow).filter((m) => m !== null);
},
);
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference // POST /api/sessions/:sessionId/messages — send a user message + kick off inference
app.post<{ Params: { sessionId: string } }>( app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/messages', '/api/sessions/:sessionId/messages',
@@ -34,7 +126,8 @@ export function registerMessageRoutes(
} }
const sessionId = req.params.sessionId; const sessionId = req.params.sessionId;
const { content, chat_id: explicitChatId, provider, model } = parsed.data; const { content, pane_id, chat_id: explicitChatId, provider, model, mode_id, thinking_option_id } =
parsed.data;
const isExternal = provider && provider !== 'boocode'; const isExternal = provider && provider !== 'boocode';
// Validate session exists // Validate session exists
@@ -46,8 +139,13 @@ export function registerMessageRoutes(
return { error: 'session not found' }; return { error: 'session not found' };
} }
// Resolve chat_id: use explicit value or find/create a default chat const resolved = await resolveChatId(sql, sessionId, pane_id);
let chatId: string; if (!resolved) {
reply.code(404);
return { error: 'pane not found' };
}
let chatId = resolved;
if (explicitChatId) { if (explicitChatId) {
const chatRows = await sql<{ id: string }[]>` const chatRows = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE id = ${explicitChatId} AND session_id = ${sessionId} AND status = 'open' SELECT id FROM chats WHERE id = ${explicitChatId} AND session_id = ${sessionId} AND status = 'open'
@@ -57,20 +155,6 @@ export function registerMessageRoutes(
return { error: 'chat not found or not open in this session' }; return { error: 'chat not found or not open in this session' };
} }
chatId = explicitChatId; 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) { if (!isExternal) {
@@ -113,8 +197,8 @@ export function registerMessageRoutes(
// External provider: create a task for the dispatcher // External provider: create a task for the dispatcher
const projectId = sessionRows[0]!.project_id; const projectId = sessionRows[0]!.project_id;
const [task] = await sql<{ id: string; state: string }[]>` const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, session_id) INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${sessionId}) VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
RETURNING id, state RETURNING id, state
`; `;
reply.code(202); reply.code(202);

View File

@@ -1,80 +1,17 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { PROVIDERS } from '../services/provider-registry.js'; import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.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 { export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
app.get('/api/providers', async (_req, _reply) => { app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
const llamaModels = await fetchLlamaSwapModels(config); const cwd = req.query.cwd;
return getProviderSnapshot(sql, config, cwd);
});
const agents = await sql<{ name: string; models: ProviderModel[]; label: string | null; transport: string | null; supports_acp: boolean }[]>` app.post('/api/providers/refresh', async (_req, _reply) => {
SELECT name, models, label, transport, supports_acp FROM available_agents clearProviderSnapshotCache();
`; const entries = await getProviderSnapshot(sql, config, undefined, true);
const agentMap = new Map(agents.map((a) => [a.name, a])); return { refreshed: entries.length };
const result: ProviderResponse[] = [];
for (const provider of PROVIDERS) {
const isNative = provider.name === 'boocode';
const agentRow = agentMap.get(provider.name);
const installed = isNative || !!agentRow;
if (!installed) continue;
let models: ProviderModel[];
if (provider.modelSource === 'llama-swap') {
models = llamaModels;
} else if (agentRow?.models && agentRow.models.length > 0) {
models = agentRow.models;
} else if (provider.staticModels) {
models = provider.staticModels;
} else {
models = [];
}
let transport: string = provider.transport;
if (agentRow) {
transport = provider.transport === 'acp' && !agentRow.supports_acp ? 'pty' : provider.transport;
}
result.push({
name: provider.name,
label: agentRow?.label ?? provider.label,
transport,
installed,
models,
});
}
return result;
}); });
} }

View File

@@ -0,0 +1,93 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import { getSkillBody } from '@boocode/server/skills';
import {
buildSkillInvokeSyntheticFrames,
buildSkillInvokeUserFrames,
DEFAULT_SKILL_USER_MESSAGE,
runSkillInvokeTransaction,
} from '@boocode/server/skill-invoke';
import { resolveChatId } from './chat-resolve.js';
const SkillInvokeBody = z.object({
pane_id: z.string().min(1).max(200),
skill_name: z.string().min(1),
user_message: z.string().max(64_000).nullable().optional(),
});
interface InferenceApi {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
hasActive: (chatId: string) => boolean;
}
export function registerSkillRoutes(
app: FastifyInstance,
sql: Sql,
broker: Broker,
inference: InferenceApi,
): void {
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/skill_invoke',
async (req, reply) => {
const parsed = SkillInvokeBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const sessionId = req.params.sessionId;
const { pane_id, skill_name } = parsed.data;
const sessionRows = await sql<{ id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const chatId = await resolveChatId(sql, sessionId, pane_id);
if (!chatId) {
reply.code(404);
return { error: 'pane not found' };
}
if (inference.hasActive(chatId)) {
reply.code(409);
return { error: 'inference already running on this chat' };
}
const userText = parsed.data.user_message?.trim()
? parsed.data.user_message
: DEFAULT_SKILL_USER_MESSAGE;
const body = await getSkillBody(skill_name);
if (body === null) {
reply.code(404);
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
}
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
sessionId,
chatId,
skillName: skill_name,
skillBody: body,
userText,
});
for (const frame of buildSkillInvokeSyntheticFrames(chatId, result, toolCall, body)) {
broker.publishFrame(sessionId, frame as WsFrame);
}
for (const frame of buildSkillInvokeUserFrames(chatId, result.user_message_id, userText)) {
broker.publishFrame(sessionId, frame as WsFrame);
}
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
}

View File

@@ -1,6 +1,8 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import { getPendingPermission, respondToPermission, cancelPendingPermission } from '../services/permission-waiter.js';
import { getTaskCommands } from '../services/agent-commands-cache.js';
interface InferenceApi { interface InferenceApi {
cancel: (sessionId: string, chatId: string) => Promise<boolean>; cancel: (sessionId: string, chatId: string) => Promise<boolean>;
@@ -11,6 +13,12 @@ const CreateBody = z.object({
input: z.string().min(1).max(64_000), input: z.string().min(1).max(64_000),
agent: z.string().max(100).optional(), agent: z.string().max(100).optional(),
model: z.string().max(200).optional(), model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
});
const PermissionBody = z.object({
option_id: z.string().max(200).nullable(),
}); });
const ListQuery = z.object({ const ListQuery = z.object({
@@ -27,11 +35,11 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
return { error: 'invalid body', details: parsed.error.flatten() }; return { error: 'invalid body', details: parsed.error.flatten() };
} }
const { project_id, input, agent, model } = parsed.data; const { project_id, input, agent, model, mode_id, thinking_option_id } = parsed.data;
const [task] = await sql<{ id: string; state: string }[]>` const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model) INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id)
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}) VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null})
RETURNING id, state RETURNING id, state
`; `;
@@ -111,13 +119,15 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
} }
const task = rows[0]!; const task = rows[0]!;
if (task.state !== 'pending' && task.state !== 'running') { if (task.state !== 'pending' && task.state !== 'running' && task.state !== 'blocked') {
reply.code(409); reply.code(409);
return { error: `cannot cancel task in state '${task.state}'` }; return { error: `cannot cancel task in state '${task.state}'` };
} }
cancelPendingPermission(taskId);
// If running, try to cancel inference // If running, try to cancel inference
if (task.state === 'running' && task.session_id) { if ((task.state === 'running' || task.state === 'blocked') && task.session_id) {
// Find active chat in the task's session // Find active chat in the task's session
const chats = await sql<{ id: string }[]>` const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open' SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
@@ -130,9 +140,45 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'cancelled', ended_at = clock_timestamp() SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId} AND state IN ('pending', 'running') WHERE id = ${taskId} AND state IN ('pending', 'running', 'blocked')
`; `;
return { cancelled: true }; return { cancelled: true };
}); });
// GET /api/tasks/:id/permission — pending permission prompt (if any)
app.get<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
const prompt = getPendingPermission(req.params.id);
if (!prompt) {
reply.code(404);
return { error: 'no pending permission' };
}
return prompt;
});
// POST /api/tasks/:id/permission — respond to a pending permission prompt
app.post<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
const parsed = PermissionBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const ok = respondToPermission(req.params.id, parsed.data.option_id);
if (!ok) {
reply.code(404);
return { error: 'no pending permission' };
}
return { ok: true };
});
// GET /api/tasks/:id/commands — cached ACP slash commands (if any)
app.get<{ Params: { id: string } }>('/api/tasks/:id/commands', async (req, reply) => {
const commands = getTaskCommands(req.params.id);
if (!commands?.length) {
reply.code(404);
return { error: 'no commands cached' };
}
return { taskId: req.params.id, commands };
});
} }

View File

@@ -25,7 +25,7 @@ export function registerWebSocket(
// Send snapshot of existing messages so client can hydrate // Send snapshot of existing messages so client can hydrate
const messages = await sql<Record<string, unknown>[]>` const messages = await sql<Record<string, unknown>[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, reasoning_parts, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
summary, tail_start_id, compacted_at summary, tail_start_id, compacted_at
FROM messages_with_parts FROM messages_with_parts

View File

@@ -66,3 +66,8 @@ CREATE OR REPLACE VIEW human_inbox AS
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb; 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 label TEXT;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty'; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
-- v2.2.0: Paseo-style session config on tasks.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { parseCursorAgentModelsOutput } from '../cursor-models.js';
describe('parseCursorAgentModelsOutput', () => {
it('parses cursor-agent models output with default marker', () => {
const output = `
Available models
claude-4-sonnet - Claude 4 Sonnet (default)
gpt-4.1 - GPT-4.1
Tip: use cursor-agent models for full list
`.trim();
const models = parseCursorAgentModelsOutput(output);
expect(models).toEqual([
{ id: 'claude-4-sonnet', label: 'Claude 4 Sonnet', isDefault: true },
{ id: 'gpt-4.1', label: 'GPT-4.1', isDefault: false },
]);
});
it('uses current marker when no default', () => {
const output = `
model-a - Model A (current)
model-b - Model B
`.trim();
const models = parseCursorAgentModelsOutput(output);
expect(models.find((m) => m.id === 'model-a')?.isDefault).toBe(true);
expect(models.find((m) => m.id === 'model-b')?.isDefault).toBe(false);
});
it('defaults to first model when no markers', () => {
const output = 'alpha - Alpha\nbeta - Beta';
const models = parseCursorAgentModelsOutput(output);
expect(models[0]?.isDefault).toBe(true);
expect(models[1]?.isDefault).toBe(false);
});
it('skips malformed lines', () => {
const output = 'no-separator\nvalid - Valid';
const models = parseCursorAgentModelsOutput(output);
expect(models).toEqual([{ id: 'valid', label: 'Valid', isDefault: true }]);
});
});

View File

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

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
mergeModels,
prefixLlamaSwapModels,
clearProviderSnapshotCache,
getProviderSnapshot,
} from '../provider-snapshot.js';
vi.mock('../acp-probe.js', () => ({
probeAcpProvider: vi.fn(),
}));
import { probeAcpProvider } from '../acp-probe.js';
const mockProbe = vi.mocked(probeAcpProvider);
function mockSql(agents: Array<{
name: string;
install_path: string | null;
supports_acp: boolean;
models: Array<{ id: string; label: string }> | null;
label: string | null;
transport: string | null;
}>) {
return vi.fn((strings: TemplateStringsArray) => {
const query = strings.join('');
if (query.includes('FROM available_agents')) {
return Promise.resolve(agents);
}
if (query.includes('UPDATE available_agents')) {
return Promise.resolve([]);
}
return Promise.resolve([]);
}) as unknown as import('../db.js').Sql;
}
const config = {
LLAMA_SWAP_URL: 'http://llama-swap.test',
} as import('../config.js').Config;
describe('prefixLlamaSwapModels', () => {
it('prefixes bare ids', () => {
expect(prefixLlamaSwapModels([{ id: 'qwen3', label: 'qwen3' }])).toEqual([
{ id: 'llama-swap/qwen3', label: 'qwen3' },
]);
});
it('leaves already-prefixed ids unchanged', () => {
expect(prefixLlamaSwapModels([{ id: 'llama-swap/qwen3', label: 'qwen3' }])).toEqual([
{ id: 'llama-swap/qwen3', label: 'qwen3' },
]);
});
});
describe('mergeModels', () => {
it('dedupes by id preserving first occurrence', () => {
const merged = mergeModels(
[{ id: 'a', label: 'A' }],
[{ id: 'a', label: 'A2' }, { id: 'b', label: 'B' }],
);
expect(merged).toEqual([
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
]);
});
});
describe('getProviderSnapshot', () => {
beforeEach(() => {
clearProviderSnapshotCache();
vi.restoreAllMocks();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: 'local-model' }, { id: 'llama-swap/existing' }],
}),
}),
);
});
it('merges opencode ACP models with prefixed llama-swap models', async () => {
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'opencode/big-pickle', label: 'Big Pickle', isDefault: true }],
modes: [{ id: 'build', label: 'Build' }],
defaultModeId: 'build',
commands: [{ name: 'custom', description: 'From ACP probe' }],
});
const sql = mockSql([
{
name: 'opencode',
install_path: '/usr/bin/opencode',
supports_acp: true,
models: null,
label: 'OpenCode',
transport: 'acp',
},
]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const opencode = entries.find((e) => e.name === 'opencode');
expect(opencode?.models.map((m) => m.id)).toEqual([
'opencode/big-pickle',
'llama-swap/local-model',
'llama-swap/existing',
]);
expect(opencode?.commands.some((c) => c.name === 'help')).toBe(true);
expect(opencode?.commands.some((c) => c.name === 'custom')).toBe(true);
});
it('combines qwen-shaped probe and settings model lists via mergeModels', () => {
const merged = mergeModels(
[{ id: 'qwen-probed', label: 'Qwen Probed' }],
[{ id: 'from-settings', label: 'from-settings' }],
);
expect(merged.map((m) => m.id)).toEqual(['qwen-probed', 'from-settings']);
});
it('returns cached entries on second call within TTL', async () => {
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'm1', label: 'M1' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: null,
label: 'Goose',
transport: 'acp',
},
]);
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
expect(mockProbe).toHaveBeenCalledTimes(1);
});
it('attaches claude thinking options', async () => {
const sql = mockSql([
{
name: 'claude',
install_path: '/usr/bin/claude',
supports_acp: false,
models: [{ id: 'claude-sonnet', label: 'Sonnet' }],
label: 'Claude Code',
transport: 'pty',
},
]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const claude = entries.find((e) => e.name === 'claude');
expect(claude?.models[0]?.thinkingOptions?.length).toBeGreaterThan(0);
expect(claude?.modes.length).toBeGreaterThan(0);
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
});
});

View File

@@ -0,0 +1,35 @@
import { promises as fs } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
export async function readWorktreeTextFile(
worktreePath: string,
filePath: string,
line?: number | null,
limit?: number | null,
): Promise<string> {
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
if (!absolute.startsWith(resolve(worktreePath))) {
throw new Error(`path escapes worktree: ${filePath}`);
}
const raw = await fs.readFile(absolute, 'utf8');
if (!line && !limit) return raw;
const lines = raw.split(/\r?\n/);
const start = Math.max((line ?? 1) - 1, 0);
const end = limit ? start + limit : undefined;
return lines.slice(start, end).join('\n');
}
/** Write a file inside the worktree (creates parent dirs). */
export async function writeWorktreeTextFile(
worktreePath: string,
filePath: string,
content: string,
): Promise<void> {
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
if (!absolute.startsWith(resolve(worktreePath))) {
throw new Error(`path escapes worktree: ${filePath}`);
}
await fs.mkdir(dirname(absolute), { recursive: true });
await fs.writeFile(absolute, content, 'utf8');
}

View File

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

View File

@@ -1,22 +1,12 @@
/** /**
* ACP dispatch — runs ACP-capable agents (opencode, goose) directly on the host. * ACP dispatch — runs ACP-capable agents directly on the host.
* *
* v2.1.1: BooCoder runs on the host now — agents are spawned directly, * v2.3: Paseo-aligned tool lifecycle — stable toolCallId, merge on
* no SSH needed. Uses @agentclientprotocol/sdk for structured JSON-RPC. * tool_call_update, reasoning stream, worktree FS client, persist-ready snapshots.
*
* Flow:
* 1. Spawn `opencode acp` (or `goose acp`) in the worktree
* 2. Wrap child's stdin/stdout into NDJSON streams
* 3. Create a ClientSideConnection from the SDK
* 4. Initialize → newSession → prompt(task)
* 5. Collect session updates (tool calls, text output)
* 6. On prompt completion → return collected output
*/ */
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { Readable, Writable } from 'node:stream';
import { import {
ClientSideConnection, ClientSideConnection,
ndJsonStream,
type Client, type Client,
type SessionNotification, type SessionNotification,
type RequestPermissionRequest, type RequestPermissionRequest,
@@ -27,13 +17,30 @@ import {
type WriteTextFileResponse, type WriteTextFileResponse,
type CreateTerminalRequest, type CreateTerminalRequest,
type CreateTerminalResponse, type CreateTerminalResponse,
type SessionConfigOption,
type ClientSideConnection as ConnectionType,
} from '@agentclientprotocol/sdk'; } from '@agentclientprotocol/sdk';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { findThoughtLevelConfigId } from './acp-derive.js';
import { resolveAcpSpawnArgs } from './acp-spawn.js';
import { createAcpNdJsonStream } from './acp-stream.js';
import { waitForPermissionResponse, cancelPendingPermission } from './permission-waiter.js';
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
import {
type AcpToolSnapshot,
mergeToolSnapshot,
snapshotToWireToolCall,
synthesizeCanceledSnapshots,
} from './acp-tool-snapshot.js';
export interface AcpDispatchResult { export interface AcpDispatchResult {
exitCode: number; exitCode: number;
output: string; output: string;
toolCalls: Array<{ title: string; input: unknown; output?: unknown }>; toolSnapshots: AcpToolSnapshot[];
reasoningText: string;
stopReason: string; stopReason: string;
} }
@@ -42,212 +49,316 @@ export interface AcpDispatchOpts {
task: string; task: string;
worktreePath: string; worktreePath: string;
model?: string; model?: string;
modeId?: string;
thinkingOptionId?: string;
taskId?: string;
sessionId?: string;
chatId?: string;
messageId?: string;
broker?: Broker;
installPath?: string; installPath?: string;
signal?: AbortSignal; signal?: AbortSignal;
log: FastifyBaseLogger; log: FastifyBaseLogger;
} }
function acpArgs(agent: string): string[] | null { async function applySessionOverrides(
switch (agent) { connection: ConnectionType,
case 'opencode': acpSessionId: string,
return ['acp']; configOptions: SessionConfigOption[] | null | undefined,
case 'goose': opts: Pick<AcpDispatchOpts, 'model' | 'modeId' | 'thinkingOptionId' | 'log'>,
return ['acp']; ): Promise<void> {
const { model, modeId, thinkingOptionId, log } = opts;
if (modeId) {
try {
await connection.setSessionMode({ sessionId: acpSessionId, modeId });
} catch (err) {
log.warn({ modeId, err: err instanceof Error ? err.message : String(err) }, 'acp-dispatch: setSessionMode failed');
}
}
if (model) {
try {
await connection.unstable_setSessionModel({ sessionId: acpSessionId, modelId: model });
} catch (err) {
log.warn({ model, err: err instanceof Error ? err.message : String(err) }, 'acp-dispatch: setSessionModel failed');
}
}
if (thinkingOptionId) {
const configId = findThoughtLevelConfigId(configOptions);
if (configId) {
try {
await connection.setSessionConfigOption({
sessionId: acpSessionId,
configId,
value: thinkingOptionId,
});
} catch (err) {
log.warn(
{ thinkingOptionId, err: err instanceof Error ? err.message : String(err) },
'acp-dispatch: setSessionConfigOption failed',
);
}
}
}
}
class AcpStreamContext {
readonly textChunks: string[] = [];
readonly reasoningChunks: string[] = [];
readonly toolSnapshots = new Map<string, AcpToolSnapshot>();
private aborted = false;
constructor(
private readonly opts: Pick<
AcpDispatchOpts,
'broker' | 'sessionId' | 'chatId' | 'messageId' | 'taskId'
>,
private readonly worktreePath: string,
) {}
get reasoningText(): string {
return this.reasoningChunks.join('');
}
get output(): string {
return this.textChunks.join('');
}
get snapshots(): AcpToolSnapshot[] {
return [...this.toolSnapshots.values()];
}
markAborted(): void {
this.aborted = true;
for (const snap of synthesizeCanceledSnapshots(this.toolSnapshots.values())) {
this.toolSnapshots.set(snap.toolCallId, snap);
this.publishToolSnapshot(snap);
}
}
private canStream(): boolean {
return !!(this.opts.broker && this.opts.sessionId && this.opts.chatId && this.opts.messageId);
}
private publishToolSnapshot(snapshot: AcpToolSnapshot): void {
if (!this.canStream()) return;
const wire = snapshotToWireToolCall(snapshot);
this.opts.broker!.publishFrame(this.opts.sessionId!, {
type: 'tool_call',
message_id: this.opts.messageId!,
chat_id: this.opts.chatId!,
tool_call: wire,
} as WsFrame);
}
handleToolUpdate(toolCallId: string, update: Parameters<typeof mergeToolSnapshot>[1]): void {
const previous = this.toolSnapshots.get(toolCallId);
const snapshot = mergeToolSnapshot(toolCallId, update, previous);
this.toolSnapshots.set(toolCallId, snapshot);
this.publishToolSnapshot(snapshot);
}
async handleSessionUpdate(params: SessionNotification): Promise<void> {
const update = params.update;
switch (update.sessionUpdate) {
case 'agent_message_chunk': {
const content = update.content;
if (content.type === 'text' && 'text' in content) {
const text = (content as { text: string }).text;
this.textChunks.push(text);
if (this.canStream()) {
this.opts.broker!.publishFrame(this.opts.sessionId!, {
type: 'delta',
message_id: this.opts.messageId!,
chat_id: this.opts.chatId!,
content: text,
} as WsFrame);
}
}
break;
}
case 'agent_thought_chunk': {
const content = update.content;
if (content.type === 'text' && 'text' in content) {
const text = (content as { text: string }).text;
this.reasoningChunks.push(text);
if (this.canStream()) {
this.opts.broker!.publishFrame(this.opts.sessionId!, {
type: 'reasoning_delta',
message_id: this.opts.messageId!,
chat_id: this.opts.chatId!,
content: text,
} as WsFrame);
}
}
break;
}
case 'tool_call':
this.handleToolUpdate(update.toolCallId, update);
break;
case 'tool_call_update':
this.handleToolUpdate(update.toolCallId, update);
break;
case 'available_commands_update': {
const commands = update.availableCommands.map((cmd) => ({
name: cmd.name,
description: cmd.description ?? undefined,
}));
if (this.opts.taskId && commands.length > 0) {
mergeTaskCommands(this.opts.taskId, commands);
if (this.canStream() && this.opts.sessionId) {
const all = getTaskCommands(this.opts.taskId) ?? commands;
this.opts.broker!.publishFrame(this.opts.sessionId, {
type: 'agent_commands',
task_id: this.opts.taskId,
session_id: this.opts.sessionId,
commands: all,
} as WsFrame);
}
}
break;
}
default: default:
return null; break;
} }
} }
/** buildClient(agent: string, modeId: string | undefined, taskId: string | undefined, sessionId: string | undefined): Client {
* Convert a Node.js Readable stream to a web ReadableStream<Uint8Array>. return {
*/ sessionUpdate: (params) => this.handleSessionUpdate(params),
function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Uint8Array> { requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
return new ReadableStream<Uint8Array>({ if (taskId && sessionId) {
start(controller) { return waitForPermissionResponse(taskId, sessionId, agent, modeId, params);
nodeStream.on('data', (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk));
});
nodeStream.on('end', () => {
controller.close();
});
nodeStream.on('error', (err) => {
controller.error(err);
});
},
cancel() {
if ('destroy' in nodeStream && typeof (nodeStream as Readable).destroy === 'function') {
(nodeStream as Readable).destroy();
} }
const firstOption = params.options[0];
if (firstOption) {
return { outcome: { outcome: 'selected', optionId: firstOption.optionId } };
}
return { outcome: { outcome: 'cancelled' } };
}, },
}); readTextFile: async (params: ReadTextFileRequest): Promise<ReadTextFileResponse> => {
const content = await readWorktreeTextFile(
this.worktreePath,
params.path,
params.line,
params.limit,
);
return { content };
},
writeTextFile: async (params: WriteTextFileRequest): Promise<WriteTextFileResponse> => {
await writeWorktreeTextFile(this.worktreePath, params.path, params.content);
return {};
},
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
return { terminalId: 'noop' };
},
};
}
} }
/**
* Convert a Node.js Writable stream to a web WritableStream<Uint8Array>.
*/
function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
const ok = (nodeStream as Writable).write(chunk, (err) => {
if (err) reject(err);
});
if (ok) resolve();
else (nodeStream as Writable).once('drain', resolve);
});
},
close() {
return new Promise<void>((resolve) => {
(nodeStream as Writable).end(resolve);
});
},
abort() {
(nodeStream as Writable).destroy();
},
});
}
/**
* Dispatch a task to an ACP-capable agent via SSH.
*
* Opens a structured ACP session, sends the task as a prompt, and collects
* all session updates. Returns the collected output and tool calls.
*/
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> { export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
const { agent, task, worktreePath, installPath, signal, log } = opts; const {
agent,
task,
worktreePath,
installPath,
signal,
log,
taskId,
modeId,
sessionId,
chatId,
messageId,
broker,
} = opts;
const args = acpArgs(agent); const args = resolveAcpSpawnArgs(agent);
if (!args) { if (!args) {
return { return {
exitCode: 1, exitCode: 1,
output: `Agent '${agent}' does not support ACP.`, output: `Agent '${agent}' does not support ACP.`,
toolCalls: [], toolSnapshots: [],
reasoningText: '',
stopReason: 'error', stopReason: 'error',
}; };
} }
const binary = installPath ?? agent; const binary = installPath ?? agent;
log.info({ agent, binary, worktreePath }, 'acp-dispatch: spawning'); log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
const child = spawn(binary, args, { const child = spawn(binary, args, {
cwd: worktreePath, cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env }, env: { ...process.env },
}); });
// Wire up abort const streamCtx = new AcpStreamContext(
{ broker, sessionId, chatId, messageId, taskId },
worktreePath,
);
let killed = false; let killed = false;
const cleanup = () => { const cleanup = () => {
if (!killed) { if (!killed) {
killed = true; killed = true;
streamCtx.markAborted();
child.kill('SIGTERM'); child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 5_000); setTimeout(() => child.kill('SIGKILL'), 5_000);
} }
if (taskId) cancelPendingPermission(taskId);
}; };
if (signal) { if (signal) {
if (signal.aborted) { if (signal.aborted) {
cleanup(); cleanup();
return { exitCode: 130, output: 'Aborted before start', toolCalls: [], stopReason: 'cancelled' }; return {
exitCode: 130,
output: 'Aborted before start',
toolSnapshots: streamCtx.snapshots,
reasoningText: '',
stopReason: 'cancelled',
};
} }
signal.addEventListener('abort', cleanup, { once: true }); signal.addEventListener('abort', cleanup, { once: true });
} }
try { try {
// Create web streams from the child process stdio const stream = createAcpNdJsonStream(child);
const inputStream = nodeReadableToWeb(child.stdout!);
const outputStream = nodeWritableToWeb(child.stdin!);
// Create the NDJSON ACP stream
const stream = ndJsonStream(outputStream, inputStream);
// Collected session updates
const textChunks: string[] = [];
const toolCalls: Array<{ title: string; input: unknown; output?: unknown }> = [];
// Create client-side connection — we are the "client" (editor), the agent is remote
const connection = new ClientSideConnection( const connection = new ClientSideConnection(
(_agentInterface): Client => ({ () => streamCtx.buildClient(agent, modeId, taskId, sessionId),
// Handle session updates from the agent
async sessionUpdate(params: SessionNotification): Promise<void> {
const update = params.update;
if (update.sessionUpdate === 'agent_message_chunk') {
// ContentChunk with content: ContentBlock
const content = update.content;
if (content.type === 'text' && 'text' in content) {
textChunks.push((content as { text: string }).text);
}
} else if (update.sessionUpdate === 'tool_call') {
toolCalls.push({
title: update.title,
input: update.rawInput,
});
} else if (update.sessionUpdate === 'tool_call_update') {
const last = toolCalls[toolCalls.length - 1];
if (last && update.rawOutput !== undefined) {
last.output = update.rawOutput;
}
}
},
// Permission requests — auto-approve by selecting the first option (worktree is isolated)
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
// Select the first available option to auto-approve
const firstOption = params.options[0];
if (firstOption) {
return {
outcome: { outcome: 'selected', optionId: firstOption.optionId },
};
}
// No options available — cancel
return { outcome: { outcome: 'cancelled' } };
},
// File system operations — let the agent handle them directly in the worktree
async readTextFile(_params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return { content: '' };
},
async writeTextFile(_params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return {};
},
async createTerminal(_params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
return { terminalId: 'noop' };
},
}),
stream, stream,
); );
// Initialize the connection await connection.initialize({
// ProtocolVersion is a number in this SDK version
const initResult = await connection.initialize({
protocolVersion: 1, protocolVersion: 1,
clientInfo: { name: 'boocoder', version: '2.0.1' }, clientInfo: { name: 'boocoder', version: '2.3.0' },
clientCapabilities: {}, clientCapabilities: {},
}); });
log.info({ agentInfo: initResult.agentInfo }, 'acp-dispatch: initialized');
// Create a new session const acpSession = await connection.newSession({ cwd: worktreePath, mcpServers: [] });
const session = await connection.newSession({ log.info({ sessionId: acpSession.sessionId }, 'acp-dispatch: session created');
cwd: worktreePath,
mcpServers: [], await applySessionOverrides(connection, acpSession.sessionId, acpSession.configOptions, opts);
});
log.info({ sessionId: session.sessionId }, 'acp-dispatch: session created');
// Send the prompt
const promptResult = await connection.prompt({ const promptResult = await connection.prompt({
sessionId: session.sessionId, sessionId: acpSession.sessionId,
prompt: [{ type: 'text', text: task }], prompt: [{ type: 'text', text: task }],
}); });
const stopReason = promptResult.stopReason ?? 'end_turn'; const stopReason = promptResult.stopReason ?? 'end_turn';
log.info({ agent, stopReason, toolCallCount: toolCalls.length }, 'acp-dispatch: prompt completed'); log.info(
{ agent, stopReason, toolCallCount: streamCtx.snapshots.length, reasoningChars: streamCtx.reasoningText.length },
'acp-dispatch: prompt completed',
);
// Clean shutdown await connection.closeSession({ sessionId: acpSession.sessionId }).catch(() => {});
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
return { return {
exitCode: 0, exitCode: 0,
output: textChunks.join(''), output: streamCtx.output,
toolCalls, toolSnapshots: streamCtx.snapshots,
reasoningText: streamCtx.reasoningText,
stopReason, stopReason,
}; };
} catch (err) { } catch (err) {
@@ -256,14 +367,13 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
return { return {
exitCode: 1, exitCode: 1,
output: message, output: message,
toolCalls: [], toolSnapshots: streamCtx.snapshots,
reasoningText: streamCtx.reasoningText,
stopReason: 'error', stopReason: 'error',
}; };
} finally { } finally {
if (signal) signal.removeEventListener('abort', cleanup); if (signal) signal.removeEventListener('abort', cleanup);
cleanup(); cleanup();
// Wait for child to exit
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
child.on('close', resolve); child.on('close', resolve);
setTimeout(resolve, 3_000); setTimeout(resolve, 3_000);

View File

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

View File

@@ -0,0 +1,29 @@
/**
* Resolve ACP spawn argv per provider (host-probe verified 2026-05-25).
*/
export function resolveAcpSpawnArgs(agent: string): string[] | null {
switch (agent) {
case 'opencode':
case 'goose':
return ['acp'];
case 'cursor':
return ['acp'];
case 'copilot':
return ['--acp'];
case 'qwen':
return ['--acp'];
default:
return null;
}
}
export function resolveAcpProbeBinaries(agent: string): string[] {
switch (agent) {
case 'cursor':
return ['cursor-agent', 'agent'];
case 'copilot':
return ['copilot'];
default:
return [agent];
}
}

View File

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

View File

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

View File

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

View File

@@ -2,77 +2,99 @@ import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { exec as execCb } from 'node:child_process'; import { exec as execCb } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME } from './provider-registry.js'; import { PROVIDERS_BY_NAME, PROBED_AGENT_NAMES } from './provider-registry.js';
import { resolveAcpProbeBinaries } from './acp-spawn.js';
import { clearProviderSnapshotCache } from './provider-snapshot.js';
import { readQwenSettingsModels } from './qwen-settings.js';
const exec = promisify(execCb); const exec = promisify(execCb);
const KNOWN_AGENTS = ['opencode', 'goose', 'claude', 'qwen'].map((name) => ({ async function resolveInstallPath(agentName: string): Promise<string | null> {
name, const candidates = resolveAcpProbeBinaries(agentName);
supportsAcp: PROVIDERS_BY_NAME.get(name)?.transport === 'acp', for (const bin of candidates) {
})); try {
const { stdout } = await exec(`which ${bin}`, { timeout: 10_000 });
const path = stdout.trim();
if (path) return path;
} catch {
/* try next */
}
}
return null;
}
async function detectAcpSupport(agentName: string, installPath: string): Promise<boolean> {
const transport = PROVIDERS_BY_NAME.get(agentName)?.transport;
if (transport !== 'acp') return false;
if (agentName === 'copilot') {
try {
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
return stdout.includes('--acp');
} catch {
return false;
}
}
if (agentName === 'qwen') {
try {
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
return stdout.includes('--acp');
} catch {
return false;
}
}
try {
await exec(`"${installPath}" acp --help`, { timeout: 10_000 });
return true;
} catch {
return false;
}
}
/** /**
* Probe for available agents on the HOST. * Probe for available agents on the HOST.
*
* v2.1.1: BooCoder runs on the host now — agents are local binaries,
* no SSH needed. Direct `which` / `exec` calls.
*/ */
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> { export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
clearProviderSnapshotCache();
log.info('agent-probe: scanning for known agents'); log.info('agent-probe: scanning for known agents');
for (const agent of KNOWN_AGENTS) { for (const agentName of PROBED_AGENT_NAMES) {
try { try {
const { stdout: whichOut } = await exec(`which ${agent.name}`, { timeout: 10_000 }); const installPath = await resolveInstallPath(agentName);
const installPath = whichOut.trim();
if (!installPath) continue; if (!installPath) continue;
let version: string | null = null; let version: string | null = null;
try { try {
const { stdout: verOut } = await exec(`${agent.name} --version`, { timeout: 15_000 }); const { stdout: verOut } = await exec(`"${installPath}" --version`, { timeout: 15_000 });
version = verOut.trim().slice(0, 100); version = verOut.trim().slice(0, 100);
} catch { } catch {
// Some agents may not support --version /* optional */
} }
let supportsAcp = agent.supportsAcp; const providerDef = PROVIDERS_BY_NAME.get(agentName);
let supportsAcp = providerDef?.transport === 'acp';
if (supportsAcp) { if (supportsAcp) {
try { supportsAcp = await detectAcpSupport(agentName, installPath);
await exec(`${agent.name} acp --help`, { timeout: 10_000 });
} catch {
supportsAcp = false;
}
} }
let models: Array<{ id: string; label: string }> = []; let models: Array<{ id: string; label: string }> = [];
const providerDef = PROVIDERS_BY_NAME.get(agent.name);
if (providerDef?.modelSource === 'static' && providerDef.staticModels) { if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
models = providerDef.staticModels; models = providerDef.staticModels;
} }
if (agent.name === 'qwen') { if (agentName === 'qwen') {
try { models = await readQwenSettingsModels();
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 label = providerDef?.label ?? agentName;
const transport = providerDef?.transport ?? 'pty'; const transport =
providerDef?.transport === 'acp' && !supportsAcp ? 'pty' : (providerDef?.transport ?? 'pty');
await sql` await sql`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport) 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}) VALUES (${agentName}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${label}, ${transport})
ON CONFLICT (name) DO UPDATE SET ON CONFLICT (name) DO UPDATE SET
install_path = EXCLUDED.install_path, install_path = EXCLUDED.install_path,
version = EXCLUDED.version, version = EXCLUDED.version,
@@ -82,10 +104,10 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
label = EXCLUDED.label, label = EXCLUDED.label,
transport = EXCLUDED.transport transport = EXCLUDED.transport
`; `;
log.info({ agent: agent.name, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found'); log.info({ agent: agentName, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
log.debug({ agent: agent.name, err: msg }, 'agent-probe: not found'); log.debug({ agent: agentName, err: msg }, 'agent-probe: not found');
} }
} }

View File

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

View File

@@ -0,0 +1,39 @@
/**
* Cursor model list parser — lifted from Paseo cursor-acp-agent.ts
*/
import type { ProviderModel } from './provider-types.js';
const CURSOR_MODEL_MARKER_PATTERN = /\s+\((?:default|current)\)$/;
export function parseCursorAgentModelsOutput(output: string): ProviderModel[] {
const parsed = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && line !== 'Available models' && !line.startsWith('Tip:'))
.map((line) => {
const separatorIndex = line.indexOf(' - ');
if (separatorIndex <= 0) return null;
const id = line.slice(0, separatorIndex).trim();
const rawLabel = line.slice(separatorIndex + 3).trim();
if (!id || !rawLabel) return null;
let marker: 'default' | 'current' | null = null;
if (rawLabel.endsWith(' (default)')) marker = 'default';
else if (rawLabel.endsWith(' (current)')) marker = 'current';
return { id, label: rawLabel.replace(CURSOR_MODEL_MARKER_PATTERN, ''), marker };
})
.filter((m): m is { id: string; label: string; marker: 'default' | 'current' | null } => m !== null);
const defaultModelId =
parsed.find((m) => m.marker === 'default')?.id ??
parsed.find((m) => m.marker === 'current')?.id ??
parsed[0]?.id;
return parsed.map((model) => ({
id: model.id,
label: model.label,
isDefault: model.id === defaultModelId,
}));
}

View File

@@ -1,10 +1,14 @@
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js'; import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
import { dispatchViaAcp } from './acp-dispatch.js'; import { dispatchViaAcp } from './acp-dispatch.js';
import { dispatchViaPty } from './pty-dispatch.js'; import { dispatchViaPty } from './pty-dispatch.js';
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
import { getManifestCommands } from './provider-commands.js';
import { persistExternalAgentTurn } from './agent-turn-persist.js';
interface InferenceRunner { interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
@@ -24,7 +28,7 @@ const POLL_INTERVAL_MS = 5_000;
const COMPLETION_POLL_MS = 2_000; const COMPLETION_POLL_MS = 2_000;
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } { export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
const { sql, inference, log, config } = deps; const { sql, inference, broker, log, config } = deps;
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
let running = false; let running = false;
let stopping = false; let stopping = false;
@@ -34,8 +38,17 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
if (running || stopping) return; if (running || stopping) return;
// Grab one pending task // Grab one pending task
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }[]>` const rows = await sql<{
SELECT id, project_id, input, agent, model, session_id id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
}[]>`
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
FROM tasks FROM tasks
WHERE state = 'pending' WHERE state = 'pending'
ORDER BY created_at ORDER BY created_at
@@ -51,7 +64,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; session_id: string | null }): Promise<void> { async function runTask(task: {
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
}): Promise<void> {
const taskId = task.id; const taskId = task.id;
// Determine execution path: if agent is specified AND exists in available_agents → Path B // Determine execution path: if agent is specified AND exists in available_agents → Path B
@@ -179,7 +201,16 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>───────────────────────────────── // ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>─────────────────────────────────
async function runExternalAgent( async function runExternalAgent(
task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }, task: {
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
},
supportsAcp: boolean, supportsAcp: boolean,
installPath: string | null, installPath: string | null,
): Promise<void> { ): Promise<void> {
@@ -265,6 +296,33 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// Step 2: Dispatch to agent // Step 2: Dispatch to agent
let outputSummary: string; let outputSummary: string;
let assistantContent = '';
let acpReasoning = '';
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: assistantId,
chat_id: chatId,
role: 'assistant',
} as WsFrame);
const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands);
broker.publishFrame(sessionId, {
type: 'agent_commands',
task_id: taskId,
session_id: sessionId,
commands: manifestCommands,
} as WsFrame);
}
if (supportsAcp) { if (supportsAcp) {
const result = await dispatchViaAcp({ const result = await dispatchViaAcp({
@@ -273,16 +331,20 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
worktreePath, worktreePath,
installPath: installPath ?? undefined, installPath: installPath ?? undefined,
model: task.model ?? undefined, model: task.model ?? undefined,
modeId: task.mode_id ?? undefined,
thinkingOptionId: task.thinking_option_id ?? undefined,
taskId,
sessionId,
chatId,
messageId: assistantId,
broker,
signal: ac.signal, signal: ac.signal,
log, log,
}); });
assistantContent = result.output.slice(0, 50_000);
acpReasoning = result.reasoningText.slice(0, 200_000);
outputSummary = result.output.slice(0, 500); outputSummary = result.output.slice(0, 500);
await persistExternalAgentTurn(sql, assistantId, result.toolSnapshots, acpReasoning);
// Store agent output as an assistant message
await sql`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', ${result.output.slice(0, 50_000)}, 'complete', clock_timestamp())
`;
} else { } else {
const result = await dispatchViaPty({ const result = await dispatchViaPty({
agent, agent,
@@ -290,18 +352,35 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
worktreePath, worktreePath,
installPath: installPath ?? undefined, installPath: installPath ?? undefined,
model: task.model ?? undefined, model: task.model ?? undefined,
modeId: task.mode_id ?? undefined,
thinkingOptionId: task.thinking_option_id ?? undefined,
signal: ac.signal, signal: ac.signal,
log, log,
}); });
assistantContent = (result.stdout || result.stderr || '(no output)').slice(0, 50_000);
outputSummary = (result.stdout || result.stderr).slice(0, 500); outputSummary = (result.stdout || result.stderr).slice(0, 500);
// Store agent output as an assistant message if (assistantContent) {
const content = result.stdout || result.stderr || '(no output)'; broker.publishFrame(sessionId, {
await sql` type: 'delta',
INSERT INTO messages (session_id, chat_id, role, content, status, created_at) message_id: assistantId,
VALUES (${sessionId}, ${chatId}, 'assistant', ${content.slice(0, 50_000)}, 'complete', clock_timestamp()) chat_id: chatId,
`; content: assistantContent,
} as WsFrame);
} }
}
await sql`
UPDATE messages
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
WHERE id = ${assistantId}
`;
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: assistantId,
chat_id: chatId,
} as WsFrame);
if (stopping) { if (stopping) {
await sql` await sql`
@@ -344,6 +423,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)'); log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
clearTaskCommands(taskId);
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
@@ -357,6 +437,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// Best-effort cleanup // Best-effort cleanup
await cleanupWorktree(projectPath, taskId); await cleanupWorktree(projectPath, taskId);
clearTaskCommands(taskId);
} }
} }

View File

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

View File

@@ -57,14 +57,29 @@ export async function startMcpServer(sql: Sql): Promise<void> {
input: z.string().describe('Task description / prompt for the agent'), input: z.string().describe('Task description / prompt for the agent'),
agent: z.string().optional().describe('Agent name (optional — uses default if omitted)'), agent: z.string().optional().describe('Agent name (optional — uses default if omitted)'),
model: z.string().optional().describe('Model override (optional)'), model: z.string().optional().describe('Model override (optional)'),
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
}, },
async (args) => { async (args) => {
const [row] = await sql<TaskRow[]>` const [row] = await sql<TaskRow[]>`
INSERT INTO tasks (project_id, input, agent, model, state) INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, state)
VALUES (${args.project_id}, ${args.input}, ${args.agent ?? null}, ${args.model ?? null}, 'pending') VALUES (
${args.project_id},
${args.input},
${args.agent ?? null},
${args.model ?? null},
${args.mode_id ?? null},
${args.thinking_option_id ?? null},
'pending'
)
RETURNING id, state RETURNING id, state
`; `;
return textResult({ task_id: row!.id, state: row!.state }); return textResult({
task_id: row!.id,
state: row!.state,
mode_id: args.mode_id ?? null,
thinking_option_id: args.thinking_option_id ?? null,
});
}, },
); );
@@ -147,11 +162,21 @@ export async function startMcpServer(sql: Sql): Promise<void> {
input: z.string().describe('Task prompt'), input: z.string().describe('Task prompt'),
agent: z.string().describe('Agent name (must match available_agents registry)'), agent: z.string().describe('Agent name (must match available_agents registry)'),
model: z.string().optional().describe('Model override (optional)'), model: z.string().optional().describe('Model override (optional)'),
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
}, },
async (args) => { async (args) => {
const [row] = await sql<TaskRow[]>` const [row] = await sql<TaskRow[]>`
INSERT INTO tasks (project_id, input, agent, model, state) INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, state)
VALUES (${args.project_id}, ${args.input}, ${args.agent}, ${args.model ?? null}, 'pending') VALUES (
${args.project_id},
${args.input},
${args.agent},
${args.model ?? null},
${args.mode_id ?? null},
${args.thinking_option_id ?? null},
'pending'
)
RETURNING id, state RETURNING id, state
`; `;
@@ -161,7 +186,13 @@ export async function startMcpServer(sql: Sql): Promise<void> {
`; `;
const executionPath = agentRow?.supports_acp ? 'acp' : 'pty'; const executionPath = agentRow?.supports_acp ? 'acp' : 'pty';
return textResult({ task_id: row!.id, state: row!.state, execution_path: executionPath }); return textResult({
task_id: row!.id,
state: row!.state,
execution_path: executionPath,
mode_id: args.mode_id ?? null,
thinking_option_id: args.thinking_option_id ?? null,
});
}, },
); );

View File

@@ -0,0 +1,113 @@
/**
* Blocks ACP dispatch on permission prompts until the user responds via API.
*/
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
import { isUnattendedMode } from './provider-manifest.js';
const DEFAULT_TIMEOUT_MS = 120_000;
interface PendingPermission {
request: RequestPermissionRequest;
sessionId: string;
resolve: (response: RequestPermissionResponse) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
const pendingByTask = new Map<string, PendingPermission>();
export interface PermissionPrompt {
taskId: string;
toolTitle?: string;
options: Array<{ optionId: string; label: string }>;
}
export interface PermissionHooks {
onPrompt?: (prompt: PermissionPrompt & { sessionId: string }) => void | Promise<void>;
onResolved?: (taskId: string, sessionId: string) => void | Promise<void>;
}
let hooks: PermissionHooks = {};
export function setPermissionHooks(next: PermissionHooks): void {
hooks = next;
}
function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
return {
taskId,
toolTitle: params.toolCall?.title ?? undefined,
options: params.options.map((o) => ({
optionId: o.optionId,
label: o.name,
})),
};
}
export function waitForPermissionResponse(
taskId: string,
sessionId: string,
provider: string,
modeId: string | undefined,
params: RequestPermissionRequest,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<RequestPermissionResponse> {
if (isUnattendedMode(provider, modeId)) {
const first = params.options[0];
if (first) {
return Promise.resolve({ outcome: { outcome: 'selected', optionId: first.optionId } });
}
return Promise.resolve({ outcome: { outcome: 'cancelled' } });
}
return new Promise((resolve, reject) => {
const existing = pendingByTask.get(taskId);
if (existing) {
clearTimeout(existing.timer);
existing.reject(new Error('superseded by newer permission request'));
}
const timer = setTimeout(() => {
pendingByTask.delete(taskId);
void hooks.onResolved?.(taskId, sessionId);
resolve({ outcome: { outcome: 'cancelled' } });
}, timeoutMs);
pendingByTask.set(taskId, { request: params, sessionId, resolve, reject, timer });
const prompt = toPrompt(taskId, params);
void hooks.onPrompt?.({ ...prompt, sessionId });
});
}
export function respondToPermission(taskId: string, optionId: string | null): boolean {
const pending = pendingByTask.get(taskId);
if (!pending) return false;
clearTimeout(pending.timer);
pendingByTask.delete(taskId);
if (optionId) {
pending.resolve({ outcome: { outcome: 'selected', optionId } });
} else {
pending.resolve({ outcome: { outcome: 'cancelled' } });
}
void hooks.onResolved?.(taskId, pending.sessionId);
return true;
}
export function getPendingPermission(taskId: string): PermissionPrompt | null {
const pending = pendingByTask.get(taskId);
if (!pending) return null;
return toPrompt(taskId, pending.request);
}
export function cancelPendingPermission(taskId: string): void {
const pending = pendingByTask.get(taskId);
if (!pending) return;
clearTimeout(pending.timer);
pendingByTask.delete(taskId);
pending.resolve({ outcome: { outcome: 'cancelled' } });
void hooks.onResolved?.(taskId, pending.sessionId);
}

View File

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

View File

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

View File

@@ -2,10 +2,21 @@ export interface ProviderDef {
name: string; name: string;
label: string; label: string;
transport: 'native' | 'acp' | 'pty'; transport: 'native' | 'acp' | 'pty';
modelSource: 'llama-swap' | 'static'; modelSource: 'llama-swap' | 'static' | 'probe';
staticModels?: Array<{ id: string; label: string }>; staticModels?: Array<{ id: string; label: string }>;
/** Merge llama-swap models into probed list (OpenCode). */
mergeLlamaSwap?: boolean;
} }
/**
* Model discovery rules (see provider-snapshot.ts):
* - boocode: llama-swap only
* - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids)
* - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only
* - cursor: ACP probe + cursor-agent models CLI fallback
* - goose / copilot: ACP probe only
* - claude: static manifest models + thinking options
*/
export const PROVIDERS: ProviderDef[] = [ export const PROVIDERS: ProviderDef[] = [
{ {
name: 'boocode', name: 'boocode',
@@ -13,17 +24,24 @@ export const PROVIDERS: ProviderDef[] = [
transport: 'native', transport: 'native',
modelSource: 'llama-swap', modelSource: 'llama-swap',
}, },
{
name: 'cursor',
label: 'Cursor Agent',
transport: 'acp',
modelSource: 'probe',
},
{ {
name: 'opencode', name: 'opencode',
label: 'OpenCode', label: 'OpenCode',
transport: 'acp', transport: 'acp',
modelSource: 'llama-swap', modelSource: 'probe',
mergeLlamaSwap: true,
}, },
{ {
name: 'goose', name: 'goose',
label: 'Goose', label: 'Goose',
transport: 'acp', transport: 'acp',
modelSource: 'llama-swap', modelSource: 'probe',
}, },
{ {
name: 'claude', name: 'claude',
@@ -38,9 +56,18 @@ export const PROVIDERS: ProviderDef[] = [
{ {
name: 'qwen', name: 'qwen',
label: 'Qwen Code', label: 'Qwen Code',
transport: 'pty', transport: 'acp',
modelSource: 'static', modelSource: 'probe',
},
{
name: 'copilot',
label: 'GitHub Copilot',
transport: 'acp',
modelSource: 'probe',
}, },
]; ];
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p])); export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
/** External agents probed on host (excludes native boocode). */
export const PROBED_AGENT_NAMES = PROVIDERS.filter((p) => p.name !== 'boocode').map((p) => p.name);

View File

@@ -0,0 +1,266 @@
/**
* Provider snapshot cache — cold ACP probe per provider + static manifest merge.
*/
import { homedir } from 'node:os';
import { exec as execCb } from 'node:child_process';
import { promisify } from 'node:util';
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
import {
getManifestDefaultModeId,
getManifestModes,
PROVIDER_MANIFEST,
} from './provider-manifest.js';
import { probeAcpProvider } from './acp-probe.js';
import { parseCursorAgentModelsOutput } from './cursor-models.js';
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
import { getManifestCommands, mergeCommands } from './provider-commands.js';
import { readQwenSettingsModels } from './qwen-settings.js';
const exec = promisify(execCb);
interface AgentRow {
name: string;
install_path: string | null;
supports_acp: boolean;
models: ProviderModel[] | null;
label: string | null;
transport: string | null;
}
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
try {
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
if (!res.ok) return [];
const parsed = (await res.json()) as { data?: Array<{ id: string }> };
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
} catch {
return [];
}
}
async function fetchCursorModelsCli(installPath: string): Promise<ProviderModel[]> {
try {
const { stdout } = await exec(`"${installPath}" models`, { timeout: 15_000, maxBuffer: 1024 * 1024 });
return parseCursorAgentModelsOutput(stdout);
} catch {
return [];
}
}
/** Prefix llama-swap model ids so they don't collide with provider-native models. */
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
return models.map((m) => ({
...m,
id: m.id.startsWith('llama-swap/') ? m.id : `llama-swap/${m.id}`,
}));
}
function attachClaudeThinking(models: ProviderModel[]): ProviderModel[] {
const thinking = PROVIDER_MANIFEST.claude?.thinkingOptions;
if (!thinking?.length) return models;
return models.map((m) => ({
...m,
thinkingOptions: thinking,
defaultThinkingOptionId: 'medium',
}));
}
export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
const seen = new Set<string>();
const out: ProviderModel[] = [];
for (const list of lists) {
for (const m of list) {
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push(m);
}
}
return out;
}
async function buildProviderEntry(
provider: ProviderDef,
agentRow: AgentRow | undefined,
llamaModels: ProviderModel[],
cwd: string,
): Promise<ProviderSnapshotEntry | null> {
const isNative = provider.name === 'boocode';
const installed = isNative || !!agentRow;
if (!installed) return null;
let transport = provider.transport;
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
transport = 'pty';
}
const fallbackModes = getManifestModes(provider.name);
const defaultModeId = getManifestDefaultModeId(provider.name);
if (isNative) {
return {
name: provider.name,
label: provider.label,
transport,
status: 'ready',
installed: true,
models: llamaModels,
modes: [],
defaultModeId: null,
commands: getManifestCommands(provider.name),
};
}
let models: ProviderModel[] = [];
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
models = llamaModels;
} else if (agentRow?.models?.length) {
models = agentRow.models;
} else if (provider.staticModels) {
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
}
if (provider.name === 'claude') {
models = attachClaudeThinking(models);
return {
name: provider.name,
label: agentRow?.label ?? provider.label,
transport,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
};
}
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
if (probe.models.length > 0) {
models = probe.models;
} else if (provider.name === 'cursor' && agentRow.install_path) {
models = await fetchCursorModelsCli(agentRow.install_path);
} else if (provider.modelSource === 'llama-swap') {
models = llamaModels;
}
if (provider.name === 'qwen') {
const settingsModels = await readQwenSettingsModels();
models = mergeModels(models, settingsModels);
}
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
const nativeModels = probe.models.length > 0 ? probe.models : models;
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
}
return {
name: provider.name,
label: agentRow.label ?? provider.label,
transport,
status: probe.ok ? 'ready' : 'error',
installed: true,
models,
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
defaultModeId: probe.defaultModeId ?? defaultModeId,
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
error: probe.error,
};
}
// PTY-only providers (qwen fallback when ACP unavailable)
if (provider.name === 'qwen') {
if (models.length === 0) {
models = await readQwenSettingsModels();
}
}
return {
name: provider.name,
label: agentRow?.label ?? provider.label,
transport,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
};
}
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
const CACHE_TTL_MS = 5 * 60_000;
export async function getProviderSnapshot(
sql: Sql,
config: Config,
cwd?: string,
force = false,
): Promise<ProviderSnapshotEntry[]> {
const resolvedCwd = cwd?.trim() || homedir();
const cacheKey = resolvedCwd;
const cached = snapshotCache.get(cacheKey);
if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) {
return cached.entries;
}
const inflight = snapshotInflight.get(cacheKey);
if (!force && inflight) {
return inflight;
}
const build = async (): Promise<ProviderSnapshotEntry[]> => {
const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<AgentRow[]>`
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents
`;
const agentMap = new Map(agents.map((a) => [a.name, a]));
const built = await Promise.all(
PROVIDERS.map((provider) =>
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
),
);
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
snapshotCache.set(cacheKey, { at: Date.now(), entries });
return entries;
};
const promise = build().finally(() => {
snapshotInflight.delete(cacheKey);
});
snapshotInflight.set(cacheKey, promise);
return promise;
}
export function clearProviderSnapshotCache(): void {
snapshotCache.clear();
snapshotInflight.clear();
}
/** Persist probed model lists back to available_agents for fast legacy reads. */
export async function persistProbedModels(
sql: Sql,
entries: ProviderSnapshotEntry[],
log: FastifyBaseLogger,
): Promise<void> {
let count = 0;
for (const entry of entries) {
if (entry.name === 'boocode' || entry.models.length === 0) continue;
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
await sql`
UPDATE available_agents
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
WHERE name = ${entry.name}
`;
count++;
}
if (count > 0) {
log.info({ count }, 'provider-snapshot: persisted models to available_agents');
}
}

View File

@@ -0,0 +1,51 @@
/** Shared provider / snapshot types (Paseo-shaped, BooCoder-native). */
export interface ProviderMode {
id: string;
label: string;
description?: string;
/** Auto-approve tool permissions when this mode is selected. */
isUnattended?: boolean;
}
export interface ThinkingOption {
id: string;
label: string;
isDefault?: boolean;
}
export interface ProviderModel {
id: string;
label: string;
description?: string;
isDefault?: boolean;
thinkingOptions?: ThinkingOption[];
defaultThinkingOptionId?: string;
}
export type ProviderSnapshotStatus = 'ready' | 'error';
export interface AgentCommand {
name: string;
description?: string;
}
export interface ProviderSnapshotEntry {
name: string;
label: string;
transport: string;
status: ProviderSnapshotStatus;
installed: boolean;
models: ProviderModel[];
modes: ProviderMode[];
defaultModeId: string | null;
commands: AgentCommand[];
error?: string;
}
export interface AgentSessionConfig {
provider: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
}

View File

@@ -1,15 +1,5 @@
/** /**
* PTY dispatch — runs external agents directly on the host. * PTY dispatch — runs external agents directly on the host.
*
* v2.1.3: Spawns agent binaries directly (no sh -c wrapper) using the
* install_path from agent-probe. Follows Paseo's pattern: direct binary
* path + args array + cwd.
*
* Supported agents:
* - claude: `claude -p --model <model>` (print mode, reads task from stdin)
* - opencode: `opencode --model <model>` (stdin pipe)
* - qwen: `qwen -p <task> --output-format stream-json`
* - goose: `goose run --text <task>`
*/ */
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
@@ -25,27 +15,44 @@ export interface PtyDispatchOpts {
task: string; task: string;
worktreePath: string; worktreePath: string;
model?: string; model?: string;
modeId?: string;
thinkingOptionId?: string;
installPath?: string; installPath?: string;
signal?: AbortSignal; signal?: AbortSignal;
log: FastifyBaseLogger; log: FastifyBaseLogger;
} }
interface AgentCommand { interface PtySpawnSpec {
binary: string; binary: string;
args: string[]; args: string[];
stdin?: string; stdin?: string;
} }
function buildAgentCommand(agent: string, task: string, model?: string, installPath?: string): AgentCommand | null { function buildPtySpawnSpec(
agent: string,
task: string,
model?: string,
modeId?: string,
thinkingOptionId?: string,
installPath?: string,
): PtySpawnSpec | null {
const binary = installPath ?? agent; const binary = installPath ?? agent;
switch (agent) { switch (agent) {
case 'claude': case 'claude': {
return { const args = ['-p'];
binary, if (model) args.push('--model', model);
args: model ? ['-p', '--model', model] : ['-p'], if (modeId) args.push('--permission-mode', modeId);
stdin: task, if (thinkingOptionId) args.push('--effort', thinkingOptionId);
}; return { binary, args, stdin: task };
}
case 'qwen': {
const args = ['-p', task, '--output-format', 'stream-json'];
if (model) args.push('--model', model);
if (modeId) args.push('--approval-mode', modeId);
return { binary, args };
}
case 'opencode': case 'opencode':
return { return {
@@ -54,20 +61,10 @@ function buildAgentCommand(agent: string, task: string, model?: string, installP
stdin: task, stdin: task,
}; };
case 'qwen':
return {
binary,
args: model
? ['-p', task, '--model', model, '--output-format', 'stream-json']
: ['-p', task, '--output-format', 'stream-json'],
};
case 'goose': case 'goose':
return { return {
binary, binary,
args: model args: model ? ['run', '--text', task, '--model', model] : ['run', '--text', task],
? ['run', '--text', task, '--model', model]
: ['run', '--text', task],
}; };
default: default:
@@ -76,9 +73,9 @@ function buildAgentCommand(agent: string, task: string, model?: string, installP
} }
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> { export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
const { agent, task, worktreePath, model, installPath, signal, log } = opts; const { agent, task, worktreePath, model, modeId, thinkingOptionId, installPath, signal, log } = opts;
const cmd = buildAgentCommand(agent, task, model, installPath); const cmd = buildPtySpawnSpec(agent, task, model, modeId, thinkingOptionId, installPath);
if (!cmd) { if (!cmd) {
return { return {
exitCode: 1, exitCode: 1,
@@ -87,7 +84,7 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
}; };
} }
log.info({ agent, binary: cmd.binary, worktreePath }, 'pty-dispatch: starting'); log.info({ agent, binary: cmd.binary, worktreePath, modeId }, 'pty-dispatch: starting');
return new Promise<DispatchResult>((resolve, reject) => { return new Promise<DispatchResult>((resolve, reject) => {
const child = spawn(cmd.binary, cmd.args, { const child = spawn(cmd.binary, cmd.args, {

View File

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

View File

@@ -1,129 +0,0 @@
/**
* @deprecated v2.1.1 — BooCoder runs on the host now. Use direct spawn/exec instead.
* Kept for one release cycle in case of rollback.
*
* SSH helper — spawns commands on the host via SSH.
*
* BooCode's container cannot directly spawn host processes (opencode, goose, claude, pi).
* They live on the HOST at /usr/local/bin/ or Sam's PATH. We SSH to the host over the
* Tailscale IP (same mechanism BooTerm uses: samkintop@100.114.205.53).
*/
import { spawn, type ChildProcess } from 'node:child_process';
export const SSH_HOST = process.env.BOOCODER_SSH_HOST ?? '100.114.205.53';
export const SSH_USER = process.env.BOOCODER_SSH_USER ?? 'samkintop';
/** Common SSH args — strict host checking disabled for container-to-host trust. */
const SSH_BASE_ARGS = [
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'BatchMode=yes',
];
export interface SshExecResult {
exitCode: number;
stdout: string;
stderr: string;
}
/**
* Execute a command on the host via SSH, collecting all output.
* Returns when the remote process exits.
*/
export async function sshExec(
command: string,
opts?: { signal?: AbortSignal; timeoutMs?: number },
): Promise<SshExecResult> {
return new Promise<SshExecResult>((resolve, reject) => {
const child = spawn('ssh', [
...SSH_BASE_ARGS,
`${SSH_USER}@${SSH_HOST}`,
command,
], {
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let killed = false;
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
const cleanup = () => {
if (!killed) {
killed = true;
child.kill('SIGTERM');
}
};
// Abort signal
if (opts?.signal) {
if (opts.signal.aborted) {
cleanup();
reject(new Error('SSH exec aborted before start'));
return;
}
opts.signal.addEventListener('abort', cleanup, { once: true });
}
// Timeout
let timer: ReturnType<typeof setTimeout> | undefined;
if (opts?.timeoutMs) {
timer = setTimeout(() => {
cleanup();
reject(new Error(`SSH exec timed out after ${opts.timeoutMs}ms`));
}, opts.timeoutMs);
}
child.on('close', (code) => {
if (timer) clearTimeout(timer);
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
resolve({ exitCode: code ?? 1, stdout, stderr });
});
child.on('error', (err) => {
if (timer) clearTimeout(timer);
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
reject(err);
});
// Close stdin immediately — we're not sending input via sshExec
child.stdin!.end();
});
}
/**
* Spawn an SSH child process with a command on the host.
* Returns the raw ChildProcess for callers that need streaming I/O (ACP, PTY).
*/
export function sshSpawn(command: string): ChildProcess {
return spawn('ssh', [
...SSH_BASE_ARGS,
`${SSH_USER}@${SSH_HOST}`,
command,
], {
stdio: ['pipe', 'pipe', 'pipe'],
});
}
/**
* Spawn an SSH child process that pipes stdin through.
* Used for agents that read a task from stdin (e.g. `echo "task" | claude -p`).
*/
export function sshSpawnWithStdin(command: string, input: string): ChildProcess {
const child = spawn('ssh', [
...SSH_BASE_ARGS,
`${SSH_USER}@${SSH_HOST}`,
command,
], {
stdio: ['pipe', 'pipe', 'pipe'],
});
// Write the input and close stdin
child.stdin!.write(input);
child.stdin!.end();
return child;
}

View File

@@ -6,7 +6,7 @@
* After the agent completes, we diff the worktree against HEAD and * After the agent completes, we diff the worktree against HEAD and
* queue the diff into pending_changes. * queue the diff into pending_changes.
*/ */
import { sshExec } from './ssh.js'; import { hostExec } from './host-exec.js';
const WORKTREE_BASE = '/tmp/booworktrees'; const WORKTREE_BASE = '/tmp/booworktrees';
@@ -23,10 +23,10 @@ export async function createWorktree(
const branchName = `task-${taskId}`; const branchName = `task-${taskId}`;
// Ensure the base directory exists // Ensure the base directory exists
await sshExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal }); await hostExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
// Create the worktree with a new branch from HEAD // Create the worktree with a new branch from HEAD
const result = await sshExec( const result = await hostExec(
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`, `git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
{ signal: opts?.signal, timeoutMs: 30_000 }, { signal: opts?.signal, timeoutMs: 30_000 },
); );
@@ -49,7 +49,7 @@ export async function diffWorktree(
): Promise<string> { ): Promise<string> {
// First, commit any uncommitted changes in the worktree so we can diff branches // First, commit any uncommitted changes in the worktree so we can diff branches
// Stage all changes // Stage all changes
const addResult = await sshExec( const addResult = await hostExec(
`cd ${shellEscape(worktreePath)} && git add -A`, `cd ${shellEscape(worktreePath)} && git add -A`,
{ signal: opts?.signal, timeoutMs: 30_000 }, { signal: opts?.signal, timeoutMs: 30_000 },
); );
@@ -58,7 +58,7 @@ export async function diffWorktree(
} }
// Check if there are staged changes // Check if there are staged changes
const statusResult = await sshExec( const statusResult = await hostExec(
`cd ${shellEscape(worktreePath)} && git diff --cached --quiet`, `cd ${shellEscape(worktreePath)} && git diff --cached --quiet`,
{ signal: opts?.signal, timeoutMs: 10_000 }, { signal: opts?.signal, timeoutMs: 10_000 },
); );
@@ -69,13 +69,13 @@ export async function diffWorktree(
} }
// Commit staged changes (needed to produce a clean branch diff) // Commit staged changes (needed to produce a clean branch diff)
await sshExec( await hostExec(
`cd ${shellEscape(worktreePath)} && git -c user.email=boocoder@local -c user.name=BooCoder commit -m "task changes" --allow-empty`, `cd ${shellEscape(worktreePath)} && git -c user.email=boocoder@local -c user.name=BooCoder commit -m "task changes" --allow-empty`,
{ signal: opts?.signal, timeoutMs: 15_000 }, { signal: opts?.signal, timeoutMs: 15_000 },
); );
// Diff the worktree branch against the parent commit (HEAD of main tree) // Diff the worktree branch against the parent commit (HEAD of main tree)
const diffResult = await sshExec( const diffResult = await hostExec(
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`, `git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
{ signal: opts?.signal, timeoutMs: 60_000 }, { signal: opts?.signal, timeoutMs: 60_000 },
); );
@@ -99,13 +99,13 @@ export async function cleanupWorktree(
const branchName = `task-${taskId}`; const branchName = `task-${taskId}`;
// Remove the worktree (--force handles dirty state) // Remove the worktree (--force handles dirty state)
await sshExec( await hostExec(
`git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`, `git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`,
{ timeoutMs: 15_000 }, { timeoutMs: 15_000 },
).catch(() => {}); ).catch(() => {});
// Delete the task branch // Delete the task branch
await sshExec( await hostExec(
`git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`, `git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`,
{ timeoutMs: 10_000 }, { timeoutMs: 10_000 },
).catch(() => {}); ).catch(() => {});

View File

@@ -19,7 +19,9 @@
"./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" }, "./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" },
"./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" }, "./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" },
"./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" }, "./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" },
"./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" } "./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" },
"./skills": { "types": "./dist/services/skills.d.ts", "default": "./dist/services/skills.js" },
"./skill-invoke": { "types": "./dist/services/skill-invoke.d.ts", "default": "./dist/services/skill-invoke.js" }
}, },
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",

View File

@@ -14,6 +14,7 @@ import { registerArtifactRoutes } from './routes/artifacts.js';
import { registerChatRoutes } from './routes/chats.js'; import { registerChatRoutes } from './routes/chats.js';
import { registerSidebarRoutes } from './routes/sidebar.js'; import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js'; import { registerWebSocket } from './routes/ws.js';
import { registerCoderProxy } from './routes/coder-proxy.js';
import { registerModelRoutes } from './routes/models.js'; import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js'; import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js'; import { registerSkillsRoutes } from './routes/skills.js';
@@ -212,36 +213,10 @@ async function main() {
}); });
registerWebSocket(app, sql, broker); registerWebSocket(app, sql, broker);
// v2.0.0: reverse proxy /api/coder/* to the boocoder container. Keeps the // v2.0.0: reverse proxy /api/coder/* to boocoder (HTTP + WS). CoderPane
// SPA's HTTP requests going through a single origin (avoids CORS). WS for // connects WS through /api/coder/ws/sessions/:id on the same origin.
// the coder pane connects directly to boocoder:9502 from the browser (same
// Tailscale network — no CORS issue for WebSocket upgrade requests).
const BOOCODER_ORIGIN = process.env.BOOCODER_URL ?? 'http://boocoder:3000'; const BOOCODER_ORIGIN = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
app.all('/api/coder/*', async (req, reply) => { registerCoderProxy(app, BOOCODER_ORIGIN);
const targetPath = req.url.replace('/api/coder', '/api');
const targetUrl = `${BOOCODER_ORIGIN}${targetPath}`;
const headers: Record<string, string> = {};
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string;
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string;
try {
const res = await fetch(targetUrl, {
method: req.method as string,
headers,
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
});
reply.code(res.status);
for (const [key, value] of res.headers) {
if (key === 'transfer-encoding') continue;
reply.header(key, value);
}
const body = await res.text();
return reply.send(body);
} catch (err) {
app.log.error({ err, targetUrl }, 'coder proxy error');
reply.code(502).send({ error: 'boocoder backend unavailable' });
}
});
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist'); const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
if (existsSync(webDist)) { if (existsSync(webDist)) {

View File

@@ -0,0 +1,91 @@
import type { FastifyInstance } from 'fastify';
import WebSocket from 'ws';
function boocoderWsUrl(origin: string, path: string): string {
const u = new URL(origin);
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.pathname = path;
u.search = '';
return u.toString();
}
/**
* Reverse-proxy BooCoder HTTP + WebSocket through BooChat's single origin.
* WS must be registered before the HTTP catch-all — fetch() cannot upgrade.
*/
export function registerCoderProxy(app: FastifyInstance, boocoderOrigin: string): void {
app.get<{ Params: { sessionId: string } }>(
'/api/coder/ws/sessions/:sessionId',
{ websocket: true },
(clientSocket, req) => {
const sessionId = req.params.sessionId;
const target = boocoderWsUrl(boocoderOrigin, `/api/ws/sessions/${sessionId}`);
const upstream = new WebSocket(target);
upstream.on('open', () => {
app.log.debug({ sessionId }, 'coder ws proxy: upstream connected');
});
upstream.on('message', (data, isBinary) => {
if (clientSocket.readyState !== clientSocket.OPEN) return;
clientSocket.send(data, { binary: isBinary });
});
upstream.on('close', (code, reason) => {
if (clientSocket.readyState === clientSocket.OPEN) {
clientSocket.close(code, reason.toString());
}
});
upstream.on('error', (err) => {
app.log.warn({ err, sessionId, target }, 'coder ws proxy: upstream error');
if (clientSocket.readyState === clientSocket.OPEN) {
clientSocket.close(1011, 'upstream error');
}
});
clientSocket.on('message', (data, isBinary) => {
if (upstream.readyState !== WebSocket.OPEN) return;
upstream.send(data, { binary: isBinary });
});
clientSocket.on('close', () => {
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
upstream.close();
}
});
clientSocket.on('error', () => {
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
upstream.close();
}
});
},
);
app.all('/api/coder/*', async (req, reply) => {
const targetPath = req.url.replace('/api/coder', '/api');
const targetUrl = `${boocoderOrigin}${targetPath}`;
const headers: Record<string, string> = {};
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string;
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string;
try {
const res = await fetch(targetUrl, {
method: req.method as string,
headers,
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
});
reply.code(res.status);
for (const [key, value] of res.headers) {
if (key === 'transfer-encoding') continue;
reply.header(key, value);
}
const body = await res.text();
return reply.send(body);
} catch (err) {
app.log.error({ err, targetUrl }, 'coder proxy error');
reply.code(502).send({ error: 'boocoder backend unavailable' });
}
});
}

View File

@@ -33,7 +33,8 @@ const WorkspacePaneZ = z.object({
kind: z.enum([ kind: z.enum([
'chat', 'chat',
'terminal', 'terminal',
'agent', 'coder',
'agent', // legacy alias — normalized to coder on write
'empty', 'empty',
'settings', 'settings',
'markdown_artifact', 'markdown_artifact',
@@ -307,9 +308,12 @@ export function registerSessionRoutes(
reply.code(400); reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() }; return { error: 'invalid body', details: parsed.error.flatten() };
} }
const workspacePanes = parsed.data.workspace_panes.map((pane) =>
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
);
const rows = await sql<Session[]>` const rows = await sql<Session[]>`
UPDATE sessions UPDATE sessions
SET workspace_panes = ${sql.json(parsed.data.workspace_panes as never)}, SET workspace_panes = ${sql.json(workspacePanes as never)},
updated_at = clock_timestamp() updated_at = clock_timestamp()
WHERE id = ${req.params.id} WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,

View File

@@ -1,9 +1,13 @@
import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Chat } from '../types/api.js'; import type { Chat } from '../types/api.js';
import { getSkillBody, listSkills } from '../services/skills.js'; import { getSkillBody, listSkills } from '../services/skills.js';
import {
buildSkillInvokeSyntheticFrames,
DEFAULT_SKILL_USER_MESSAGE,
runSkillInvokeTransaction,
} from '../services/skill-invoke.js';
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in // Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
// routes/messages.ts so index.ts can pass thin adapters around broker + // routes/messages.ts so index.ts can pass thin adapters around broker +
@@ -35,8 +39,6 @@ const SkillInvokeBody = z.object({
user_message: z.string().max(64_000).nullable().optional(), user_message: z.string().max(64_000).nullable().optional(),
}); });
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
export function registerSkillsRoutes( export function registerSkillsRoutes(
app: FastifyInstance, app: FastifyInstance,
sql: Sql, sql: Sql,
@@ -62,7 +64,9 @@ export function registerSkillsRoutes(
return { error: 'invalid body', details: parsed.error.flatten() }; return { error: 'invalid body', details: parsed.error.flatten() };
} }
const { skill_name } = parsed.data; const { skill_name } = parsed.data;
const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE; const userText = parsed.data.user_message?.trim()
? parsed.data.user_message
: DEFAULT_SKILL_USER_MESSAGE;
const chatRows = await sql<Chat[]>` const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open' SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
@@ -80,87 +84,20 @@ export function registerSkillsRoutes(
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` }; return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
} }
const toolCallId = randomUUID(); const { result, toolCall } = await runSkillInvokeTransaction(sql, {
const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }]; sessionId,
const toolResults = { tool_call_id: toolCallId, output: body, truncated: false }; chatId: chat.id,
skillName: skill_name,
const result = await sql.begin(async (tx) => { skillBody: body,
const [synthAssistant] = await tx<{ id: string }[]>` userText,
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp())
RETURNING id
`;
// v1.13.20: parts-only write. Single skill_use tool_call, no text
// content, so one part at seq 0.
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
id: toolCallId,
name: 'skill_use',
args: { name: skill_name },
} as never)})
`;
const [toolMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
RETURNING id
`;
// v1.13.20: parts-only write of the synthetic tool result (skill body).
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
`;
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, '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}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return {
synth_assistant_id: synthAssistant!.id,
tool_message_id: toolMsg!.id,
user_message_id: userMsg!.id,
assistant_message_id: assistantMsg!.id,
};
}); });
// Synthetic frames so useSessionStream's reducer reflects the new // Synthetic frames so useSessionStream's reducer reflects the new
// history without a refetch. Frame shapes match the streaming-inference // history without a refetch. Frame shapes match the streaming-inference
// protocol (see services/inference.ts InferenceFrame). // protocol (see services/inference.ts InferenceFrame).
handlers.publishSessionFrame(sessionId, { for (const frame of buildSkillInvokeSyntheticFrames(chat.id, result, toolCall, body)) {
type: 'message_started', handlers.publishSessionFrame(sessionId, frame);
message_id: result.synth_assistant_id, }
chat_id: chat.id,
role: 'assistant',
});
handlers.publishSessionFrame(sessionId, {
type: 'tool_call',
message_id: result.synth_assistant_id,
chat_id: chat.id,
tool_call: toolCalls[0]!,
});
handlers.publishSessionFrame(sessionId, {
type: 'message_complete',
message_id: result.synth_assistant_id,
chat_id: chat.id,
});
// The tool_result frame's reducer branch creates the tool-role message
// in-place when it doesn't already exist — no separate message_started
// is needed for the tool side.
handlers.publishSessionFrame(sessionId, {
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id: toolCallId,
chat_id: chat.id,
output: body,
truncated: false,
});
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText); handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default'); handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { isAgentRegistryMarkdown, parseAgentsMd } from '../agents.js';
describe('isAgentRegistryMarkdown', () => {
it('rejects Cursor navigation AGENTS.md at repo root', () => {
expect(
isAgentRegistryMarkdown('# Agent navigation\n\n## Doc map\n'),
).toBe(false);
});
it('accepts the global data/AGENTS.md registry shape', () => {
expect(isAgentRegistryMarkdown('# Agents\n\n## Code Reviewer\n---\n')).toBe(true);
});
});
describe('parseAgentsMd', () => {
it('does not emit errors for navigation sections when file is skipped upstream', () => {
// When isAgentRegistryMarkdown returns false, getAgentsForProject never calls this.
// Sanity: a nav-shaped file would produce six "missing fence" errors if parsed.
const nav = `# Agent navigation
## Doc map
| Need | Read |
|------|------|
## Task routing
Start here
`;
const r = parseAgentsMd(nav);
expect(r.agents).toHaveLength(0);
expect(r.errors.length).toBeGreaterThan(0);
});
});

View File

@@ -226,6 +226,76 @@ describe('buildMessagesPayload', async () => {
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' }); expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
}); });
it('strips assistant tool_calls when matching tool results are missing', async () => {
const session = makeSession();
const project = makeProject();
const toolCall: ToolCall = {
id: 'call_orphan',
name: 'grep',
args: { pattern: 'foo' },
};
const history: Message[] = [
makeMessage('user', 'search'),
makeMessage('assistant', 'partial answer', { tool_calls: [toolCall] }),
makeMessage('assistant', 'final answer'),
];
const result = await buildMessagesPayload(session, project, history);
// tool_calls stripped from the orphan turn; text content kept.
expect(result).toHaveLength(4);
expect(result[1]).toMatchObject({ role: 'user', content: 'search' });
expect(result[2]).toMatchObject({ role: 'assistant', content: 'partial answer' });
expect(result[2]!.tool_calls).toBeUndefined();
expect(result[3]).toMatchObject({ role: 'assistant', content: 'final answer' });
});
it('drops tool-call-only assistant rows when tool results never arrived', async () => {
const session = makeSession();
const project = makeProject();
const toolCall: ToolCall = {
id: 'call_orphan_only',
name: 'grep',
args: { pattern: 'foo' },
};
const history: Message[] = [
makeMessage('user', 'search'),
makeMessage('assistant', '', { tool_calls: [toolCall] }),
makeMessage('assistant', 'final answer'),
];
const result = await buildMessagesPayload(session, project, history);
expect(result).toHaveLength(3);
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
});
it('skips stray tool rows when the owning assistant tool_calls were stripped', async () => {
const session = makeSession();
const project = makeProject();
const toolCallA: ToolCall = {
id: 'call_a',
name: 'grep',
args: { pattern: 'foo' },
};
const toolCallB: ToolCall = {
id: 'call_b',
name: 'read',
args: { path: 'x' },
};
const toolResult: ToolResult = {
tool_call_id: 'call_a',
output: 'match',
truncated: false,
};
const history: Message[] = [
makeMessage('user', 'search'),
makeMessage('assistant', '', { tool_calls: [toolCallA, toolCallB] }),
makeMessage('tool', '', { tool_results: toolResult }),
makeMessage('assistant', 'final answer'),
];
const result = await buildMessagesPayload(session, project, history);
expect(result).toHaveLength(3);
expect(result.find((m) => m.role === 'tool')).toBeUndefined();
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
});
it('skips tool rows with no tool_results', async () => { it('skips tool rows with no tool_results', async () => {
const session = makeSession(); const session = makeSession();
const project = makeProject(); const project = makeProject();

View File

@@ -309,6 +309,14 @@ export function parseAgentsMd(content: string): ParseResult {
return { agents, errors }; return { agents, errors };
} }
/** True when a file at `<project>/AGENTS.md` is an agent registry, not Cursor/doc nav. */
export function isAgentRegistryMarkdown(content: string): boolean {
const firstLine = content.trimStart().split('\n')[0]?.trim() ?? '';
// BooCode monorepo root AGENTS.md is navigation only; registry is /data/AGENTS.md.
if (firstLine === '# Agent navigation') return false;
return true;
}
// ---- mtime-keyed cache + public API ---------------------------------------- // ---- mtime-keyed cache + public API ----------------------------------------
interface CacheEntry { interface CacheEntry {
@@ -397,7 +405,7 @@ export async function getAgentsForProject(projectPath: string): Promise<AgentsRe
for (const a of r.agents) byName.set(a.name, { ...a, source: 'global' }); for (const a of r.agents) byName.set(a.name, { ...a, source: 'global' });
errors.push(...r.errors); errors.push(...r.errors);
} }
if (projectContent !== null) { if (projectContent !== null && isAgentRegistryMarkdown(projectContent)) {
const r = parseAgentsMd(projectContent); const r = parseAgentsMd(projectContent);
for (const a of r.agents) byName.set(a.name, { ...a, source: 'project' }); for (const a of r.agents) byName.set(a.name, { ...a, source: 'project' });
errors.push(...r.errors); errors.push(...r.errors);

View File

@@ -37,6 +37,34 @@ export interface OpenAiMessage {
// omit it and exercise the byte-stability surface directly through // omit it and exercise the byte-stability surface directly through
// buildSystemPromptWithFingerprint. The observer Map in system-prompt.ts // buildSystemPromptWithFingerprint. The observer Map in system-prompt.ts
// updates regardless of whether log is passed. // updates regardless of whether log is passed.
function toolResultIdsFollowing(history: Message[], assistantIdx: number): Set<string> {
const ids = new Set<string>();
for (let j = assistantIdx + 1; j < history.length; j++) {
const row = history[j]!;
if (row.role === 'user' || row.role === 'assistant') break;
if (row.role === 'tool' && row.tool_results?.tool_call_id) {
ids.add(row.tool_results.tool_call_id);
}
}
return ids;
}
function findAssistantOwnerForToolCall(history: Message[], toolIdx: number, callId: string): number | null {
for (let k = toolIdx - 1; k >= 0; k--) {
const row = history[k]!;
if (row.role === 'user') break;
if (row.role === 'assistant' && row.tool_calls?.some((tc) => tc.id === callId)) return k;
}
return null;
}
function assistantToolCallsArePayloadComplete(history: Message[], assistantIdx: number): boolean {
const assistant = history[assistantIdx]!;
if (!assistant.tool_calls?.length) return false;
const fulfilled = toolResultIdsFollowing(history, assistantIdx);
return assistant.tool_calls.every((tc) => fulfilled.has(tc.id));
}
export async function buildMessagesPayload( export async function buildMessagesPayload(
session: Session, session: Session,
project: Project, project: Project,
@@ -97,6 +125,10 @@ export async function buildMessagesPayload(
if (m.role === 'tool') { if (m.role === 'tool') {
const tr = m.tool_results; const tr = m.tool_results;
if (!tr) continue; if (!tr) continue;
const ownerIdx = findAssistantOwnerForToolCall(history, i, tr.tool_call_id);
if (ownerIdx == null || !assistantToolCallsArePayloadComplete(history, ownerIdx)) {
continue;
}
const outputText = tr.error const outputText = tr.error
? `error: ${tr.error}` ? `error: ${tr.error}`
: typeof tr.output === 'string' : typeof tr.output === 'string'
@@ -115,18 +147,27 @@ export async function buildMessagesPayload(
content: m.content && m.content.length > 0 ? m.content : null, content: m.content && m.content.length > 0 ? m.content : null,
}; };
if (m.tool_calls && m.tool_calls.length > 0) { if (m.tool_calls && m.tool_calls.length > 0) {
if (assistantToolCallsArePayloadComplete(history, i)) {
msg.tool_calls = m.tool_calls.map((tc) => ({ msg.tool_calls = m.tool_calls.map((tc) => ({
id: tc.id, id: tc.id,
type: 'function' as const, type: 'function' as const,
function: { name: tc.name, arguments: JSON.stringify(tc.args) }, function: { name: tc.name, arguments: JSON.stringify(tc.args) },
})); }));
} }
// Orphaned tool_calls (no matching tool rows) are stripped so the
// upstream API does not reject the payload on the next user turn.
}
// v1.13.1-C: collapse reasoning_parts into a single string. The view // v1.13.1-C: collapse reasoning_parts into a single string. The view
// returns them ordered by sequence; multiple reasoning parts on one // returns them ordered by sequence; multiple reasoning parts on one
// message are rare but concat preserves ordering. Skip when absent. // message are rare but concat preserves ordering. Skip when absent.
if (m.reasoning_parts && m.reasoning_parts.length > 0) { if (m.reasoning_parts && m.reasoning_parts.length > 0) {
msg.reasoning = m.reasoning_parts.map((p) => p.text ?? '').join(''); msg.reasoning = m.reasoning_parts.map((p) => p.text ?? '').join('');
} }
const hasPayload =
(msg.content != null && msg.content.trim().length > 0) ||
(msg.tool_calls != null && msg.tool_calls.length > 0) ||
(msg.reasoning != null && msg.reasoning.length > 0);
if (!hasPayload) continue;
out.push(msg); out.push(msg);
continue; continue;
} }

View File

@@ -0,0 +1,148 @@
import { randomUUID } from 'node:crypto';
import type { Sql } from '../db.js';
export const DEFAULT_SKILL_USER_MESSAGE = 'Apply this skill.';
export interface SkillInvokeTransactionResult {
synth_assistant_id: string;
tool_message_id: string;
user_message_id: string;
assistant_message_id: string;
}
export interface SkillInvokeToolCall {
id: string;
name: 'skill_use';
args: { name: string };
}
export type SkillInvokeSessionFrame = Record<string, unknown> & { type: string };
export async function runSkillInvokeTransaction(
sql: Sql,
args: {
sessionId: string;
chatId: string;
skillName: string;
skillBody: string;
userText: string;
},
): Promise<{ result: SkillInvokeTransactionResult; toolCall: SkillInvokeToolCall }> {
const toolCallId = randomUUID();
const toolCall: SkillInvokeToolCall = {
id: toolCallId,
name: 'skill_use',
args: { name: args.skillName },
};
const toolResults = {
tool_call_id: toolCallId,
output: args.skillBody,
truncated: false,
};
const result = await sql.begin(async (tx) => {
const [synthAssistant] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${args.sessionId}, ${args.chatId}, 'assistant', '', 'complete', clock_timestamp())
RETURNING id
`;
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
id: toolCallId,
name: 'skill_use',
args: { name: args.skillName },
} as never)})
`;
const [toolMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${args.sessionId}, ${args.chatId}, 'tool', '', 'complete', clock_timestamp())
RETURNING id
`;
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
`;
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${args.sessionId}, ${args.chatId}, 'user', ${args.userText}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${args.sessionId}, ${args.chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${args.sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${args.chatId}`;
return {
synth_assistant_id: synthAssistant!.id,
tool_message_id: toolMsg!.id,
user_message_id: userMsg!.id,
assistant_message_id: assistantMsg!.id,
};
});
return { result, toolCall };
}
export function buildSkillInvokeSyntheticFrames(
chatId: string,
result: SkillInvokeTransactionResult,
toolCall: SkillInvokeToolCall,
skillBody: string,
): SkillInvokeSessionFrame[] {
return [
{
type: 'message_started',
message_id: result.synth_assistant_id,
chat_id: chatId,
role: 'assistant',
},
{
type: 'tool_call',
message_id: result.synth_assistant_id,
chat_id: chatId,
tool_call: toolCall,
},
{
type: 'message_complete',
message_id: result.synth_assistant_id,
chat_id: chatId,
},
{
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id: toolCall.id,
chat_id: chatId,
output: skillBody,
truncated: false,
},
];
}
export function buildSkillInvokeUserFrames(
chatId: string,
userMessageId: string,
userText: string,
): SkillInvokeSessionFrame[] {
return [
{
type: 'message_started',
message_id: userMessageId,
chat_id: chatId,
role: 'user',
},
{
type: 'delta',
message_id: userMessageId,
chat_id: chatId,
content: userText,
},
{
type: 'message_complete',
message_id: userMessageId,
chat_id: chatId,
},
];
}

View File

@@ -16,7 +16,7 @@ import { pathGuard, PathScopeError } from './path_guard.js';
// new skills, per-entry mtime check on body access so a hot-edited SKILL.md // new skills, per-entry mtime check on body access so a hot-edited SKILL.md
// is re-read without a restart. No watcher. // is re-read without a restart. No watcher.
const SKILLS_ROOT = '/data/skills'; const SKILLS_ROOT = process.env.SKILLS_ROOT ?? '/data/skills';
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024; const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
const LIST_CACHE_TTL_MS = 60_000; const LIST_CACHE_TTL_MS = 60_000;

View File

@@ -85,6 +85,13 @@ export const DeltaFrame = z.object({
content: z.string(), content: z.string(),
}); });
export const ReasoningDeltaFrame = z.object({
type: z.literal('reasoning_delta'),
message_id: Uuid,
chat_id: Uuid.optional(),
content: z.string(),
});
export const ToolCallFrame = z.object({ export const ToolCallFrame = z.object({
type: z.literal('tool_call'), type: z.literal('tool_call'),
message_id: Uuid, message_id: Uuid,
@@ -256,6 +263,37 @@ export const ProjectDeletedFrame = z.object({
project_id: Uuid, project_id: Uuid,
}); });
const PermissionOptionShape = z.object({
option_id: z.string(),
label: z.string(),
});
export const PermissionRequestedFrame = z.object({
type: z.literal('permission_requested'),
task_id: Uuid,
session_id: Uuid,
tool_title: z.string().optional(),
options: z.array(PermissionOptionShape),
});
export const PermissionResolvedFrame = z.object({
type: z.literal('permission_resolved'),
task_id: Uuid,
session_id: Uuid,
});
const AgentCommandShape = z.object({
name: z.string(),
description: z.string().optional(),
});
export const AgentCommandsFrame = z.object({
type: z.literal('agent_commands'),
task_id: Uuid,
session_id: Uuid,
commands: z.array(AgentCommandShape),
});
// ---- discriminated union --------------------------------------------------- // ---- discriminated union ---------------------------------------------------
export const WsFrameSchema = z.discriminatedUnion('type', [ export const WsFrameSchema = z.discriminatedUnion('type', [
@@ -263,6 +301,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
SnapshotFrame, SnapshotFrame,
MessageStartedFrame, MessageStartedFrame,
DeltaFrame, DeltaFrame,
ReasoningDeltaFrame,
ToolCallFrame, ToolCallFrame,
ToolResultFrame, ToolResultFrame,
MessageCompleteFrame, MessageCompleteFrame,
@@ -271,6 +310,9 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
ChatRenamedFrame, ChatRenamedFrame,
CompactedFrame, CompactedFrame,
ErrorFrame, ErrorFrame,
PermissionRequestedFrame,
PermissionResolvedFrame,
AgentCommandsFrame,
// per-user // per-user
ChatStatusFrame, ChatStatusFrame,
SessionUpdatedFrame, SessionUpdatedFrame,
@@ -300,6 +342,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'snapshot', 'snapshot',
'message_started', 'message_started',
'delta', 'delta',
'reasoning_delta',
'tool_call', 'tool_call',
'tool_result', 'tool_result',
'message_complete', 'message_complete',
@@ -308,6 +351,9 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'chat_renamed', 'chat_renamed',
'compacted', 'compacted',
'error', 'error',
'permission_requested',
'permission_resolved',
'agent_commands',
'chat_status', 'chat_status',
'session_updated', 'session_updated',
'session_renamed', 'session_renamed',

View File

@@ -13,7 +13,13 @@ import type {
Skill, Skill,
AskUserAnswer, AskUserAnswer,
ToolCostStat, ToolCostStat,
Provider, ProviderSnapshotEntry,
CoderSendMessageBody,
CoderSendMessageResponse,
CoderMessageWire,
CoderTaskDetail,
PermissionPrompt,
AgentCommand,
} from './types'; } from './types';
export class ApiError extends Error { export class ApiError extends Error {
@@ -300,7 +306,46 @@ export const api = {
models: () => request<ModelInfo[]>('/api/models'), models: () => request<ModelInfo[]>('/api/models'),
coder: { coder: {
providers: () => request<Provider[]>('/api/coder/providers'), snapshot: (cwd?: string) => {
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
},
refreshProviders: () =>
request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }),
sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
method: 'POST',
body: JSON.stringify(body),
}),
getTaskPermission: (taskId: string) =>
request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`),
respondTaskPermission: (taskId: string, optionId: string | null) =>
request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, {
method: 'POST',
body: JSON.stringify({ option_id: optionId }),
}),
getTaskCommands: (taskId: string) =>
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
getTask: (taskId: string) =>
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
listMessages: (sessionId: string, chatId?: string) =>
request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
),
skillInvoke: (sessionId: string, paneId: string, skillName: string, userMessage: string | null) =>
request<{
user_message_id: string;
assistant_message_id: string;
synth_assistant_id: string;
tool_message_id: string;
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
method: 'POST',
body: JSON.stringify({
pane_id: paneId,
skill_name: skillName,
user_message: userMessage,
}),
}),
}, },
agents: { agents: {

View File

@@ -209,14 +209,95 @@ export interface ModelInfo {
export interface ProviderModel { export interface ProviderModel {
id: string; id: string;
label: string; label: string;
description?: string;
isDefault?: boolean;
thinkingOptions?: ThinkingOption[];
defaultThinkingOptionId?: string;
} }
export interface Provider { export interface ProviderMode {
id: string;
label: string;
description?: string;
isUnattended?: boolean;
}
export interface ThinkingOption {
id: string;
label: string;
isDefault?: boolean;
}
export type ProviderSnapshotStatus = 'ready' | 'error';
export interface ProviderSnapshotEntry {
name: string; name: string;
label: string; label: string;
transport: string; transport: string;
status: ProviderSnapshotStatus;
installed: boolean; installed: boolean;
models: ProviderModel[]; models: ProviderModel[];
modes: ProviderMode[];
defaultModeId: string | null;
commands: AgentCommand[];
error?: string;
}
export interface AgentSessionConfig {
provider: string;
model: string;
modeId: string | null;
thinkingOptionId: string | null;
}
export interface PermissionPrompt {
taskId: string;
toolTitle?: string;
options: Array<{ optionId: string; label: string }>;
}
export interface AgentCommand {
name: string;
description?: string;
}
export interface CoderSendMessageBody {
content: string;
pane_id: string;
chat_id?: string;
provider?: string;
model?: string;
mode_id?: string;
thinking_option_id?: string;
}
export interface CoderSendMessageResponse {
user_message_id?: string;
assistant_message_id?: string;
task_id?: string;
dispatched?: boolean;
}
export interface CoderMessageWire {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
status?: 'streaming' | 'complete' | 'failed';
reasoning_text?: string;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
}
export interface CoderTaskDetail {
id: string;
state: 'pending' | 'running' | 'completed' | 'failed' | 'blocked' | 'cancelled';
input: string;
output_summary: string | null;
agent: string | null;
model: string | null;
session_id: string | null;
} }
export interface SidebarSession { export interface SidebarSession {

View File

@@ -85,6 +85,13 @@ export const DeltaFrame = z.object({
content: z.string(), content: z.string(),
}); });
export const ReasoningDeltaFrame = z.object({
type: z.literal('reasoning_delta'),
message_id: Uuid,
chat_id: Uuid.optional(),
content: z.string(),
});
export const ToolCallFrame = z.object({ export const ToolCallFrame = z.object({
type: z.literal('tool_call'), type: z.literal('tool_call'),
message_id: Uuid, message_id: Uuid,
@@ -256,6 +263,37 @@ export const ProjectDeletedFrame = z.object({
project_id: Uuid, project_id: Uuid,
}); });
const PermissionOptionShape = z.object({
option_id: z.string(),
label: z.string(),
});
export const PermissionRequestedFrame = z.object({
type: z.literal('permission_requested'),
task_id: Uuid,
session_id: Uuid,
tool_title: z.string().optional(),
options: z.array(PermissionOptionShape),
});
export const PermissionResolvedFrame = z.object({
type: z.literal('permission_resolved'),
task_id: Uuid,
session_id: Uuid,
});
const AgentCommandShape = z.object({
name: z.string(),
description: z.string().optional(),
});
export const AgentCommandsFrame = z.object({
type: z.literal('agent_commands'),
task_id: Uuid,
session_id: Uuid,
commands: z.array(AgentCommandShape),
});
// ---- discriminated union --------------------------------------------------- // ---- discriminated union ---------------------------------------------------
export const WsFrameSchema = z.discriminatedUnion('type', [ export const WsFrameSchema = z.discriminatedUnion('type', [
@@ -263,6 +301,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
SnapshotFrame, SnapshotFrame,
MessageStartedFrame, MessageStartedFrame,
DeltaFrame, DeltaFrame,
ReasoningDeltaFrame,
ToolCallFrame, ToolCallFrame,
ToolResultFrame, ToolResultFrame,
MessageCompleteFrame, MessageCompleteFrame,
@@ -271,6 +310,9 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
ChatRenamedFrame, ChatRenamedFrame,
CompactedFrame, CompactedFrame,
ErrorFrame, ErrorFrame,
PermissionRequestedFrame,
PermissionResolvedFrame,
AgentCommandsFrame,
// per-user // per-user
ChatStatusFrame, ChatStatusFrame,
SessionUpdatedFrame, SessionUpdatedFrame,
@@ -300,6 +342,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'snapshot', 'snapshot',
'message_started', 'message_started',
'delta', 'delta',
'reasoning_delta',
'tool_call', 'tool_call',
'tool_result', 'tool_result',
'message_complete', 'message_complete',
@@ -308,6 +351,9 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'chat_renamed', 'chat_renamed',
'compacted', 'compacted',
'error', 'error',
'permission_requested',
'permission_resolved',
'agent_commands',
'chat_status', 'chat_status',
'session_updated', 'session_updated',
'session_renamed', 'session_renamed',

View File

@@ -0,0 +1,39 @@
import { ChevronDown } from 'lucide-react';
import { useState } from 'react';
import type { AgentCommand } from '@/api/types';
import { cn } from '@/lib/utils';
interface Props {
commands: AgentCommand[];
}
export function AgentCommandsHint({ commands }: Props) {
const [open, setOpen] = useState(false);
if (commands.length === 0) return null;
return (
<div className="mx-2 mb-1 rounded-md border border-border/60 bg-muted/30 text-xs">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-2 py-1.5 text-muted-foreground hover:text-foreground max-md:min-h-[44px]"
>
<span>Slash commands ({commands.length})</span>
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
</button>
{open && (
<ul className="px-2 pb-2 space-y-1 border-t border-border/40 max-h-48 overflow-y-auto overscroll-contain touch-pan-y">
{commands.map((cmd) => (
<li key={cmd.name} className="font-mono">
<span className="text-primary/80">/{cmd.name}</span>
{cmd.description && (
<span className="ml-1.5 text-muted-foreground font-sans line-clamp-1">{cmd.description}</span>
)}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,308 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Shield, Cpu, Brain } from 'lucide-react';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { BottomSheet } from '@/components/BottomSheet';
import { useViewport } from '@/hooks/useViewport';
import { cn } from '@/lib/utils';
const PREFS_KEY = 'boocode.coder.agent-prefs';
type ProviderPrefs = Record<string, {
model: string;
modeId: string | null;
thinkingOptionId: string | null;
}>;
function loadPrefs(): ProviderPrefs {
try {
const raw = localStorage.getItem(PREFS_KEY);
return raw ? (JSON.parse(raw) as ProviderPrefs) : {};
} catch {
return {};
}
}
function savePrefs(prefs: ProviderPrefs): void {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
}
function defaultsForProvider(entry: ProviderSnapshotEntry): AgentSessionConfig {
const model =
entry.models.find((m) => m.isDefault)?.id ??
entry.models[0]?.id ??
'';
const selectedModel = entry.models.find((m) => m.id === model);
const modeId = entry.defaultModeId ?? entry.modes[0]?.id ?? null;
const thinkingOptionId =
selectedModel?.defaultThinkingOptionId ??
selectedModel?.thinkingOptions?.find((t) => t.isDefault)?.id ??
selectedModel?.thinkingOptions?.[0]?.id ??
null;
return {
provider: entry.name,
model,
modeId,
thinkingOptionId,
};
}
function resolveConfig(
entry: ProviderSnapshotEntry,
prefs: ProviderPrefs,
): AgentSessionConfig {
const saved = prefs[entry.name];
const base = defaultsForProvider(entry);
const model =
saved?.model && entry.models.some((m) => m.id === saved.model)
? saved.model
: base.model;
const selectedModel = entry.models.find((m) => m.id === model);
const modeId =
saved?.modeId && entry.modes.some((m) => m.id === saved.modeId)
? saved.modeId
: base.modeId;
const thinkingOptions = selectedModel?.thinkingOptions ?? [];
const thinkingOptionId =
saved?.thinkingOptionId &&
thinkingOptions.some((t) => t.id === saved.thinkingOptionId)
? saved.thinkingOptionId
: base.thinkingOptionId;
return { provider: entry.name, model, modeId, thinkingOptionId };
}
interface PickerProps {
label: string;
value: string;
disabled?: boolean;
options: Array<{ id: string; label: string }>;
onPick: (id: string) => void;
icon?: React.ReactNode;
}
function CompactPicker({ label, value, disabled, options, onPick, icon }: PickerProps) {
const { isMobile } = useViewport();
const [open, setOpen] = useState(false);
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
const list = (
<div className="py-1">
{options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => {
onPick(o.id);
setOpen(false);
}}
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={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{o.label}</span>
</button>
))}
</div>
);
if (isMobile) {
return (
<>
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
aria-label={`${label}: ${currentLabel}`}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{icon ?? <Cpu className="size-4" />}
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
<div className="px-2">{list}</div>
</BottomSheet>
</>
);
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
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 disabled:opacity-40 max-w-[140px]"
>
{icon}
<span className="truncate">{currentLabel}</span>
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
{options.map((o) => (
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="font-mono text-xs">
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
{o.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface Props {
projectPath?: string;
value: AgentSessionConfig;
onChange: (next: AgentSessionConfig) => void;
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange }: Props) {
const allEntries = useProviderSnapshot(projectPath);
const entries = useMemo(
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
[allEntries],
);
const [refreshing, setRefreshing] = useState(false);
const hydratedRef = useRef(false);
useEffect(() => {
hydratedRef.current = false;
}, [projectPath]);
useEffect(() => {
if (!entries?.length || hydratedRef.current) return;
hydratedRef.current = true;
const prefs = loadPrefs();
const entry =
entries.find((e) => e.name === value.provider) ??
entries.find((e) => e.name === 'boocode') ??
entries[0];
if (!entry) return;
onChange(resolveConfig(entry, prefs));
}, [entries, onChange, value.provider]);
const currentEntry = useMemo(
() => entries?.find((e) => e.name === value.provider),
[entries, value.provider],
);
const currentModel = useMemo(
() => currentEntry?.models.find((m) => m.id === value.model),
[currentEntry, value.model],
);
const thinkingOptions = currentModel?.thinkingOptions ?? [];
useEffect(() => {
onProviderCommandsChange?.(currentEntry?.commands ?? []);
}, [currentEntry, onProviderCommandsChange]);
function persist(next: AgentSessionConfig): void {
const prefs = loadPrefs();
prefs[next.provider] = {
model: next.model,
modeId: next.modeId,
thinkingOptionId: next.thinkingOptionId,
};
savePrefs(prefs);
onChange(next);
}
function pickProvider(name: string): void {
const entry = entries?.find((e) => e.name === name);
if (!entry) return;
persist(resolveConfig(entry, loadPrefs()));
}
function pickModel(model: string): void {
const entry = currentEntry;
if (!entry) return;
const selected = entry.models.find((m) => m.id === model);
const thinkingOptionId =
selected?.defaultThinkingOptionId ??
selected?.thinkingOptions?.find((t) => t.isDefault)?.id ??
selected?.thinkingOptions?.[0]?.id ??
null;
persist({ ...value, model, thinkingOptionId });
}
async function handleRefresh(): Promise<void> {
setRefreshing(true);
try {
await api.coder.refreshProviders();
await refreshProviderSnapshot(projectPath);
} finally {
setRefreshing(false);
}
}
if (!entries) {
return (
<div className="text-xs text-muted-foreground px-2 py-1">Loading agents</div>
);
}
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
return (
<div className="flex flex-wrap items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
<CompactPicker
label="Provider"
value={value.provider}
options={providerOptions}
onPick={pickProvider}
icon={<Cpu className="size-3 shrink-0" />}
/>
<CompactPicker
label="Mode"
value={value.modeId ?? ''}
disabled={modeOptions.length === 0}
options={modeOptions}
onPick={(modeId) => persist({ ...value, modeId })}
icon={<Shield className="size-3 shrink-0" />}
/>
<CompactPicker
label="Model"
value={value.model}
disabled={modelOptions.length === 0}
options={modelOptions}
onPick={pickModel}
/>
{thinkingOpts.length > 0 && (
<CompactPicker
label="Thinking"
value={value.thinkingOptionId ?? ''}
options={thinkingOpts}
onPick={(thinkingOptionId) => persist({ ...value, thinkingOptionId })}
icon={<Brain className="size-3 shrink-0" />}
/>
)}
<button
type="button"
onClick={() => void handleRefresh()}
disabled={refreshing}
className="ml-auto inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
aria-label="Refresh provider list"
title="Refresh providers"
>
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
</button>
</div>
);
}

View File

@@ -23,7 +23,8 @@ import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay'; import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker'; import { AgentPicker } from '@/components/AgentPicker';
import { ContextBar } from '@/components/ContextBar'; import { ContextBar } from '@/components/ContextBar';
import { SkillSlashCommand } from '@/components/SkillSlashCommand'; import { SlashCommandPicker } from '@/components/SlashCommandPicker';
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Message } from '@/api/types'; import type { Message } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
@@ -87,10 +88,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of // Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
// the input and stays open while the input is `/<word>` with no whitespace. // the input and stays open while the input is `/<word>` with no whitespace.
// Disabled entirely when the caller doesn't pass onSlashCommand. // Disabled entirely when the caller doesn't pass onSlashCommand.
// v1.12 CP7.5: anchorRect was a snapshot taken at open time. SkillSlashCommand // SlashCommandPicker reads the live textarea rect via inputRef (textareaRef below)
// now reads the live textarea rect via inputRef (textareaRef below) so it can // so it can recompute on visualViewport changes (iOS keyboard open/close).
// recompute on visualViewport changes (iOS keyboard open/close), so the
// anchorRect field is no longer needed in this state.
const [slashState, setSlashState] = useState<{ const [slashState, setSlashState] = useState<{
query: string; query: string;
} | null>(null); } | null>(null);
@@ -168,13 +167,11 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
// input parses to a known skill. Falls through to onSend for unknown // input parses to a known skill. Falls through to onSend for unknown
// slash names (literal text) or when slash dispatch isn't wired. // slash names (literal text) or when slash dispatch isn't wired.
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) { if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/); const parsed = parseSlashInput(text);
if (match && skillsLookup.has(match[1]!)) { if (parsed && skillsLookup.has(parsed.cmdName)) {
const skillName = match[1]!;
const args = (match[2] ?? '').trim();
setBusy(true); setBusy(true);
try { try {
await onSlashCommand(skillName, args); await onSlashCommand(parsed.cmdName, parsed.args);
setValue(''); setValue('');
setAttachments([]); setAttachments([]);
setSlashState(null); setSlashState(null);
@@ -268,8 +265,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
// slash-prefixed token with no whitespace (i.e. user is still typing the // slash-prefixed token with no whitespace (i.e. user is still typing the
// skill name). Hand off to args mode the moment a space appears or the // skill name). Hand off to args mode the moment a space appears or the
// slash leaves position 0. // slash leaves position 0.
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) { if (onSlashCommand && isSlashCommandToken(newValue)) {
const query = newValue.slice(1); const query = slashQuery(newValue);
if (!slashState) { if (!slashState) {
setSlashState({ query }); setSlashState({ query });
} else if (slashState.query !== query) { } else if (slashState.query !== query) {
@@ -496,7 +493,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) return; if (mentionState?.open) return;
// SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let // SlashCommandPicker owns Arrow/Enter/Tab/Esc via a document listener; let
// it consume them so the textarea doesn't also submit on Enter. // it consume them so the textarea doesn't also submit on Enter.
if (slashState) return; if (slashState) return;
// IME safety: never act on Enter while an IME composition is in flight // IME safety: never act on Enter while an IME composition is in flight
@@ -658,12 +655,13 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
/> />
)} )}
{slashState && ( {slashState && (
<SkillSlashCommand <SlashCommandPicker
query={slashState.query} query={slashState.query}
skills={skills} items={skills}
inputRef={textareaRef} inputRef={textareaRef}
onSelect={handleSlashSelect} onSelect={handleSlashSelect}
onClose={() => setSlashState(null)} onClose={() => setSlashState(null)}
emptyLabel="No skills available"
/> />
)} )}
</div> </div>

View File

@@ -183,13 +183,13 @@ export function ChatTabBar({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40"> <DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem onSelect={() => onAddPane('chat')}> <DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New chat <MessageSquare size={14} /> New BooChat
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}> <DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New terminal <Terminal size={14} /> New BooTerm
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('coder')}> <DropdownMenuItem onSelect={() => onAddPane('coder')}>
<Code size={14} /> New coder <Code size={14} /> New BooCode
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -14,6 +14,7 @@ import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub, ContextMenuSub,
ContextMenuSubContent, ContextMenuSubContent,
ContextMenuSubTrigger, ContextMenuSubTrigger,
@@ -37,14 +38,15 @@ function useTerminals(): TerminalRegistration[] {
return list; return list;
} }
// Wrap a message body with a right-click context menu offering "Send to // Wrap a message body with a right-click context menu offering Copy and
// terminal → <pane name>". The submenu is disabled when nothing is selected // "Send to terminal → <pane name>". Send is disabled when nothing is
// or no terminal panes are open; clicking a target emits a sendToTerminal // selected or no terminal panes are open; clicking a target emits a
// event that TerminalPane subscribes to (filtered by pane_id). // sendToTerminal event that TerminalPane subscribes to (filtered by pane_id).
function SendToTerminalMenu({ children }: { children: ReactNode }) { function SendToTerminalMenu({ children }: { children: ReactNode }) {
const [selection, setSelection] = useState(''); const [selection, setSelection] = useState('');
const terminals = useTerminals(); const terminals = useTerminals();
const canSend = selection.length > 0 && terminals.length > 0; const hasSelection = selection.length > 0;
const canSend = hasSelection && terminals.length > 0;
return ( return (
<ContextMenu <ContextMenu
@@ -57,6 +59,17 @@ function SendToTerminalMenu({ children }: { children: ReactNode }) {
> >
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger> <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem
disabled={!hasSelection}
onSelect={() => {
void navigator.clipboard.writeText(selection).catch((err) => {
toast.error(err instanceof Error ? err.message : 'copy failed');
});
}}
>
Copy
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub> <ContextMenuSub>
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger> <ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
<ContextMenuSubContent> <ContextMenuSubContent>

View File

@@ -29,13 +29,13 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onAddPane('chat')}> <DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New chat <MessageSquare size={14} /> New BooChat
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}> <DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New terminal <Terminal size={14} /> New BooTerm
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('coder')}> <DropdownMenuItem onSelect={() => onAddPane('coder')}>
<Code size={14} /> New coder <Code size={14} /> New BooCode
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -0,0 +1,49 @@
import { ShieldAlert } from 'lucide-react';
import type { PermissionPrompt } from '@/api/types';
import { cn } from '@/lib/utils';
interface Props {
prompt: PermissionPrompt;
onRespond: (optionId: string | null) => void;
busy?: boolean;
}
export function PermissionCard({ prompt, onRespond, busy }: Props) {
return (
<div className="mx-3 my-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
<div className="flex items-start gap-2">
<ShieldAlert className="size-4 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground">Permission required</p>
{prompt.toolTitle && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{prompt.toolTitle}</p>
)}
<div className="flex flex-wrap gap-2 mt-2">
{prompt.options.map((opt) => (
<button
key={opt.optionId}
type="button"
disabled={busy}
onClick={() => onRespond(opt.optionId)}
className={cn(
'rounded-md border border-input bg-background px-2.5 py-1 text-xs hover:bg-accent',
'max-md:min-h-[44px] disabled:opacity-40',
)}
>
{opt.label}
</button>
))}
<button
type="button"
disabled={busy}
onClick={() => onRespond(null)}
className="rounded-md border border-destructive/40 px-2.5 py-1 text-xs text-destructive hover:bg-destructive/10 max-md:min-h-[44px] disabled:opacity-40"
>
Deny
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X } from 'lucide-react'; import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
@@ -26,6 +26,7 @@ import { useViewport } from '@/hooks/useViewport';
import { usePullToRefresh } from '@/hooks/usePullToRefresh'; import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import type { SidebarProject } from '@/api/types'; import type { SidebarProject } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls'; import { giteaUrlFor } from '@/lib/projectUrls';
import { isCoderSessionName } from '@/lib/coder-session';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const EXPANDED_KEY = 'boocode.sidebar.expanded'; const EXPANDED_KEY = 'boocode.sidebar.expanded';
@@ -382,7 +383,11 @@ export function ProjectSidebar() {
to={`/session/${s.id}`} to={`/session/${s.id}`}
className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`} className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}
> >
{isCoderSessionName(s.name) ? (
<Code className="size-3.5 shrink-0 opacity-70" />
) : (
<MessageSquare className="size-3.5 shrink-0 opacity-70" /> <MessageSquare className="size-3.5 shrink-0 opacity-70" />
)}
<span className="truncate flex-1" title={s.name}>{s.name}</span> <span className="truncate flex-1" title={s.name}>{s.name}</span>
<span className="text-xs text-muted-foreground shrink-0 tabular-nums"> <span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{relTime(s.updated_at)} {relTime(s.updated_at)}

View File

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

@@ -1,221 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, RefObject } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
import type { Skill } from '@/api/types';
interface Props {
query: string;
skills: Skill[];
// v1.12 CP7.5: was `anchorRect: {top, left}` (snapshot at open time). Now a
// live ref so the dropdown can re-stat the input on visualViewport events —
// critical on iOS where the keyboard shifts the visual viewport and the
// dropdown would otherwise sit in the wrong place (often hidden).
inputRef: RefObject<HTMLElement | null>;
onSelect: (skillName: string) => void;
onClose: () => void;
}
// max-h-[320px] on the popover — use as the height budget for above/below
// fit decisions. Slightly under-estimates when the list is short, but the
// only consequence is we sometimes flip below when we'd fit above; no UX
// breakage either way.
const DROPDOWN_HEIGHT_BUDGET = 320;
// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern —
// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn
// `Command` (cmdk) isn't installed in this project; per the addendum we use
// a plain div + Tailwind instead of pulling a new primitive autonomously.
//
// v1.12 CP7.5: portalled to document.body (escapes transformed/will-change
// ancestor stacking contexts that hid the popover inside ChatInput on iOS)
// + visualViewport-aware positioning (handles keyboard open/close + the iOS
// "shift layout to keep input visible" auto-scroll).
// Case-insensitive prefix match on `name` only. Description is display-only
// in v1 (substring search across description is deferred to a polish batch).
function filterByPrefix(skills: Skill[], query: string): Skill[] {
const q = query.toLowerCase();
const filtered = q
? skills.filter((s) => s.name.toLowerCase().startsWith(q))
: skills;
// Stable alphabetical ordering matches the server's cache order (skills.ts
// sorts on name asc) but we re-sort here so a stale client cache doesn't
// surprise the user.
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
}
export function SkillSlashCommand({ query, skills, inputRef, onSelect, onClose }: Props) {
const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]);
// Anchor + viewport tracking. `rect` is the input's bounding rect in layout
// viewport coords. `vvTick` forces a re-render whenever visualViewport
// changes even if the rect itself didn't (e.g. user scrolled the visual
// viewport without the input moving in layout space).
const [rect, setRect] = useState<DOMRect | null>(
() => inputRef.current?.getBoundingClientRect() ?? null,
);
const [vvTick, setVvTick] = useState(0);
useEffect(() => { setHighlightIndex(0); }, [query]);
// v1.12 CP7.5: recalc on viewport changes. iOS Safari fires
// visualViewport.resize when the soft keyboard opens/closes; .scroll fires
// when the page is shifted to keep the focused input visible above the
// keyboard. Both events should trigger a position recompute.
useEffect(() => {
function recalc() {
setRect(inputRef.current?.getBoundingClientRect() ?? null);
setVvTick((t) => t + 1);
}
recalc();
const vv = window.visualViewport;
vv?.addEventListener('resize', recalc);
vv?.addEventListener('scroll', recalc);
window.addEventListener('resize', recalc);
return () => {
vv?.removeEventListener('resize', recalc);
vv?.removeEventListener('scroll', recalc);
window.removeEventListener('resize', recalc);
};
}, [inputRef]);
// Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the
// textarea reach the popover even though focus stays in the textarea.
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (filtered.length === 0) return;
e.preventDefault();
const target = filtered[highlightIndex] ?? filtered[0];
if (target) onSelect(target.name);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [filtered, highlightIndex, onSelect, onClose]);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [onClose]);
useEffect(() => {
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
if (el) el.scrollIntoView({ block: 'nearest' });
}, [highlightIndex]);
// v1.12 CP7.5: visualViewport-corrected positioning. getBoundingClientRect
// returns layout-viewport coords; iOS Safari's `position: fixed` positions
// relative to the layout viewport too — but the visible area can be offset
// (vv.offsetTop/offsetLeft) when iOS scrolls the input above the keyboard.
// Subtracting the vv offsets keeps the dropdown locked to the input's
// visual position. vvTick is in the dep list to force recompute on
// visualViewport events even when the rect itself didn't change.
//
// Default: position above the input (matches original UX). Flip below if
// above doesn't fit (input too close to top of visible viewport). When
// below would overlap the keyboard, cap top so the dropdown stays visible.
const style = useMemo<CSSProperties>(() => {
if (!rect) return { display: 'none' };
const vv = window.visualViewport;
const vvOffsetTop = vv?.offsetTop ?? 0;
const vvOffsetLeft = vv?.offsetLeft ?? 0;
const vvHeight = vv?.height ?? window.innerHeight;
const anchorTop = rect.top - vvOffsetTop;
const anchorBottom = rect.bottom - vvOffsetTop;
const left = rect.left - vvOffsetLeft;
const fitsAbove = anchorTop >= DROPDOWN_HEIGHT_BUDGET;
if (fitsAbove) {
// translate(-100%) on Y so the dropdown grows upward from anchorTop.
return {
position: 'fixed',
top: anchorTop,
left,
transform: 'translateY(-100%)',
};
}
// Render below; clamp so the bottom edge stays inside the visible viewport.
const maxTop = Math.max(0, vvHeight - DROPDOWN_HEIGHT_BUDGET);
return {
position: 'fixed',
top: Math.min(anchorBottom, maxTop),
left,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rect, vvTick]);
const popover = filtered.length === 0 ? (
<div
ref={popoverRef}
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
style={style}
>
<div className="text-xs text-muted-foreground px-2 py-1">
{query ? `No skill starts with "/${query}"` : 'No skills available'}
</div>
</div>
) : (
<div
ref={popoverRef}
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
style={style}
>
{filtered.map((skill, i) => (
<button
key={skill.name}
type="button"
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left px-2.5 py-2 cursor-pointer block',
i === highlightIndex && 'bg-muted',
)}
onMouseEnter={() => setHighlightIndex(i)}
onMouseDown={(e) => {
// mousedown not click — click runs after blur/focus shuffles which
// can race with the textarea's onBlur close path.
e.preventDefault();
onSelect(skill.name);
}}
>
<div className="font-mono text-xs font-bold text-foreground">/{skill.name}</div>
<div
className="text-xs text-muted-foreground overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{skill.description}
</div>
</button>
))}
</div>
);
// v1.12 CP7.5: portal to document.body to escape ChatInput's stacking
// context. The original render-in-place rendered the dropdown inside the
// composer's transformed/will-change ancestor tree, which on iOS Safari +
// Vivaldi caused the popover to either disappear or sit at z-index 0
// behind the autofill toolbar. document.body has no transform ancestor.
return createPortal(popover, document.body);
}

View File

@@ -0,0 +1,181 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, RefObject } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
export interface SlashCommandItem {
name: string;
description?: string;
}
interface Props {
query: string;
items: SlashCommandItem[];
inputRef: RefObject<HTMLElement | null>;
onSelect: (name: string) => void;
onClose: () => void;
emptyLabel?: string;
}
const DROPDOWN_HEIGHT_BUDGET = 320;
function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandItem[] {
const q = query.toLowerCase();
const filtered = q ? items.filter((s) => s.name.toLowerCase().startsWith(q)) : items;
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
}
export function SlashCommandPicker({
query,
items,
inputRef,
onSelect,
onClose,
emptyLabel = 'No commands available',
}: Props) {
const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterByPrefix(items, query), [items, query]);
const [rect, setRect] = useState<DOMRect | null>(
() => inputRef.current?.getBoundingClientRect() ?? null,
);
const [vvTick, setVvTick] = useState(0);
useEffect(() => { setHighlightIndex(0); }, [query]);
useEffect(() => {
function recalc() {
setRect(inputRef.current?.getBoundingClientRect() ?? null);
setVvTick((t) => t + 1);
}
recalc();
const vv = window.visualViewport;
vv?.addEventListener('resize', recalc);
vv?.addEventListener('scroll', recalc);
window.addEventListener('resize', recalc);
return () => {
vv?.removeEventListener('resize', recalc);
vv?.removeEventListener('scroll', recalc);
window.removeEventListener('resize', recalc);
};
}, [inputRef]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (filtered.length === 0) return;
e.preventDefault();
const target = filtered[highlightIndex] ?? filtered[0];
if (target) onSelect(target.name);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [filtered, highlightIndex, onSelect, onClose]);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [onClose]);
useEffect(() => {
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
if (el) el.scrollIntoView({ block: 'nearest' });
}, [highlightIndex]);
const style = useMemo<CSSProperties>(() => {
if (!rect) return { display: 'none' };
const vv = window.visualViewport;
const vvOffsetTop = vv?.offsetTop ?? 0;
const vvHeight = vv?.height ?? window.innerHeight;
// Visible region in layout-viewport coords (what position:fixed uses)
const visibleTop = vvOffsetTop;
const visibleBottom = vvOffsetTop + vvHeight;
const spaceAbove = rect.top - visibleTop;
const spaceBelow = visibleBottom - rect.bottom;
if (spaceAbove >= Math.min(DROPDOWN_HEIGHT_BUDGET, spaceBelow)) {
// Place above: clamp to visible top
const popupTop = Math.max(visibleTop, rect.top - DROPDOWN_HEIGHT_BUDGET);
return {
position: 'fixed',
top: popupTop,
left: rect.left,
maxHeight: rect.top - popupTop,
};
}
// Place below: clamp to visible bottom
return {
position: 'fixed',
top: rect.bottom,
left: rect.left,
maxHeight: Math.min(DROPDOWN_HEIGHT_BUDGET, visibleBottom - rect.bottom),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rect, vvTick]);
const popover = filtered.length === 0 ? (
<div
ref={popoverRef}
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
style={style}
>
<div className="text-xs text-muted-foreground px-2 py-1">
{query ? `No command starts with "/${query}"` : emptyLabel}
</div>
</div>
) : (
<div
ref={popoverRef}
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
style={style}
>
{filtered.map((item, i) => (
<div
key={item.name}
role="option"
aria-selected={i === highlightIndex}
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left px-2.5 py-2 cursor-pointer block',
i === highlightIndex && 'bg-muted',
)}
onMouseEnter={() => setHighlightIndex(i)}
onClick={() => onSelect(item.name)}
>
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
{item.description && (
<div
className="text-xs text-muted-foreground overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.description}
</div>
)}
</div>
))}
</div>
);
return createPortal(popover, document.body);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react'; import { MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types'; import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats'; import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
import { terminalsRegistry } from '@/lib/events'; import { terminalsRegistry } from '@/lib/events';
@@ -34,6 +34,8 @@ interface Props {
// v1.9: passed through to SettingsPane when one is mounted in the grid. // v1.9: passed through to SettingsPane when one is mounted in the grid.
session: Session; session: Session;
project: Project | null; project: Project | null;
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
} }
export function Workspace({ export function Workspace({
@@ -45,6 +47,7 @@ export function Workspace({
chatsHook, chatsHook,
session, session,
project, project,
onAddPane,
}: Props) { }: Props) {
const { const {
panes, panes,
@@ -59,6 +62,7 @@ export function Workspace({
showLandingPage, showLandingPage,
addSplitPane, addSplitPane,
removePane, removePane,
isPaneChatPending,
handlePaneDragStart, handlePaneDragStart,
handlePaneDragOver, handlePaneDragOver,
handlePaneDragLeave, handlePaneDragLeave,
@@ -134,44 +138,11 @@ export function Workspace({
return out; return out;
}, [panes]); }, [panes]);
// Per-coder-pane WS connection (status dot lives in the pane header).
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
{!isMobile && (
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
// v1.9: settings panes excluded from the MAX cap (decision c).
disabled={panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES &&
'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
>
<PanelRight size={14} />
Split
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
<MessageSquare size={14} /> Chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
<Terminal size={14} /> Terminal
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('coder')}>
<Code size={14} /> Coder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* v1.8: mobile multi-pane SwipeablePaneTab strip removed; the header
pill (MobileTabSwitcher) is the mobile pane switcher. */}
<div <div
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')} className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
style={ style={
@@ -185,6 +156,7 @@ export function Workspace({
{panes.map((pane, idx) => { {panes.map((pane, idx) => {
const isSettings = pane.kind === 'settings'; const isSettings = pane.kind === 'settings';
const isTerminal = pane.kind === 'terminal'; const isTerminal = pane.kind === 'terminal';
const isCoder = pane.kind === 'coder';
const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact'; const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact';
// v1.9: when maximized, hide every pane except the settings one. // v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts // display:none keeps the React tree mounted so streams / drafts
@@ -197,9 +169,8 @@ export function Workspace({
} }
return null; return null;
} }
// Terminal panes own their tab strip (no chats, no ChatTabBar) and // Terminal + coder panes own their tab strip (no chats, no ChatTabBar).
// are not drag-reorderable for now — keeps the layout grid simple. const isChromeless = isSettings || isTerminal || isCoder || isArtifact;
const isChromeless = isSettings || isTerminal || isArtifact;
return ( return (
<div <div
key={pane.id} key={pane.id}
@@ -233,13 +204,66 @@ export function Workspace({
onCloseAll={() => closeAllTabs(idx)} onCloseAll={() => closeAllTabs(idx)}
onAddPane={(kind) => { onAddPane={(kind) => {
if (kind === 'chat') void createChat(idx); if (kind === 'chat') void createChat(idx);
else addSplitPane(kind); else onAddPane(kind);
}} }}
onShowHistory={() => showLandingPage(idx)} onShowHistory={() => showLandingPage(idx)}
onRename={renameChat} onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/> />
)} )}
{isCoder && (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<Code size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">BooCode</span>
<div className="ml-auto flex items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="New pane"
title="New pane"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span
className={cn(
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
coderConnected[pane.id] ? 'bg-green-500' : 'bg-red-500',
)}
title={coderConnected[pane.id] ? 'Connected' : 'Disconnected'}
/>
{panes.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removePane(idx);
}}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="Close BooCode pane"
title="Close BooCode pane"
>
<X size={12} />
</button>
)}
</div>
</div>
)}
{isTerminal && ( {isTerminal && (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0"> <div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<Terminal size={12} className="text-muted-foreground" /> <Terminal size={12} className="text-muted-foreground" />
@@ -259,14 +283,14 @@ export function Workspace({
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40"> <DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem onSelect={() => addSplitPane('chat')}> <DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New chat <MessageSquare size={14} /> New BooChat
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}> <DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New terminal <Terminal size={14} /> New BooTerm
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('coder')}> <DropdownMenuItem onSelect={() => onAddPane('coder')}>
<Code size={14} /> New coder <Code size={14} /> New BooCode
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -323,7 +347,18 @@ export function Workspace({
active={idx === activePaneIdx} active={idx === activePaneIdx}
/> />
) : pane.kind === 'coder' ? ( ) : pane.kind === 'coder' ? (
<CoderPane sessionId={sessionId} /> <CoderPane
sessionId={sessionId}
paneId={pane.id}
chatId={activePaneChatId(pane)}
chatPending={isPaneChatPending(pane.id)}
projectPath={project?.path}
onConnectedChange={(connected) =>
setCoderConnected((prev) =>
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
)
}
/>
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? ( ) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
<MarkdownArtifactPane <MarkdownArtifactPane
chatId={pane.markdown_artifact_state.chat_id} chatId={pane.markdown_artifact_state.chat_id}

View File

@@ -0,0 +1,228 @@
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
import { ToolCallGroup } from '@/components/ToolCallGroup';
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
export interface CoderMessageWire {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
status?: 'streaming' | 'complete' | 'failed';
reasoning_text?: string;
tool_calls?: CoderToolCallWire[];
}
export interface CoderToolMessageWire {
id: string;
role: 'tool';
tool_results: {
tool_call_id: string;
output: unknown;
truncated?: boolean;
error?: string;
};
}
export type CoderTimelineWire = CoderMessageWire | CoderToolMessageWire;
function isToolMessage(m: CoderTimelineWire): m is CoderToolMessageWire {
return m.role === 'tool';
}
type RenderItem =
| { kind: 'message'; message: CoderMessageWire }
| { kind: 'tool_run'; run: ToolRun; key: string }
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
const GROUP_THRESHOLD = 3;
const SCROLL_THRESHOLD_PX = 150;
function flattenCoderMessages(messages: CoderTimelineWire[]): RenderItem[] {
const items: RenderItem[] = [];
const runsByCallId = new Map<string, ToolRun>();
for (const m of messages) {
if (isToolMessage(m)) {
const run = runsByCallId.get(m.tool_results.tool_call_id);
if (run) {
run.result = {
tool_call_id: m.tool_results.tool_call_id,
output: m.tool_results.output,
truncated: m.tool_results.truncated ?? false,
...(m.tool_results.error ? { error: m.tool_results.error } : {}),
};
}
continue;
}
if (m.role === 'user' || m.role === 'system') {
items.push({ kind: 'message', message: m });
continue;
}
const hasToolCalls = (m.tool_calls?.length ?? 0) > 0;
const hasText = m.content.trim().length > 0;
const hasReasoning = (m.reasoning_text?.trim().length ?? 0) > 0;
// External agents persist tool calls + final answer on one row. Render tools
// before the answer text so the timeline matches BooChat (tools, then reply).
const externalCombined = hasToolCalls && (hasText || hasReasoning);
if (externalCombined) {
if (hasReasoning) {
items.push({
kind: 'message',
message: { ...m, content: '', reasoning_text: m.reasoning_text },
});
}
for (const tc of m.tool_calls!) {
const run = wireToolCallToRun(tc);
runsByCallId.set(tc.id, run);
items.push({ kind: 'tool_run', run, key: tc.id });
}
if (hasText || m.status === 'streaming') {
items.push({
kind: 'message',
message: { ...m, reasoning_text: undefined },
});
}
continue;
}
// Native inference: separate assistant rows per step — mirror MessageList.
if (hasText || hasReasoning || m.status === 'streaming') {
items.push({ kind: 'message', message: m });
}
if (hasToolCalls) {
for (const tc of m.tool_calls!) {
const run = wireToolCallToRun(tc);
runsByCallId.set(tc.id, run);
items.push({ kind: 'tool_run', run, key: tc.id });
}
}
}
return items;
}
function groupToolRuns(items: RenderItem[]): RenderItem[] {
const out: RenderItem[] = [];
let i = 0;
while (i < items.length) {
const item = items[i]!;
if (item.kind !== 'tool_run') {
out.push(item);
i += 1;
continue;
}
const name = item.run.call.name;
let j = i + 1;
while (
j < items.length &&
items[j]!.kind === 'tool_run' &&
(items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name
) {
j += 1;
}
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
if (run.length >= GROUP_THRESHOLD) {
out.push({ kind: 'tool_group', runs: run.map((r) => r.run), key: `group-${run[0]!.key}` });
} else {
for (const r of run) out.push(r);
}
i = j;
}
return out;
}
function CoderTextBubble({ message }: { message: CoderMessageWire }) {
const isUser = message.role === 'user';
const isStreaming = message.status === 'streaming';
const hasText = message.content.trim().length > 0;
const hasReasoning = (message.reasoning_text?.trim().length ?? 0) > 0;
if (isUser) {
return (
<div className="flex flex-col items-end gap-1">
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
{message.content}
</div>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{hasReasoning && (
<details className="rounded border border-border/40 bg-muted/20 px-2 py-1">
<summary className="cursor-pointer text-xs text-muted-foreground select-none">Reasoning</summary>
<pre className="mt-1 max-h-48 overflow-y-auto whitespace-pre-wrap text-[11px] text-muted-foreground font-mono">
{message.reasoning_text}
</pre>
</details>
)}
{(hasText || (isStreaming && !hasReasoning)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasText ? <MarkdownRenderer content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
)}
{message.status === 'failed' && (
<div className="text-xs text-destructive">message failed</div>
)}
</div>
);
}
interface Props {
messages: CoderTimelineWire[];
footer?: ReactNode;
}
export function CoderMessageList({ messages, footer }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
const renderItems = useMemo(
() => groupToolRuns(flattenCoderMessages(messages)),
[messages],
);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isNearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
}, []);
useEffect(() => {
if (isNearBottomRef.current) {
endRef.current?.scrollIntoView({ block: 'end' });
}
}, [messages]);
if (messages.length === 0) {
return null;
}
return (
<div className="flex-1 overflow-y-auto" ref={scrollRef} 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') {
return <CoderTextBubble key={item.message.id} message={item.message} />;
}
if (item.kind === 'tool_run') {
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;
})}
{footer}
<div ref={endRef} />
</div>
</div>
);
}

View File

@@ -1,16 +1,21 @@
// v2.0.0: BooCoder pane — renders the BooCoder chat + diff interface inside // BooCoder pane — chat + diff inside BooChat's multi-pane workspace.
// BooChat's multi-pane workspace.
// //
// Architecture: // REST: /api/coder/* proxied by BooChat to host boocoder.service (:9502).
// - REST calls go through /api/coder/* which BooChat's server proxies to // WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
// the boocoder container at http://boocoder:3000/api/*
// - WS connects directly to the boocoder container at :9502 (same Tailscale
// network, no CORS for WebSocket). In dev, the Vite proxy handles it.
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, Send, Check, X, RefreshCw } from 'lucide-react'; import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { AgentComposerBar } from '@/components/AgentComposerBar';
import { ProviderPicker } from '@/components/ProviderPicker'; import { PermissionCard } from '@/components/PermissionCard';
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
import { api } from '@/api/client';
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
import { useSkills } from '@/hooks/useSkills';
import { toast } from 'sonner';
import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command';
import { mergeWireToolCall } from '@/lib/coder-tools';
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -22,16 +27,26 @@ interface CoderMessage {
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
status?: 'streaming' | 'complete' | 'failed'; status?: 'streaming' | 'complete' | 'failed';
reasoning_text?: string;
tool_calls?: Array<{ tool_calls?: Array<{
id: string; id: string;
function: { name: string; arguments: string }; function: { name: string; arguments: string };
}>; }>;
tool_results?: { }
interface CoderToolMessage {
id: string;
role: 'tool';
tool_results: {
tool_call_id: string; tool_call_id: string;
content: string; output: unknown;
truncated?: boolean;
error?: string;
}; };
} }
type CoderTimelineMessage = CoderMessage | CoderToolMessage;
interface PendingChange { interface PendingChange {
id: string; id: string;
file_path: string; file_path: string;
@@ -43,24 +58,106 @@ interface PendingChange {
interface Props { interface Props {
sessionId: string; sessionId: string;
paneId: string;
chatId?: string;
chatPending?: boolean;
projectPath?: string;
onConnectedChange?: (connected: boolean) => void;
} }
// --------------------------------------------------------------------------- interface WsHandlers {
// Hooks onPermissionRequested?: (prompt: PermissionPrompt) => void;
// --------------------------------------------------------------------------- onPermissionResolved?: (taskId: string) => void;
onAssistantComplete?: () => void;
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
onConnectedChange?: (connected: boolean) => void;
}
function useCoderMessages(sessionId: string) { type RawCoderMessage = {
const [messages, setMessages] = useState<CoderMessage[]>([]); id: string;
role: string;
chat_id?: string;
content?: string | null;
status?: string | null;
reasoning_text?: string;
reasoning_parts?: Array<{ text?: string }> | null;
tool_results?: {
tool_call_id: string;
output: unknown;
truncated?: boolean;
error?: string;
} | null;
tool_calls?: Array<
| { id: string; name: string; args?: Record<string, unknown> }
| { id: string; function: { name: string; arguments: string } }
> | null;
};
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
if (raw.role === 'tool') {
if (!raw.tool_results?.tool_call_id) return null;
return {
id: raw.id,
role: 'tool',
tool_results: raw.tool_results,
};
}
if (raw.role !== 'user' && raw.role !== 'assistant' && raw.role !== 'system') return null;
const tool_calls = raw.tool_calls?.map((tc) => {
if ('function' in tc) {
return { id: tc.id, function: tc.function };
}
return {
id: tc.id,
function: {
name: tc.name,
arguments: JSON.stringify(tc.args ?? {}),
},
};
});
const reasoning_text =
raw.reasoning_text ??
raw.reasoning_parts?.map((p) => p.text ?? '').join('') ??
'';
return {
id: raw.id,
role: raw.role as CoderMessage['role'],
content: raw.content ?? '',
status: (raw.status ?? 'complete') as CoderMessage['status'],
...(reasoning_text ? { reasoning_text } : {}),
...(tool_calls?.length ? { tool_calls } : {}),
};
}
function useCoderMessages(sessionId: string, chatId: string | undefined, handlers: WsHandlers) {
const [messages, setMessages] = useState<CoderTimelineMessage[]>([]);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
const chatIdRef = useRef(chatId);
chatIdRef.current = chatId;
const loadMessages = useCallback(() => {
if (!chatId) {
setMessages([]);
return Promise.resolve();
}
return api.coder
.listMessages(sessionId, chatId)
.then((rows) =>
setMessages(
rows
.map(mapCoderTimelineRow)
.filter((m): m is CoderTimelineMessage => m !== null),
),
)
.catch(() => {/* boocoder may be down */});
}, [sessionId, chatId]);
useEffect(() => { useEffect(() => {
// Fetch existing messages on mount void loadMessages();
fetch(`/api/coder/sessions/${sessionId}/messages`) }, [loadMessages]);
.then((res) => res.ok ? res.json() : [])
.then((data: CoderMessage[]) => setMessages(data))
.catch(() => {/* noop — coder backend may not be running */});
}, [sessionId]);
useEffect(() => { useEffect(() => {
// WS connects to the coder backend. In production, this goes through the // WS connects to the coder backend. In production, this goes through the
@@ -77,38 +174,137 @@ function useCoderMessages(sessionId: string) {
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
try { try {
const frame = JSON.parse(ev.data as string); const frame = JSON.parse(ev.data as string);
if (frame.type === 'message_started') { const scopedChatId = chatIdRef.current;
setMessages((prev) => [ if (
scopedChatId &&
frame.chat_id &&
frame.chat_id !== scopedChatId &&
frame.type !== 'snapshot'
) {
return;
}
if (frame.type === 'snapshot' && Array.isArray(frame.messages)) {
const rawMessages = (frame.messages as RawCoderMessage[]).filter(
(m) => !scopedChatId || m.chat_id === scopedChatId,
);
setMessages(
rawMessages
.map(mapCoderTimelineRow)
.filter((m): m is CoderTimelineMessage => m !== null),
);
} else if (frame.type === 'message_started') {
setMessages((prev) => {
if (prev.some((m) => m.id === frame.message_id)) return prev;
const role = frame.role ?? 'assistant';
const tempIdx =
role === 'user'
? prev.findIndex((m) => m.id.startsWith('temp-') && m.role === 'user')
: -1;
if (tempIdx >= 0) {
return prev.map((m, i) =>
i === tempIdx ? { ...m, id: frame.message_id, status: 'streaming' } : m,
);
}
return [
...prev, ...prev,
{ id: frame.message_id, role: frame.role ?? 'assistant', content: '', status: 'streaming' }, { id: frame.message_id, role, content: '', status: 'streaming' },
]); ];
});
} else if (frame.type === 'delta') { } else if (frame.type === 'delta') {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) => {
m.id === frame.message_id if (m.id !== frame.message_id || m.role === 'tool') return m;
? { ...m, content: m.content + (frame.content ?? '') } const chunk = frame.content ?? '';
: m if (m.role === 'user') {
) return { ...m, content: chunk || m.content };
}
return { ...m, content: m.content + chunk };
}),
); );
} else if (frame.type === 'message_complete') { } else if (frame.type === 'message_complete') {
setMessages((prev) => setMessages((prev) => {
prev.map((m) => const completed = prev.find(
m.id === frame.message_id ? { ...m, status: 'complete' } : m (m): m is CoderMessage => m.id === frame.message_id && m.role === 'assistant',
)
); );
const next = prev.map((m) =>
m.id === frame.message_id && m.role !== 'tool'
? { ...m, status: 'complete' as const }
: m,
);
if (completed) {
queueMicrotask(() => handlersRef.current.onAssistantComplete?.());
}
return next;
});
} else if (frame.type === 'tool_call') { } else if (frame.type === 'tool_call') {
const tc = frame.tool_call as { id: string; name: string; args?: Record<string, unknown> } | undefined;
if (tc?.id) {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === frame.message_id m.role !== 'assistant' || m.id !== frame.message_id
? m
: { ...m, tool_calls: mergeWireToolCall(m.tool_calls, { ...tc, args: tc.args ?? {} }) },
),
);
}
} else if (frame.type === 'tool_result') {
setMessages((prev) => {
const exists = prev.some((m) => m.id === frame.tool_message_id);
if (exists) {
return prev.map((m) =>
m.role === 'tool' && m.id === frame.tool_message_id
? { ? {
...m, ...m,
tool_calls: [ tool_results: {
...(m.tool_calls ?? []), tool_call_id: frame.tool_call_id,
{ id: frame.tool_call_id, function: { name: frame.name, arguments: frame.arguments ?? '' } }, output: frame.output,
], truncated: frame.truncated,
...(frame.error ? { error: frame.error } : {}),
},
} }
: m : m,
) );
}
return [
...prev,
{
id: frame.tool_message_id,
role: 'tool' as const,
tool_results: {
tool_call_id: frame.tool_call_id,
output: frame.output,
truncated: frame.truncated,
...(frame.error ? { error: frame.error } : {}),
},
},
];
});
} else if (frame.type === 'reasoning_delta') {
setMessages((prev) =>
prev.map((m) =>
m.id === frame.message_id && m.role === 'assistant'
? { ...m, reasoning_text: (m.reasoning_text ?? '') + (frame.content ?? '') }
: m,
),
);
} else if (frame.type === 'permission_requested') {
handlersRef.current.onPermissionRequested?.({
taskId: frame.task_id,
toolTitle: frame.tool_title,
options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
optionId: o.option_id,
label: o.label,
})),
});
} else if (frame.type === 'permission_resolved') {
handlersRef.current.onPermissionResolved?.(frame.task_id);
} else if (frame.type === 'agent_commands') {
handlersRef.current.onAgentCommands?.(
frame.task_id,
(frame.commands ?? []).map((c: { name: string; description?: string }) => ({
name: c.name,
description: c.description,
})),
); );
} }
} catch { } catch {
@@ -122,7 +318,11 @@ function useCoderMessages(sessionId: string) {
}; };
}, [sessionId]); }, [sessionId]);
return { messages, setMessages, connected }; useEffect(() => {
handlersRef.current.onConnectedChange?.(connected);
}, [connected]);
return { messages, setMessages, connected, loadMessages };
} }
function usePendingChanges(sessionId: string) { function usePendingChanges(sessionId: string) {
@@ -165,48 +365,6 @@ function usePendingChanges(sessionId: string) {
// Sub-components // Sub-components
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function CoderMessageBubble({ message }: { message: CoderMessage }) {
const isUser = message.role === 'user';
return (
<div className={cn('flex flex-col gap-1 px-3 py-2', isUser ? 'items-end' : 'items-start')}>
<div
className={cn(
'rounded-lg px-3 py-2 max-w-[85%] text-sm',
isUser
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground'
)}
>
{isUser ? (
<p className="whitespace-pre-wrap">{message.content}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
<MarkdownRenderer content={message.content} />
</div>
)}
{message.tool_calls && message.tool_calls.length > 0 && (
<div className="mt-2 border-t border-border/50 pt-2 space-y-1">
{message.tool_calls.map((tc) => (
<div key={tc.id} className="text-xs font-mono text-muted-foreground">
<span className="text-primary/70">{tc.function.name}</span>
{tc.function.arguments && (
<span className="ml-1 opacity-60">
({tc.function.arguments.slice(0, 80)}
{tc.function.arguments.length > 80 ? '...' : ''})
</span>
)}
</div>
))}
</div>
)}
{message.status === 'streaming' && (
<span className="inline-block w-2 h-4 bg-current opacity-60 animate-pulse ml-0.5" />
)}
</div>
</div>
);
}
function DiffPanel({ function DiffPanel({
changes, changes,
loading, loading,
@@ -296,115 +454,272 @@ function DiffPanel({
// Main component // Main component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function CoderPane({ sessionId }: Props) { export function CoderPane({
const { messages, setMessages, connected } = useCoderMessages(sessionId); sessionId,
paneId,
chatId,
chatPending = false,
projectPath,
onConnectedChange,
}: Props) {
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
provider: 'boocode',
model: '',
modeId: null,
thinkingOptionId: null,
});
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
const [permissionBusy, setPermissionBusy] = useState(false);
const [providerCommands, setProviderCommands] = useState<AgentCommand[]>([]);
const [liveTaskCommands, setLiveTaskCommands] = useState<AgentCommand[]>([]);
const { skills } = useSkills();
const [slashState, setSlashState] = useState<{ query: string } | null>(null);
const displayedCommands = useMemo(() => {
const base =
agentConfig.provider === 'boocode'
? skills.map((s) => ({ name: s.name, description: s.description }))
: providerCommands;
return mergeCommandsByName(base, liveTaskCommands);
}, [agentConfig.provider, skills, providerCommands, liveTaskCommands]);
const skillsByName = useMemo(() => new Set(skills.map((s) => s.name)), [skills]);
const commandsByName = useMemo(
() => new Set(displayedCommands.map((c) => c.name)),
[displayedCommands],
);
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
onConnectedChange,
onPermissionRequested: (prompt) => {
setActiveTaskId(prompt.taskId);
setPermissionPrompt(prompt);
},
onPermissionResolved: (taskId) => {
if (activeTaskId === taskId || permissionPrompt?.taskId === taskId) {
setPermissionPrompt(null);
}
},
onAssistantComplete: () => {
setActiveTaskId(null);
setPermissionPrompt(null);
setLiveTaskCommands([]);
},
onAgentCommands: (_taskId, commands) => {
setLiveTaskCommands(commands);
},
});
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId); const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [provider, setProvider] = useState('boocode');
const [model, setModel] = useState('qwen3.6-35b-a3b-mxfp4');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Refresh pending changes when a message_complete arrives // Refresh pending changes when a message_complete arrives
useEffect(() => { useEffect(() => {
const lastMsg = messages[messages.length - 1]; const lastAssistant = [...messages].reverse().find(
if (lastMsg?.role === 'assistant' && lastMsg.status === 'complete') { (m): m is CoderMessage => m.role === 'assistant',
);
if (lastAssistant?.status === 'complete') {
refresh(); refresh();
} }
}, [messages, refresh]); }, [messages, refresh]);
// Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth)
useEffect(() => {
if (!activeTaskId || connected) return;
const interval = setInterval(() => {
if (!permissionPrompt) {
void api.coder
.getTaskPermission(activeTaskId)
.then((prompt) => {
setPermissionPrompt({
taskId: prompt.taskId,
toolTitle: prompt.toolTitle,
options: prompt.options,
});
})
.catch(() => {/* no pending permission */});
}
void api.coder
.getTaskCommands(activeTaskId)
.then((res) => setLiveTaskCommands(res.commands))
.catch(() => {/* not cached yet */});
void api.coder
.getTask(activeTaskId)
.then((task) => {
if (task.state === 'running' || task.state === 'pending' || task.state === 'blocked') {
return;
}
setActiveTaskId(null);
setPermissionPrompt(null);
setLiveTaskCommands([]);
void loadMessages();
})
.catch(() => {/* task gone */});
}, 2000);
return () => clearInterval(interval);
}, [activeTaskId, connected, permissionPrompt, loadMessages]);
const handleProviderCommandsChange = useCallback((commands: AgentCommand[]) => {
setProviderCommands(commands);
}, []);
const handlePermissionRespond = useCallback(async (optionId: string | null) => {
if (!permissionPrompt) return;
setPermissionBusy(true);
try {
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId);
setPermissionPrompt(null);
} finally {
setPermissionBusy(false);
}
}, [permissionPrompt]);
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
const text = input.trim(); const text = input.trim();
if (!text || sending) return; if (!text || sending || !chatId) return;
if (text.startsWith('/')) {
const parsed = parseSlashInput(text);
if (parsed) {
const { cmdName, args } = parsed;
if (agentConfig.provider === 'boocode' && skillsByName.has(cmdName)) {
setInput('');
setSlashState(null);
setSending(true);
setPermissionPrompt(null);
setLiveTaskCommands([]);
try {
await api.coder.skillInvoke(
sessionId,
paneId,
cmdName,
args.length > 0 ? args : null,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally {
setSending(false);
}
return;
}
if (!commandsByName.has(cmdName)) {
// Unknown slash — fall through and send as literal text.
}
}
}
setInput(''); setInput('');
setSlashState(null);
setSending(true); setSending(true);
setPermissionPrompt(null);
setLiveTaskCommands([]);
// Optimistic user message
const tempId = `temp-${Date.now()}`; const tempId = `temp-${Date.now()}`;
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]); setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
try { try {
const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, { const data = await api.coder.sendMessage(sessionId, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
content: text, content: text,
provider: provider !== 'boocode' ? provider : undefined, pane_id: paneId,
model: model || undefined, chat_id: chatId,
}), provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined,
model: agentConfig.model || undefined,
mode_id: agentConfig.modeId ?? undefined,
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
}); });
if (res.ok) {
const data = await res.json();
// Replace temp message with real one if server returned it
if (data.user_message_id) { if (data.user_message_id) {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => m.id === tempId ? { ...m, id: data.user_message_id } : m) prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
); );
} }
if (data.task_id) {
setActiveTaskId(data.task_id);
} else {
setActiveTaskId(null);
} }
} catch { } catch (err) {
// The WS will bring the real messages; optimistic is good enough toast.error(err instanceof Error ? err.message : 'failed to send');
} finally { } finally {
setSending(false); setSending(false);
} }
}, [input, sending, sessionId, provider, model, setMessages]); }, [
input,
sending,
sessionId,
paneId,
chatId,
agentConfig,
skillsByName,
commandsByName,
setMessages,
]);
const handleSlashSelect = useCallback((name: string) => {
const next = `/${name} `;
setInput(next);
setSlashState(null);
requestAnimationFrame(() => {
const ta = inputRef.current;
if (ta) {
ta.selectionStart = ta.selectionEnd = next.length;
ta.focus();
}
});
}, []);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInput(newValue);
if (isSlashCommandToken(newValue)) {
setSlashState({ query: slashQuery(newValue) });
} else {
setSlashState(null);
}
}, []);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (slashState) return;
if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
void handleSend(); void handleSend();
} }
}, },
[handleSend] [handleSend, slashState]
); );
return ( return (
<div className="flex flex-col h-full bg-background"> <div className="flex flex-col h-full bg-background">
{/* Header */} {/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/30 shrink-0"> <div className="flex-1 min-h-0 flex flex-col">
<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 shrink-0',
connected ? 'bg-green-500' : 'bg-red-500'
)}
title={connected ? 'Connected' : 'Disconnected'}
/>
</div>
{/* Chat area */}
<div className="flex-1 min-h-0 overflow-y-auto">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-sm text-muted-foreground gap-2"> <div className="flex flex-col items-center justify-center flex-1 text-sm text-muted-foreground gap-2">
<Code size={32} className="opacity-40" /> <Code size={32} className="opacity-40" />
<p>Send a message to start coding</p> <p>{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}</p>
</div> </div>
) : ( ) : (
<div className="py-2"> <CoderMessageList
{messages.map((msg) => ( messages={messages as CoderTimelineWire[]}
<CoderMessageBubble key={msg.id} message={msg} /> footer={
))} activeTaskId && !permissionPrompt && sending === false ? (
<div ref={messagesEndRef} /> <p className="text-xs text-muted-foreground animate-pulse">Agent running</p>
</div> ) : undefined
}
/>
)} )}
</div> </div>
{permissionPrompt && (
<PermissionCard
prompt={permissionPrompt}
onRespond={(id) => void handlePermissionRespond(id)}
busy={permissionBusy}
/>
)}
{/* Diff panel — only shows when there are pending changes */} {/* Diff panel — only shows when there are pending changes */}
{changes.filter((c) => c.status === 'pending').length > 0 && ( {changes.filter((c) => c.status === 'pending').length > 0 && (
<div className="h-48 shrink-0"> <div className="h-48 shrink-0">
@@ -418,22 +733,30 @@ export function CoderPane({ sessionId }: Props) {
</div> </div>
)} )}
{/* Input */} {/* Composer + input */}
<div className="shrink-0 border-t border-border p-2"> <div className="shrink-0 border-t border-border">
{displayedCommands.length > 0 && <AgentCommandsHint commands={displayedCommands} />}
<AgentComposerBar
projectPath={projectPath}
value={agentConfig}
onChange={setAgentConfig}
onProviderCommandsChange={handleProviderCommandsChange}
/>
<div className="p-2">
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<textarea <textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Ask BooCoder to write code..." placeholder="Type / for commands…"
rows={1} rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring max-h-32 min-h-[36px] max-md:min-h-[44px]" className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring max-h-32 min-h-[36px] max-md:min-h-[44px]"
/> />
<button <button
type="button" type="button"
onClick={() => void handleSend()} onClick={() => void handleSend()}
disabled={!input.trim() || sending} disabled={!input.trim() || sending || !chatId || chatPending}
className="inline-flex items-center justify-center size-9 max-md:min-h-[44px] max-md:min-w-[44px] rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed shrink-0" className="inline-flex items-center justify-center size-9 max-md:min-h-[44px] max-md:min-w-[44px] rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
aria-label="Send message" aria-label="Send message"
> >
@@ -441,6 +764,16 @@ export function CoderPane({ sessionId }: Props) {
</button> </button>
</div> </div>
</div> </div>
{slashState && (
<SlashCommandPicker
query={slashState.query}
items={displayedCommands}
inputRef={inputRef}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
/>
)}
</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,49 @@
import { useEffect, useSyncExternalStore } from 'react';
import { api } from '@/api/client';
import type { ProviderSnapshotEntry } from '@/api/types';
let cached: ProviderSnapshotEntry[] | null = null;
let inflight: Promise<ProviderSnapshotEntry[]> | null = null;
const listeners = new Set<() => void>();
function notify(): void {
for (const fn of listeners) fn();
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
function getSnapshot(): ProviderSnapshotEntry[] | null {
return cached;
}
async function doFetch(cwd?: string): Promise<ProviderSnapshotEntry[]> {
const data = await api.coder.snapshot(cwd);
cached = data;
inflight = null;
notify();
return data;
}
function ensureLoaded(cwd?: string): void {
if (cached || inflight) return;
inflight = doFetch(cwd).catch((err) => {
inflight = null;
console.error('provider snapshot fetch failed:', err);
return [];
});
}
export function refreshProviderSnapshot(cwd?: string): Promise<ProviderSnapshotEntry[]> {
cached = null;
inflight = null;
return doFetch(cwd);
}
export function useProviderSnapshot(cwd?: string): ProviderSnapshotEntry[] | null {
const entries = useSyncExternalStore(subscribe, getSnapshot);
useEffect(() => { ensureLoaded(cwd); }, [cwd]);
return entries;
}

View File

@@ -48,9 +48,14 @@ function applyFrame(state: State, frame: WsFrame): State {
return { ...state, messages: [...state.messages, newMsg] }; return { ...state, messages: [...state.messages, newMsg] };
} }
case 'delta': { case 'delta': {
const next = state.messages.map((m) => const next = state.messages.map((m) => {
m.id === frame.message_id ? { ...m, content: m.content + frame.content } : m if (m.id !== frame.message_id) return m;
); const chunk = frame.content ?? '';
if (m.role === 'user') {
return { ...m, content: chunk || m.content };
}
return { ...m, content: m.content + chunk };
});
return { ...state, messages: next }; return { ...state, messages: next };
} }
case 'tool_call': { case 'tool_call': {

View File

@@ -32,19 +32,19 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
} }
// v1.10 booterm: terminal panes carry no chats. Their `id` is used as the function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They return kind === 'coder' ? 'BooCoder' : 'Terminal';
// persist in localStorage along with chat panes so a refresh resumes the
// same tmux window via the idempotent start endpoint.
function terminalPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
} }
// v2.0.0: coder pane — renders the BooCoder interface (chat + diff panel). function scopedPane(id: string, kind: 'coder' | 'terminal', chatId: string): WorkspacePane {
// Like terminal panes, carries no chats — the CoderPane component manages return { id, kind, chatId, chatIds: [chatId], activeChatIdx: 0 };
// its own session/messages via the /api/coder proxy. }
function coderPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'coder', chatIds: [], activeChatIdx: -1 }; /** Active chat id for a pane row (chat / coder / terminal). */
export function activePaneChatId(pane: WorkspacePane): string | undefined {
const idx = pane.activeChatIdx ?? 0;
if (idx >= 0 && pane.chatIds?.[idx]) return pane.chatIds[idx];
return pane.chatId;
} }
// v1.9: settings pane factory. No chats, no state beyond identity — the // v1.9: settings pane factory. No chats, no state beyond identity — the
@@ -79,8 +79,20 @@ function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
// v1.9: settings panes are ephemeral. Filter them out before persisting so a // v1.9: settings panes are ephemeral. Filter them out before persisting so a
// page reload always returns to a clean workspace; the user re-opens via the // page reload always returns to a clean workspace; the user re-opens via the
// sidebar Settings button when needed. // sidebar Settings button when needed.
function normalizePaneKind(pane: WorkspacePane): WorkspacePane {
// v2.3: server once accepted legacy 'agent' before 'coder' landed in the schema.
if ((pane.kind as string) === 'agent') {
return { ...pane, kind: 'coder' };
}
return pane;
}
function normalizePanes(panes: WorkspacePane[]): WorkspacePane[] {
return panes.map(normalizePaneKind);
}
function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] { function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
return panes.filter((p) => p.kind !== 'settings'); return normalizePanes(panes).filter((p) => p.kind !== 'settings');
} }
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES. // v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
@@ -128,6 +140,8 @@ export interface UseWorkspacePanesResult {
removeChatFromPanes: (chatId: string) => void; removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void; initializeFirstChatIfEmpty: (chatId: string) => void;
validatePanes: (validChatIds: Set<string>) => void; validatePanes: (validChatIds: Set<string>) => void;
/** True while a coder/terminal pane is waiting for its scoped chat row. */
isPaneChatPending: (paneId: string) => boolean;
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void; handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void; handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragLeave: () => void; handlePaneDragLeave: () => void;
@@ -149,6 +163,54 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
// Tracks the last value broadcast by another device (or this one's own // Tracks the last value broadcast by another device (or this one's own
// round-trip). If a PATCH would echo this exact payload, we skip the call. // round-trip). If a PATCH would echo this exact payload, we skip the call.
const lastRemoteJsonRef = useRef<string>('[]'); const lastRemoteJsonRef = useRef<string>('[]');
const pendingPaneChatRef = useRef<Set<string>>(new Set());
const [pendingPaneChatIds, setPendingPaneChatIds] = useState<Set<string>>(() => new Set());
const markPaneChatPending = useCallback((paneId: string, pending: boolean) => {
setPendingPaneChatIds((prev) => {
const next = new Set(prev);
if (pending) next.add(paneId);
else next.delete(paneId);
pendingPaneChatRef.current = next;
return next;
});
}, []);
const attachChatToPane = useCallback(
(paneId: string, chatId: string, kind: 'coder' | 'terminal') => {
setPanes((prev) =>
prev.map((p) => (p.id === paneId ? scopedPane(paneId, kind, chatId) : p)),
);
},
[],
);
const seedPaneChat = useCallback(
async (paneId: string, kind: 'coder' | 'terminal') => {
if (pendingPaneChatRef.current.has(paneId)) return;
markPaneChatPending(paneId, true);
try {
const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind(kind) });
attachChatToPane(paneId, chat.id, kind);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create pane chat');
} finally {
markPaneChatPending(paneId, false);
}
},
[sessionId, attachChatToPane, markPaneChatPending],
);
const seedEmptyScopedPanes = useCallback(
(paneList: WorkspacePane[]) => {
for (const pane of paneList) {
if (pane.kind !== 'coder' && pane.kind !== 'terminal') continue;
if ((pane.chatIds?.length ?? 0) > 0 || pane.chatId) continue;
void seedPaneChat(pane.id, pane.kind);
}
},
[seedPaneChat],
);
// v1.12.1: hydrate from server on mount, then subscribe to remote updates. // v1.12.1: hydrate from server on mount, then subscribe to remote updates.
useEffect(() => { useEffect(() => {
@@ -159,7 +221,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const session = await api.sessions.get(sessionId); const session = await api.sessions.get(sessionId);
if (cancelled) return; if (cancelled) return;
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes) let initial: WorkspacePane[] = Array.isArray(session.workspace_panes)
? session.workspace_panes ? normalizePanes(session.workspace_panes)
: []; : [];
// One-time migration: if server is empty but legacy localStorage has // One-time migration: if server is empty but legacy localStorage has
// a layout, seed the server and delete the local key. // a layout, seed the server and delete the local key.
@@ -180,12 +242,13 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next)); lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next));
setPanes(next); setPanes(next);
setActivePaneIdx(0); setActivePaneIdx(0);
seedEmptyScopedPanes(next);
} finally { } finally {
if (!cancelled) hydratedRef.current = true; if (!cancelled) hydratedRef.current = true;
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [sessionId]); }, [sessionId, seedEmptyScopedPanes]);
// v1.12.1: live cross-device sync. Replace local state when another device // v1.12.1: live cross-device sync. Replace local state when another device
// (or our own write echo) lands a session_workspace_updated frame. // (or our own write echo) lands a session_workspace_updated frame.
@@ -193,14 +256,17 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return sessionEvents.subscribe((ev) => { return sessionEvents.subscribe((ev) => {
if (ev.type !== 'session_workspace_updated') return; if (ev.type !== 'session_workspace_updated') return;
if (ev.session_id !== sessionId) return; if (ev.session_id !== sessionId) return;
const incoming = Array.isArray(ev.workspace_panes) ? ev.workspace_panes : []; const incoming = normalizePanes(
Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [],
);
const json = JSON.stringify(incoming); const json = JSON.stringify(incoming);
if (json === lastRemoteJsonRef.current) return; if (json === lastRemoteJsonRef.current) return;
lastRemoteJsonRef.current = json; lastRemoteJsonRef.current = json;
setPanes(incoming.length > 0 ? incoming : [emptyPane()]); setPanes(incoming.length > 0 ? incoming : [emptyPane()]);
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1))); setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
seedEmptyScopedPanes(incoming.length > 0 ? incoming : [emptyPane()]);
}); });
}, [sessionId]); }, [sessionId, seedEmptyScopedPanes]);
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of // v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of
// these per click. If a pane already exists for the same message_id, focus // these per click. If a pane already exists for the same message_id, focus
@@ -388,8 +454,10 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const showLandingPage = useCallback((paneIdx: number) => { const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => { setPanes((prev) => {
const pane = prev[paneIdx];
// Coder/terminal panes are not chat hosts — history button is chat-only.
if (!pane || pane.kind === 'coder' || pane.kind === 'terminal') return prev;
const next = [...prev]; const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined }; next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
return next; return next;
}); });
@@ -408,16 +476,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return prev; return prev;
} }
const newPane = const newPane =
kind === 'terminal' ? terminalPane(newPaneId) : kind === 'terminal'
kind === 'coder' ? coderPane(newPaneId) : ? { id: newPaneId, kind: 'terminal' as const, chatIds: [] as string[], activeChatIdx: -1 }
emptyPane(newPaneId); : kind === 'coder'
? { id: newPaneId, kind: 'coder' as const, chatIds: [] as string[], activeChatIdx: -1 }
: emptyPane(newPaneId);
const next = [...prev, newPane]; const next = [...prev, newPane];
setActivePaneIdx(next.length - 1); setActivePaneIdx(next.length - 1);
success = true; success = true;
if (kind === 'terminal' || kind === 'coder') {
queueMicrotask(() => void seedPaneChat(newPaneId, kind));
}
return next; return next;
}); });
return success ? newPaneId : null; return success ? newPaneId : null;
}, []); }, [seedPaneChat]);
const toggleSettingsPane = useCallback(() => { const toggleSettingsPane = useCallback(() => {
setPanes((prev) => { setPanes((prev) => {
@@ -476,19 +549,39 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const validatePanes = useCallback((validChatIds: Set<string>) => { const validatePanes = useCallback((validChatIds: Set<string>) => {
setPanes((prev) => { setPanes((prev) => {
const cleaned = prev.map((pane) => { const cleaned = prev.map((pane) => {
if (pane.kind !== 'chat' || pane.chatIds.length === 0) return pane; const usesChat =
pane.kind === 'chat' || pane.kind === 'coder' || pane.kind === 'terminal';
if (!usesChat || pane.chatIds.length === 0) return pane;
const nextIds = pane.chatIds.filter((id) => validChatIds.has(id)); const nextIds = pane.chatIds.filter((id) => validChatIds.has(id));
if (nextIds.length === pane.chatIds.length) return pane; if (nextIds.length === pane.chatIds.length) return pane;
if (nextIds.length === 0) { if (nextIds.length === 0) {
if (pane.kind === 'chat') {
return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 }; return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
} }
return { ...pane, chatId: undefined, chatIds: [], activeChatIdx: -1 };
}
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
return { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx] }; return { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx] };
}); });
const unchanged = cleaned.every((p, i) => p === prev[i]); const unchanged = cleaned.every((p, i) => p === prev[i]);
return unchanged ? prev : cleaned; const next = unchanged ? prev : cleaned;
if (!unchanged) {
for (const pane of next) {
if (pane.kind === 'coder' && !activePaneChatId(pane)) {
queueMicrotask(() => void seedPaneChat(pane.id, 'coder'));
} else if (pane.kind === 'terminal' && !activePaneChatId(pane)) {
queueMicrotask(() => void seedPaneChat(pane.id, 'terminal'));
}
}
}
return next;
}); });
}, []); }, [seedPaneChat]);
const isPaneChatPending = useCallback(
(paneId: string) => pendingPaneChatIds.has(paneId),
[pendingPaneChatIds],
);
const removeChatFromPanes = useCallback((chatId: string) => { const removeChatFromPanes = useCallback((chatId: string) => {
setPanes((prev) => prev.map((p) => { setPanes((prev) => prev.map((p) => {
@@ -574,6 +667,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
removeChatFromPanes, removeChatFromPanes,
initializeFirstChatIfEmpty, initializeFirstChatIfEmpty,
validatePanes, validatePanes,
isPaneChatPending,
handlePaneDragStart, handlePaneDragStart,
handlePaneDragOver, handlePaneDragOver,
handlePaneDragLeave, handlePaneDragLeave,

View File

@@ -0,0 +1,11 @@
/** User messages are inserted atomically — never stream-append like assistant deltas. */
export function applyMessageDelta(
role: 'user' | 'assistant' | 'system' | 'tool',
existingContent: string,
chunk: string,
): string {
if (role === 'user') {
return chunk || existingContent;
}
return existingContent + chunk;
}

View File

@@ -0,0 +1,18 @@
/** Sessions created for BooCoder work (sidebar / project list icons). */
export function isCoderSessionName(name: string | null | undefined): boolean {
if (!name) return false;
if (name === 'New BooCode') return true;
if (name.startsWith('Task [')) return true;
if (name.startsWith('Coder:')) return true;
return false;
}
/** Optimistic coder pane shell before scoped chat id arrives from the server. */
export function defaultCoderWorkspacePane(id: string = crypto.randomUUID()) {
return {
id,
kind: 'coder' as const,
chatIds: [] as string[],
activeChatIdx: -1,
};
}

View File

@@ -0,0 +1,68 @@
import type { ToolCall, ToolResult } from '@/api/types';
import type { ToolRun } from '@/components/ToolCallLine';
export interface AcpWireMeta {
status?: 'running' | 'completed' | 'failed' | 'canceled';
kind?: string | null;
title?: string;
output?: unknown;
error?: string;
}
export interface CoderToolCallWire {
id: string;
function: { name: string; arguments: string };
}
function parseArgs(raw: string): Record<string, unknown> {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
export function wireToolCallToRun(wire: CoderToolCallWire): ToolRun {
const args = parseArgs(wire.function.arguments);
const acp = args._acp as AcpWireMeta | undefined;
const { _acp: _ignored, ...rest } = args;
const lifecycle = acp?.status ?? 'running';
const call: ToolCall = {
id: wire.id,
name: wire.function.name,
args: rest,
};
if (lifecycle === 'running') {
return { call, result: null };
}
const result: ToolResult = {
tool_call_id: wire.id,
output: acp?.output ?? null,
truncated: false,
...(acp?.error ? { error: acp.error } : {}),
};
return { call, result };
}
export function mergeWireToolCall(
existing: CoderToolCallWire[] | undefined,
incoming: { id: string; name: string; args: Record<string, unknown> },
): CoderToolCallWire[] {
const entry: CoderToolCallWire = {
id: incoming.id,
function: { name: incoming.name, arguments: JSON.stringify(incoming.args) },
};
const list = existing ?? [];
const idx = list.findIndex((tc) => tc.id === incoming.id);
if (idx >= 0) {
const next = [...list];
next[idx] = entry;
return next;
}
return [...list, entry];
}
export function wireToolCallsToRuns(wires: CoderToolCallWire[] | undefined): ToolRun[] {
return (wires ?? []).map(wireToolCallToRun);
}

View File

@@ -0,0 +1,29 @@
export interface SlashCommandItem {
name: string;
description?: string;
}
/** True while the user is still typing the command name after `/`. */
export function isSlashCommandToken(value: string): boolean {
return /^\/[^\s]*$/.test(value);
}
export function slashQuery(value: string): string {
return value.slice(1);
}
export function parseSlashInput(text: string): { cmdName: string; args: string } | null {
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
if (!match) return null;
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
}
export function mergeCommandsByName(...lists: SlashCommandItem[][]): SlashCommandItem[] {
const byName = new Map<string, SlashCommandItem>();
for (const list of lists) {
for (const cmd of list) {
byName.set(cmd.name, cmd);
}
}
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
}

View File

@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu } from 'lucide-react'; import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu, Code } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Project as ProjectType, Session } from '@/api/types'; import type { Project as ProjectType, Session } from '@/api/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useSessions } from '@/hooks/useSessions'; import { useSessions } from '@/hooks/useSessions';
import { isCoderSessionName } from '@/lib/coder-session';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
@@ -124,7 +125,11 @@ export function Project() {
{sessions.map((s) => ( {sessions.map((s) => (
<li key={s.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50"> <li key={s.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
<Link to={`/session/${s.id}`} className="flex-1 flex items-center gap-2 min-w-0"> <Link to={`/session/${s.id}`} className="flex-1 flex items-center gap-2 min-w-0">
{isCoderSessionName(s.name) ? (
<Code className="size-3.5 opacity-70 shrink-0" />
) : (
<MessageSquare className="size-3.5 opacity-70 shrink-0" /> <MessageSquare className="size-3.5 opacity-70 shrink-0" />
)}
<span className="truncate text-sm">{s.name}</span> <span className="truncate text-sm">{s.name}</span>
<span className="text-xs text-muted-foreground font-mono ml-2 shrink-0"> <span className="text-xs text-muted-foreground font-mono ml-2 shrink-0">
{s.model} {s.model}

View File

@@ -190,6 +190,9 @@ function SessionInner({ sessionId }: { sessionId: string }) {
[addSplitPane, isMobile, navigate, location.pathname, location.search], [addSplitPane, isMobile, navigate, location.pathname, location.search],
); );
const activePaneKind = panes[activePaneIdx]?.kind;
const showSessionModelPicker = activePaneKind !== 'coder';
// v1.10.3 keyboard shortcuts. Window-level keydown so they fire from // v1.10.3 keyboard shortcuts. Window-level keydown so they fire from
// anywhere in the session view. Only Cmd/Ctrl-Shift-C defers to the xterm // anywhere in the session view. Only Cmd/Ctrl-Shift-C defers to the xterm
// (which has its own copy binding for that combo); everything else fires // (which has its own copy binding for that combo); everything else fires
@@ -351,7 +354,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
)} )}
</div> </div>
{session && ( {session && showSessionModelPicker && (
<ModelPicker <ModelPicker
value={session.model} value={session.model}
onChange={async (model) => { onChange={async (model) => {
@@ -449,7 +452,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
)} )}
<div className="ml-auto shrink-0"> <div className="ml-auto shrink-0">
{session && ( {session && showSessionModelPicker && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1"> <div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker <ModelPicker
value={session.model} value={session.model}
@@ -478,6 +481,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
chatsHook={chatsHook} chatsHook={chatsHook}
session={session} session={session}
project={project} project={project}
onAddPane={addPaneAndSwitch}
/> />
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
# BooCode — External Code Review & Lift Inventory # BooCode — External Code Review & Lift Inventory
Last updated: 2026-05-22 Last updated: 2026-05-25
This document tracks every open source repo BooCode references or lifts code from. Pin this so we don't lose attribution and don't re-evaluate the same projects twice. This document tracks every open source repo BooCode references or lifts code from. Pin this so we don't lose attribution and don't re-evaluate the same projects twice.
@@ -8,7 +8,7 @@ BooCode is personal/single-user — license compatibility is non-blocking, but t
> **Companion doc:** `boocode_roadmap.md` is the canonical source for shipping state, version ordering, and what's planned vs. shipped. This document is the canonical source for *why* each external repo earned its row. Reconcile shipping state via the roadmap when in doubt. > **Companion doc:** `boocode_roadmap.md` is the canonical source for shipping state, version ordering, and what's planned vs. shipped. This document is the canonical source for *why* each external repo earned its row. Reconcile shipping state via the roadmap when in doubt.
> >
> **Shipped reality as of 2026-05-22** (per roadmap): v1.13.1 (`ac1a71f`), v1.13.3 (`a08d809`), v1.13.4 (`ec8593c`), v1.13.5 (`f8fc5db`), and v1.13.6 (`81d837c`) tagged. AI SDK v6 migration done. `message_parts` table + `messages_with_parts` view live with dual-write. `experimental_repairToolCall` wired. Alpha tool ordering shipped. Two-tier compaction prune + truncate.ts opaque-id retrieval shipped. v1.13.6 closed the Q3 reasoning-render gap in compaction (latent regression from v1.13.1-C). **v1.13.7 stability bundle** (`includeUsage:true` for usage capture, trim guards against `\n` content artifacts, payload filter for trailing empty/failed assistants, `BUDGET_NO_AGENT 15→30`) — fixes a v1.13.1-A latent regression where `result.usage` came back empty. v1.13.2 (legacy-column drop) **deferred behind v1.13.8v1.13.12** as rollback insurance. v1.13.x cleanup line order is locked and **must not be folded**: v1.13.8 → v1.13.9 → v1.13.10 → v1.13.11 → v1.13.12 → v1.13.2. If anything in this catalog reads "planned" for a v1.11.xv1.13.6 lift, check the lift catalog table at the bottom for the corrected status. > **Shipped reality as of 2026-05-25** (per roadmap + CHANGELOG): v1.13.x line closed. v2.0 BooCoder shipped (write tools, dispatcher, MCP server, CoderPane). v2.1.0 provider picker shipped — BooCoder on host systemd, direct spawn dispatch (SSH deprecated). Database name `boochat`, Docker service `boocode_db`. When this catalog reads "planned" for shipped work, check `CHANGELOG.md` and `boocode_roadmap.md`.
----- -----
@@ -18,16 +18,15 @@ Sam wants BooCode to function like Paseo without using Paseo itself. **Paseo (ge
### Locked architecture decisions (2026-05-22, Sam confirmed) ### Locked architecture decisions (2026-05-22, Sam confirmed)
1. **Monorepo with three apps, not three repos.** `/opt/boocode/apps/`: 1. **Monorepo with three surfaces, not three backend packages.** `/opt/boocode/apps/`:
- `apps/web/` existing React SPA (the current chat UI). - `apps/web/` — React SPA (chat, coder, terminal panes).
- `apps/server/` existing Fastify backend (the daemon). - `apps/server/` — Fastify backend for BooChat (inference, read-only tools, :9500 in Docker).
- **`apps/chat/`** — BooChat surface (read-only inference loop, current `9500`, the live thing at `code.indifferentketchup.com`). - **`apps/coder/`** — BooCoder (write tools + external-CLI dispatch, port `9502`, `coder.indifferentketchup.com`, **shipped v2.0**, host systemd since v2.1.0).
- **`apps/coder/`** — BooCoder surface (write-tool inference loop + external-CLI dispatch, port `9502`, `coder.indifferentketchup.com`, planned for v2.0). - **`apps/booterm/`** — BooTerm (PTY/terminal pane, **live since May 2026, port `9501`**). bookworm-slim + node-pty + tmux + xterm.js. Shares Postgres database `boochat`.
- **`apps/booterm/`** — BooTerm surface (PTY/terminal pane, **live since May 2026, port `9501`**). Node 20 Alpine + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (image includes `openssh-client` + `gosu`). `/api/term/health` shares the existing `boocode_db`. Built as part of Batch 10. Confirmed working as of 2026-05-19. - All three surfaces share the same Postgres, project registry, and task infrastructure.
- All three share the server package, the auth gate, the project registry, the task table, and the worktree manager. 1. **Single shared database `boochat`.** Docker service name `boocode_db`. Three apps, one Postgres. Cross-surface joins are valuable: a BooCoder task can reference the BooChat conversation that originated it; a BooTerm session can be linked to the BooCoder task it's debugging.
1. **Single shared database.** Rename current `boocode_db``boochat_db` when BooCoder lands. Three apps, one Postgres. Cross-surface joins are valuable: a BooCoder task can reference the BooChat conversation that originated it; a BooTerm session can be linked to the BooCoder task it's debugging. Separate databases would break this.
1. **Mount strategy: blanket `/opt:rw`, permission gating at the write-tool layer.** Container gets full RW access to `/opt`; the BooCoder write tools (`edit_file`, `create_file`, `delete_file`) enforce path scoping using the v1.15 permission wildcard ruleset (`apps/coder/services/path_guard.ts`). Per-project scoping is *policy*, not *mount*. Simpler, single mount, no Docker reconfig per project. Trade-off: a bug in path-guard logic is the only thing between BooCoder and writing outside `/opt/<project>/`. **Path-guard correctness is therefore the highest-priority test target for v2.0** — fuzz it, property-test it, run it through every traversal-attack pattern. 1. **Mount strategy: blanket `/opt:rw`, permission gating at the write-tool layer.** Container gets full RW access to `/opt`; the BooCoder write tools (`edit_file`, `create_file`, `delete_file`) enforce path scoping using the v1.15 permission wildcard ruleset (`apps/coder/services/path_guard.ts`). Per-project scoping is *policy*, not *mount*. Simpler, single mount, no Docker reconfig per project. Trade-off: a bug in path-guard logic is the only thing between BooCoder and writing outside `/opt/<project>/`. **Path-guard correctness is therefore the highest-priority test target for v2.0** — fuzz it, property-test it, run it through every traversal-attack pattern.
1. **External CLI agents (`opencode`, `claude`, `goose`, `pi`) live on the host, NOT in the BooCoder container.** Sam's call: control. Host-installed agents inherit Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs without re-mounting. Tool versions update via Sam's normal `npm i -g` or `brew upgrade` flow. **BooCoder shells out via local-exec PTY** (`node-pty` with `cwd = /opt/<project>` and the host shell), or via SSH if Sam wants stricter isolation later. Container can be added back if a specific reason emerges (sandboxing a rogue agent, ABI mismatch, dependency conflict) but not pre-emptively. 1. **External CLI agents (`opencode`, `claude`, `goose`, `pi`) live on the host.** BooCoder runs as `boocoder.service` on the host (v2.1.0+) and spawns agents directly via `install_path` — no SSH tunnel. Host install inherits Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs.
### Three-surface execution model ### Three-surface execution model
@@ -35,8 +34,8 @@ Each surface has its own primary execution mode but shares the same underlying t
|Surface |Port |Execution mode |Tools |Write access | |Surface |Port |Execution mode |Tools |Write access |
|----------------------------|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------| |----------------------------|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|**BooChat** (`apps/chat`) |9500 |In-process inference loop |`view_file`, `list_dir`, `grep`, `find_files`, codecontext sidecar tools |None — `/opt` is read-only at the tool layer regardless of mount | |**BooChat** (`apps/server` + `apps/web`)|9500 |In-process inference loop |`view_file`, `list_dir`, `grep`, `find_files`, codecontext sidecar tools |None — `/opt` is read-only at the tool layer regardless of mount |
|**BooCoder** (`apps/coder`) |9502 |**Two paths, same surface:** (a) in-process inference loop with native write tools + pending-changes queue, (b) PTY-dispatched external CLI (opencode/claude/goose/pi) in a per-task git worktree|All BooChat tools + `edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind` + `dispatch_external_agent`|Yes, gated through `pending_changes` table (nothing touches disk until `/apply`)| |**BooCoder** (`apps/coder`) |9502 |**Two paths, same surface:** (a) in-process inference loop with native write tools + pending-changes queue, (b) ACP/PTY-dispatched external CLI (opencode/claude/goose/qwen) in a per-task git worktree|All BooChat tools + `edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind` + external dispatch via `tasks`|Yes, gated through `pending_changes` table (nothing touches disk until `/apply`)|
|**BooTerm** (`apps/booterm`)|**9501 (live)**|PTY to host shell via tmux, scoped to project cwd |Shell + SSH-out, no inference loop |Yes (it's a real terminal) | |**BooTerm** (`apps/booterm`)|**9501 (live)**|PTY to host shell via tmux, scoped to project cwd |Shell + SSH-out, no inference loop |Yes (it's a real terminal) |
**The "two paths, same surface" decision in BooCoder is the answer to last turn's "1 and 2 full featured" question.** The in-process loop (Option B / Answer B) handles interactive write work where Sam wants the pending-changes UI and native tool gating. The PTY dispatch (Option A / Answer A) handles parallel/dispatched/batch work where Sam wants to A/B different CLI agents against the same task in separate worktrees. The user picks per task via a `dispatch_external_agent(agent: 'opencode'|'claude'|'goose'|'pi', model: string, task: string, worktree: string)` tool the in-process loop can call, or via a UI dropdown at task creation. **The "two paths, same surface" decision in BooCoder is the answer to last turn's "1 and 2 full featured" question.** The in-process loop (Option B / Answer B) handles interactive write work where Sam wants the pending-changes UI and native tool gating. The PTY dispatch (Option A / Answer A) handles parallel/dispatched/batch work where Sam wants to A/B different CLI agents against the same task in separate worktrees. The user picks per task via a `dispatch_external_agent(agent: 'opencode'|'claude'|'goose'|'pi', model: string, task: string, worktree: string)` tool the in-process loop can call, or via a UI dropdown at task creation.
@@ -95,7 +94,7 @@ Paseo is "one interface for all your Claude Code, Codex, and OpenCode agents." 4
**Core architectural choices, each a target for BooCode to reproduce:** **Core architectural choices, each a target for BooCode to reproduce:**
1. **Daemon + clients split.** A long-running local daemon owns agent process management; thin clients (CLI, desktop Electron, mobile Expo, web) connect over WebSocket. Daemon survives client disconnects. **BooCode equivalent:** the Fastify server is the daemon; the React SPA, the three surface tabs (chat/coder/term), and a new thin `boocode` CLI are all clients. 1. **Daemon + clients split.** A long-running local daemon owns agent process management; thin clients (CLI, desktop Electron, mobile Expo, web) connect over WebSocket. Daemon survives client disconnects. **BooCode equivalent:** the Fastify server is the daemon; the React SPA, the three surface tabs (chat/coder/term), and a new thin `boocode` CLI are all clients.
1. **Six-package monorepo:** `server` (daemon), `app` (Expo iOS/Android/web), `cli`, `desktop` (Electron), `relay` (remote connectivity), `website`. **BooCode equivalent:** `apps/server` (Fastify, exists), `apps/web` (React, exists, hosts the chat/coder/term tabs), `apps/chat` + `apps/coder` + `apps/booterm` (the three surfaces — booterm already live on 9501 as of May 2026), `apps/cli` (new, thin client over WebSocket). `relay` is unnecessary — Sam's Tailscale + Caddy + Authelia stack at `code.indifferentketchup.com` already provides remote connectivity, mobile/desktop are PWA paths, no native shell needed yet. 1. **Six-package monorepo:** `server` (daemon), `app` (Expo iOS/Android/web), `cli`, `desktop` (Electron), `relay` (remote connectivity), `website`. **BooCode equivalent:** `apps/server` (BooChat Fastify backend), `apps/web` (React SPA hosting all pane types), `apps/coder` + `apps/booterm` (BooCoder and BooTerm surfaces). `relay` is unnecessary — Sam's Tailscale + Caddy + Authelia stack already provides remote connectivity; mobile is PWA.
1. **Process orchestration as the daemon's job.** Paseo spawns Claude Code / Codex / OpenCode as **child processes**, not API calls. Each agent runs with full local dev environment access. **BooCoder equivalent:** the dispatch worker (in `apps/server`) spawns `claude` / `opencode` / `goose` / `pi` via local-exec PTY on the **host**, captures stdout/stderr/exit-code into PostgreSQL stream tables, exposes WebSocket events to all three React surfaces. 1. **Process orchestration as the daemon's job.** Paseo spawns Claude Code / Codex / OpenCode as **child processes**, not API calls. Each agent runs with full local dev environment access. **BooCoder equivalent:** the dispatch worker (in `apps/server`) spawns `claude` / `opencode` / `goose` / `pi` via local-exec PTY on the **host**, captures stdout/stderr/exit-code into PostgreSQL stream tables, exposes WebSocket events to all three React surfaces.
1. **CLI shape:** 1. **CLI shape:**
@@ -413,8 +412,8 @@ Don't ship Phase 1 against AGPL/GPL code; build clean. Patterns are free; code i
- **v1.13.7 stability bundle uncovered two latent v1.13.1-A regressions (2026-05-22).** Investigation during the cosmetic-revert session surfaced: (1) `@ai-sdk/openai-compatible` defaults `includeUsage: false`, so `stream_options.include_usage` was never sent to llama-swap and `result.usage.inputTokens/outputTokens` resolved `undefined` — every assistant row had `tokens_used`/`ctx_used` NULL since v1.13.1-A shipped. One-line fix in `provider.ts`. (2) AI SDK v6 streaming occasionally emits a leading `\n` text-delta on tool-call-only turns; `content.length > 0` returned true for `"\n"`, producing an empty MessageBubble + ActionRow between every tool call. Fixed by trim guards in `MessageList.flatten` (`hasText`) and `MessageBubble` (`hasContent`). Plus: `buildMessagesPayload` now skips trailing empty/failed assistant rows (kills "Cannot have 2 or more assistant messages" rejections from the upstream), and `BUDGET_NO_AGENT` bumped 15→30 to match `BUDGET_READ_ONLY` (every tool today is read-only; the 15-cap was forward-looking). The class of bug is consistent: AI SDK v6 changes the streaming surface in ways that aren't caught by tsc or vitest — only production observability surfaces them. Argues for v1.13.11 WS-frame Zod schemas to catch the next round. - **v1.13.7 stability bundle uncovered two latent v1.13.1-A regressions (2026-05-22).** Investigation during the cosmetic-revert session surfaced: (1) `@ai-sdk/openai-compatible` defaults `includeUsage: false`, so `stream_options.include_usage` was never sent to llama-swap and `result.usage.inputTokens/outputTokens` resolved `undefined` — every assistant row had `tokens_used`/`ctx_used` NULL since v1.13.1-A shipped. One-line fix in `provider.ts`. (2) AI SDK v6 streaming occasionally emits a leading `\n` text-delta on tool-call-only turns; `content.length > 0` returned true for `"\n"`, producing an empty MessageBubble + ActionRow between every tool call. Fixed by trim guards in `MessageList.flatten` (`hasText`) and `MessageBubble` (`hasContent`). Plus: `buildMessagesPayload` now skips trailing empty/failed assistant rows (kills "Cannot have 2 or more assistant messages" rejections from the upstream), and `BUDGET_NO_AGENT` bumped 15→30 to match `BUDGET_READ_ONLY` (every tool today is read-only; the 15-cap was forward-looking). The class of bug is consistent: AI SDK v6 changes the streaming surface in ways that aren't caught by tsc or vitest — only production observability surfaces them. Argues for v1.13.11 WS-frame Zod schemas to catch the next round.
- **MCP and ACP roles locked per surface (2026-05-22).** **BooChat = MCP client only**, read-only tool consumer. **BooCoder = MCP client + MCP server + ACP client (host) + ACP agent (driveable)** — full matrix. Hard rule: BooChat MCP config must never enable a write-capable server (the read-only invariant overrides protocol convenience). BooCoder's ACP client role **replaces the raw-PTY dispatch plan for any agent that supports ACP** (opencode `opencode acp`, goose `goose acp`); claude/pi/smallcode stay on PTY fallback. The protocol pattern that justifies the full BooCoder matrix: ACP clients auto-forward their MCP `context_servers` to the dispatched agent (per goose docs) — one MCP config surface drives every dispatched agent. BooCoder MCP-server role exposes `boocoder.create_task`, `boocoder.list_pending_changes`, `boocoder.apply`, etc. so external opencode-in-Termius sessions become BooCoder-aware without going through BooCoder's UI. BooCoder ACP-agent role (`boocoder acp`) lets Zed/JetBrains/Avante.nvim drive BooCoder as their agent — outbound exposure, lowest priority of the four roles. **Reference materials**: anthropics `mcp-builder` skill (4-phase build workflow + 10-question eval framework), opencode MCP/ACP docs as JSON-schema reference, goose ACP docs for the `context_servers` auto-forward pattern, `agentclientprotocol.com` spec — but note remote ACP (HTTP/WS) is still WIP, BooCoder's ACP client must use stdio for v1. - **MCP and ACP roles locked per surface (2026-05-22).** **BooChat = MCP client only**, read-only tool consumer. **BooCoder = MCP client + MCP server + ACP client (host) + ACP agent (driveable)** — full matrix. Hard rule: BooChat MCP config must never enable a write-capable server (the read-only invariant overrides protocol convenience). BooCoder's ACP client role **replaces the raw-PTY dispatch plan for any agent that supports ACP** (opencode `opencode acp`, goose `goose acp`); claude/pi/smallcode stay on PTY fallback. The protocol pattern that justifies the full BooCoder matrix: ACP clients auto-forward their MCP `context_servers` to the dispatched agent (per goose docs) — one MCP config surface drives every dispatched agent. BooCoder MCP-server role exposes `boocoder.create_task`, `boocoder.list_pending_changes`, `boocoder.apply`, etc. so external opencode-in-Termius sessions become BooCoder-aware without going through BooCoder's UI. BooCoder ACP-agent role (`boocoder acp`) lets Zed/JetBrains/Avante.nvim drive BooCoder as their agent — outbound exposure, lowest priority of the four roles. **Reference materials**: anthropics `mcp-builder` skill (4-phase build workflow + 10-question eval framework), opencode MCP/ACP docs as JSON-schema reference, goose ACP docs for the `context_servers` auto-forward pattern, `agentclientprotocol.com` spec — but note remote ACP (HTTP/WS) is still WIP, BooCoder's ACP client must use stdio for v1.
- **BooCode monorepo locked as 3-app structure (2026-05-22).** Same `/opt/boocode/` repo: `apps/chat/` (read-only, currently the live thing at 9500), `apps/coder/` (write tools + external CLI dispatch, 9502, v2.0 planned), `apps/booterm/` (PTY terminal, **already live at 9501 since May 2026**, Node 20 Alpine + node-pty + tmux + xterm.js, tmux session per pane, SSH-out enabled). Shared Fastify backend in `apps/server`, shared React shell in `apps/web` hosting the three surfaces as tabs. BooTerm already shares `boocode_db` — confirms cross-surface DB sharing pattern works. - **BooCode monorepo locked as 3-surface structure (2026-05-22, updated 2026-05-25).** `/opt/boocode/`: BooChat = `apps/server` + `apps/web` (:9500), BooCoder = `apps/coder` (:9502, shipped v2.0, host systemd v2.1.0), BooTerm = `apps/booterm` (:9501). All share Postgres database `boochat`.
- **Single shared database, rename `boocode_db` → `boochat_db` when BooCoder lands (2026-05-22).** All three surfaces in one Postgres. Enables cross-surface joins (coder task → originating chat conversation → term debugging session). - **Single shared database `boochat` (shipped v2.0).** Docker service `boocode_db`. Enables cross-surface joins (coder task → chat → term session).
- **Mount strategy: blanket `/opt:rw`, policy enforcement at the write-tool layer (2026-05-22).** Per-project scoping is logic, not mount. Path-guard correctness becomes the highest-priority test target for v2.0 — fuzz it, property-test it, every traversal-attack pattern. - **Mount strategy: blanket `/opt:rw`, policy enforcement at the write-tool layer (2026-05-22).** Per-project scoping is logic, not mount. Path-guard correctness becomes the highest-priority test target for v2.0 — fuzz it, property-test it, every traversal-attack pattern.
- **External CLI agents (`opencode` / `claude` / `goose` / `pi`) live on the host, not in containers (2026-05-22).** BooCoder shells out via local-exec PTY (`node-pty`, host shell). Host install means inherit Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs without re-mounting. Containerize later only if a concrete reason emerges. - **External CLI agents (`opencode` / `claude` / `goose` / `pi`) live on the host, not in containers (2026-05-22).** BooCoder shells out via local-exec PTY (`node-pty`, host shell). Host install means inherit Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs without re-mounting. Containerize later only if a concrete reason emerges.
- **STRATEGIC PIVOT (2026-05-22): Build a Paseo-equivalent dispatcher inside BooCode. Lift patterns, not code.** Sam wants BooCode to function like Paseo without using Paseo itself. **Paseo (getpaseo/paseo) is AGPL-3.0** — incompatible with BooCode's MIT license and its network-served deployment at `code.indifferentketchup.com`. Vendoring Paseo code would force BooCode to become AGPL. Solution: **reproduce the architecture in BooCode's existing Fastify + TS + PostgreSQL + React stack, using only license-clean patterns**. Full target architecture documented in the new "Paseo-equivalent dispatcher inside BooCode" section at the top of this document. **Primary architectural template: `Dominic789654/agent-hub` (#48)** — Apache-2.0, license-clean, captures the exact three-process model (board server + dispatcher + assistant terminal) and the schema (tasks/projects/templates/pipelines/human_inbox) BooCode should reproduce. **Critical context-management primitive: Roo Code Boomerang Tasks pattern (#46)** — orchestrator with intentional capability restriction, down-pass/up-pass context discipline, no implicit inheritance. **Observation pattern: Claude Code hooks** (siropkin/budi #51 reference) — register BooCode as the hook receiver to get real-time visibility without wrapping the agent. **Phasing:** Phase 1 single-agent PTY dispatch → Phase 2 PostgreSQL queue + worker → Phase 3 Boomerang `new_task` tool → Phase 4 multi-agent + worktrees + CLI → Phase 5 pipelines + dashboard → Phase 6 handoff/loop/orchestrator skills. **This is now the dominant roadmap direction**, ahead of v1.12.x debugger fixes (queued) and v1.13/v1.14 batch work (deferred until Paseo-equivalent Phases 12 are scoped). - **STRATEGIC PIVOT (2026-05-22): Build a Paseo-equivalent dispatcher inside BooCode. Lift patterns, not code.** Sam wants BooCode to function like Paseo without using Paseo itself. **Paseo (getpaseo/paseo) is AGPL-3.0** — incompatible with BooCode's MIT license and its network-served deployment at `code.indifferentketchup.com`. Vendoring Paseo code would force BooCode to become AGPL. Solution: **reproduce the architecture in BooCode's existing Fastify + TS + PostgreSQL + React stack, using only license-clean patterns**. Full target architecture documented in the new "Paseo-equivalent dispatcher inside BooCode" section at the top of this document. **Primary architectural template: `Dominic789654/agent-hub` (#48)** — Apache-2.0, license-clean, captures the exact three-process model (board server + dispatcher + assistant terminal) and the schema (tasks/projects/templates/pipelines/human_inbox) BooCode should reproduce. **Critical context-management primitive: Roo Code Boomerang Tasks pattern (#46)** — orchestrator with intentional capability restriction, down-pass/up-pass context discipline, no implicit inheritance. **Observation pattern: Claude Code hooks** (siropkin/budi #51 reference) — register BooCode as the hook receiver to get real-time visibility without wrapping the agent. **Phasing:** Phase 1 single-agent PTY dispatch → Phase 2 PostgreSQL queue + worker → Phase 3 Boomerang `new_task` tool → Phase 4 multi-agent + worktrees + CLI → Phase 5 pipelines + dashboard → Phase 6 handoff/loop/orchestrator skills. **This is now the dominant roadmap direction**, ahead of v1.12.x debugger fixes (queued) and v1.13/v1.14 batch work (deferred until Paseo-equivalent Phases 12 are scoped).

View File

@@ -8,16 +8,16 @@ Last updated: 2026-05-25
BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22): BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22):
- **BooChat** (`apps/chat`, port `9500`, `code.indifferentketchup.com`) — read-only chat with file-inspection tools. The live thing. Pick a project, chat with a local LLM, get streaming responses over WebSocket. DB renamed `boochat_db` at v2.0. - **BooChat** (`apps/server` + `apps/web`, port `9500`, `code.indifferentketchup.com`) — read-only chat with file-inspection tools. Backend in `apps/server`, SPA in `apps/web`. Database `boochat` (renamed from `boocode` at v2.0).
- **BooCoder** (`apps/coder`, port `9502`, `coder.indifferentketchup.com`) — write tools + external-CLI dispatch. **Shipped v2.0.0v2.0.4.** In-process inference loop (with `pending_changes` table) AND ACP-dispatched external agents (opencode/goose) with PTY fallback (claude/pi/smallcode) — same surface, two execution paths. - **BooCoder** (`apps/coder`, port `9502`, `coder.indifferentketchup.com`) — write tools + external-CLI dispatch. **Shipped v2.0.0v2.1.0.** Host systemd service (not Docker since v2.1.0). In-process inference (with `pending_changes` table) AND ACP-dispatched external agents (opencode/goose) with PTY fallback (claude/qwen).
- **BooTerm** (`apps/booterm`, port `9501`) — PTY/tmux/xterm.js. **Live since May 2026.** Node 20 Alpine + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (openssh-client + gosu in the image). `/api/term/health` shares the existing `boochat_db`. - **BooTerm** (`apps/booterm`, port `9501`) — PTY/tmux/xterm.js. **Live since May 2026.** bookworm-slim + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (openssh-client + gosu in the image). Shares Postgres database `boochat`.
Caddy → Authelia → Tailscale → `100.114.205.53` → 9500/9501/9502. Three apps, **one shared Postgres** (`boocode_db` `boochat_db`). Caddy → Authelia → Tailscale → `100.114.205.53` → 9500/9501/9502. Three apps, **one shared Postgres** (Docker service `boocode_db`, database name `boochat`).
**Architectural commitments:** **Architectural commitments:**
- **No embeddings.** Model uses file-view tools (`view_file`, `list_dir`, `grep`, `find_files`) + sidecar analyzers (codecontext, future codesight) + codecontext MCP tools. Walked away from the RAG pipeline May 2026. - **No embeddings.** Model uses file-view tools (`view_file`, `list_dir`, `grep`, `find_files`) + sidecar analyzers (codecontext, future codesight) + codecontext MCP tools. Walked away from the RAG pipeline May 2026.
- **BooChat is read-only** through v1.x. Write tools land in BooCoder at v2.0. - **BooChat is read-only.** Write tools live in BooCoder (shipped v2.0).
- **Mount strategy: blanket `/opt:rw`, permission gating at the write-tool layer.** Per-project scoping is policy, not mount. Path-guard correctness is the #1 test target for v2.0. - **Mount strategy: blanket `/opt:rw`, permission gating at the write-tool layer.** Per-project scoping is policy, not mount. Path-guard correctness is the #1 test target for v2.0.
- **External CLI agents (`opencode`/`claude`/`goose`/`pi`) live on the host, not in containers.** BooCoder shells out via local-exec PTY or ACP subprocess. Host install inherits Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs. - **External CLI agents (`opencode`/`claude`/`goose`/`pi`) live on the host, not in containers.** BooCoder shells out via local-exec PTY or ACP subprocess. Host install inherits Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs.
- **Protocol roles locked (2026-05-22):** **BooChat = MCP client only** (read-only tool consumer, never enables write-capable MCP servers). **BooCoder = MCP client + MCP server + ACP client (host) + ACP agent (driveable)** — full matrix. BooCoder's ACP-client role replaces raw-PTY dispatch for ACP-capable agents (opencode `opencode acp`, goose `goose acp`); PTY fallback retained for claude/pi/smallcode. - **Protocol roles locked (2026-05-22):** **BooChat = MCP client only** (read-only tool consumer, never enables write-capable MCP servers). **BooCoder = MCP client + MCP server + ACP client (host) + ACP agent (driveable)** — full matrix. BooCoder's ACP-client role replaces raw-PTY dispatch for ACP-capable agents (opencode `opencode acp`, goose `goose acp`); PTY fallback retained for claude/pi/smallcode.
@@ -178,7 +178,7 @@ Inspired by Thariq Shihipar's "HTML > Markdown at length" pattern (`claude.com/b
- Add HTML-on-request rule to global `AGENTS.md`: "Stay in Markdown by default for all outputs, short or long. Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. 'render this as HTML', 'make a dashboard', 'build a diagram')." - Add HTML-on-request rule to global `AGENTS.md`: "Stay in Markdown by default for all outputs, short or long. Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. 'render this as HTML', 'make a dashboard', 'build a diagram')."
- Inline the `web-artifacts-builder` "avoid AI slop" design principles for when HTML is requested: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics. - Inline the `web-artifacts-builder` "avoid AI slop" design principles for when HTML is requested: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics.
- Cite Thariq's blog post in the rule comment so future audit passes know where the design conventions came from. - Cite Thariq's blog post in the rule comment so future audit passes know where the design conventions came from.
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available. Detection is opportunistic — when the model produces HTML (because the user asked), the tag fires; otherwise the message stays plain-Markdown and no `html_artifact` part is written. 1. **Detection at the BooChat backend.** In `apps/server/src/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available. Detection is opportunistic — when the model produces HTML (because the user asked), the tag fires; otherwise the message stays plain-Markdown and no `html_artifact` part is written.
1. **Pane-only render surface.** Every assistant message in the chat stream gets an "Open in pane" affordance (icon button in the message footer, alongside the existing copy/regenerate controls). Clicking it opens the message as an artifact pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this). 1. **Pane-only render surface.** Every assistant message in the chat stream gets an "Open in pane" affordance (icon button in the message footer, alongside the existing copy/regenerate controls). Clicking it opens the message as an artifact pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
- **Markdown pane** — renders via the same Markdown component used inline in `MessageBubble` (so syntax highlighting, fenced code blocks, tables, etc. all work). Header carries **Copy** (writes raw Markdown source to clipboard via `navigator.clipboard.writeText`) and **Download** (`.md`) buttons. - **Markdown pane** — renders via the same Markdown component used inline in `MessageBubble` (so syntax highlighting, fenced code blocks, tables, etc. all work). Header carries **Copy** (writes raw Markdown source to clipboard via `navigator.clipboard.writeText`) and **Download** (`.md`) buttons.
- **HTML pane** — renders the artifact in a sandboxed iframe at full pane height. Header carries **Download** (`.html`) only. **No Copy button** — HTML source isn't useful clipboard content; if the user wants the source they can Download and inspect. - **HTML pane** — renders the artifact in a sandboxed iframe at full pane height. Header carries **Download** (`.html`) only. **No Copy button** — HTML source isn't useful clipboard content; if the user wants the source they can Download and inspect.
@@ -261,7 +261,7 @@ Independent batch — ships clean any time after v1.13. Low leverage unless Sam
## v2.0 — BooCoder: pending changes + dual execution paths + ACP host + MCP server ## v2.0 — BooCoder: pending changes + dual execution paths + ACP host + MCP server
**Major version bump.** New app `apps/coder/` inside the existing monorepo (not a separate repo). Lands together with the `boocode_db``boochat_db` DB rename and the per-app subdomain split (`code.indifferentketchup.com` → BooChat, `coder.indifferentketchup.com` → BooCoder). **Major version bump.** New app `apps/coder/` inside the existing monorepo (not a separate repo). Shipped with database rename `boocode``boochat` and subdomain split (`code.indifferentketchup.com` → BooChat, `coder.indifferentketchup.com` → BooCoder).
**Shipped v2.0.0v2.0.4.** All 8 phases complete. See retrospective below. **Shipped v2.0.0v2.0.4.** All 8 phases complete. See retrospective below.
@@ -391,8 +391,8 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|`boochat` (was `boocode`) |`100.114.205.53:9500`|`/opt:/opt:ro` |Read-only chat + SPA host + MCP client |Live (renames at v2.0)| |`boochat` (was `boocode`) |`100.114.205.53:9500`|`/opt:/opt:ro` |Read-only chat + SPA host + MCP client |Live (renames at v2.0)|
|`booterm` |`100.114.205.53:9501`|`/opt:/opt` |PTY/tmux terminal sessions |**Live (May 2026)** | |`booterm` |`100.114.205.53:9501`|`/opt:/opt` |PTY/tmux terminal sessions |**Live (May 2026)** |
|`boocoder` |`100.114.205.53:9502`|`/opt:/opt:rw` (policy-gated)|Write tools + ACP host + MCP client + MCP server + external-CLI dispatch|**Shipped v2.0.0v2.0.4** | |`boocoder` |`100.114.205.53:9502`|`/opt:/opt:rw` (policy-gated)|Write tools + ACP host + MCP client + MCP server + external-CLI dispatch|**Shipped v2.0.0v2.0.4** |
|`boochat_db` (was `boocode_db`)|`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine (shared by all three) |**Live** (renamed at v2.0)| |**`boochat`** (Docker service `boocode_db`)|`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine (shared by all three) |**Live** (DB renamed from `boocode` at v2.0)|
|`codecontext` |`:8765` (internal) |`/opt/projects:/workspace:ro`|MCP server for architect tools |**Live (v1.12.0)** | |`codecontext` |`:8080` (internal, Docker network) |`/opt:/opt:ro`|Go HTTP sidecar for code graph tools |**Live (v1.12.0)** |
### Caddy routing target (post-v2.0) ### Caddy routing target (post-v2.0)
@@ -435,7 +435,7 @@ term.indifferentketchup.com → booterm :9501 (or routed under code.
- **v1.14.x-html:** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value - **v1.14.x-html:** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value
- **v1.15:** `permissions` table, `agent_permissions` join, `session_permissions` join, `mcp_servers (name, type, transport, url_or_command, enabled, config_hash, last_probed_at)` registry - **v1.15:** `permissions` table, `agent_permissions` join, `session_permissions` join, `mcp_servers (name, type, transport, url_or_command, enabled, config_hash, last_probed_at)` registry
- **v1.16:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)` - **v1.16:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
- **v2.0:** `pending_changes (id, session_id, file_path, diff TEXT, status, created_at)`; `tasks`, `task_templates`, `pipelines`, `pipeline_runs`; `available_agents (name, install_path, version, supports_acp, supports_mcp_client, last_probed_at)`; `human_inbox` view; DB rename `boocode_db``boochat_db` - **v2.0 (shipped):** `pending_changes`, `tasks`, `available_agents`, `human_inbox` view; database renamed `boocode``boochat`
- **v2.2:** none (`boocoder acp` is a new entry point, not a schema change) - **v2.2:** none (`boocoder acp` is a new entry point, not a schema change)
----- -----
@@ -517,8 +517,8 @@ Full inventory and rationale in `boocode_code_review.md`. Headline items below;
### Monorepo / multi-app structure (2026-05-22, locked) ### Monorepo / multi-app structure (2026-05-22, locked)
- **BooCode is a 3-app monorepo** at `/opt/boocode/`: `apps/chat` (read-only, currently the live thing at 9500), `apps/coder` (write tools + external CLI dispatch, 9502, v2.0 planned), `apps/booterm` (PTY terminal, **live since May 2026 at 9501**). Shared `apps/server` (Fastify backend) and `apps/web` (React shell hosting the three surfaces as tabs). - **BooCode is a 3-surface monorepo** at `/opt/boocode/`: BooChat (`apps/server` + `apps/web`, :9500), BooCoder (`apps/coder`, :9502, **shipped v2.0v2.1.0**, host systemd), BooTerm (`apps/booterm`, :9501, live since May 2026). One React SPA hosts chat, coder, and terminal panes.
- **Single shared database, rename `boocode_db` `boochat_db` when BooCoder lands.** All three surfaces in one Postgres. Cross-surface joins are valuable (coder task → originating chat → term debugging session). Separate databases would break this. - **Single shared database `boochat`.** Docker service `boocode_db`, all three surfaces connect to the same Postgres. Cross-surface joins are valuable (coder task → originating chat → term debugging session).
- **Mount strategy: blanket `/opt:rw`, policy enforcement at the write-tool layer.** Per-project scoping is logic, not mount. Path-guard correctness becomes the highest-priority test target for v2.0 — fuzz it, property-test it, every traversal-attack pattern (including MCP-served filesystem writes). - **Mount strategy: blanket `/opt:rw`, policy enforcement at the write-tool layer.** Per-project scoping is logic, not mount. Path-guard correctness becomes the highest-priority test target for v2.0 — fuzz it, property-test it, every traversal-attack pattern (including MCP-served filesystem writes).
- **External CLI agents on the host, not in containers.** BooCoder shells out via local-exec PTY or ACP subprocess (`node-pty`, host shell, or `child_process.spawn('opencode', ['acp'])`). Host install inherits Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs without re-mounting. Containerize later only if a concrete reason emerges. - **External CLI agents on the host, not in containers.** BooCoder shells out via local-exec PTY or ACP subprocess (`node-pty`, host shell, or `child_process.spawn('opencode', ['acp'])`). Host install inherits Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs without re-mounting. Containerize later only if a concrete reason emerges.

View File

@@ -12,10 +12,10 @@ Audit guidance files in a BooCode project against a 10-dimension rubric, then pr
Find every guidance file in the project. The expected set: Find every guidance file in the project. The expected set:
- `CLAUDE.md` (repo root) — engineering conventions, gotchas, commands - `CLAUDE.md` (repo root) — engineering conventions, gotchas, commands
- `AGENTS.md` (repo root) — **agent navigation** (doc map, task routing, openspec usage). Not the agent registry.
- `BOOCHAT.md` (repo root) — container guidance for the read-only chat surface - `BOOCHAT.md` (repo root) — container guidance for the read-only chat surface
- `BOOCODER.md` (repo root) — container guidance for the future write-capable surface (currently a stub) - `BOOCODER.md` (repo root) — container guidance for the write-capable surface
- `data/AGENTS.md` — single-file tier-2 agent registry, `## H2` per agent - `data/AGENTS.md` — single-file tier-2 **agent registry**, `## H2` per agent
- `AGENTS.md` (repo root) — non-BooCode convention; rare in this repo
Glob with `find_files` then load each with `view_file`: Glob with `find_files` then load each with `view_file`:

122
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,122 @@
# BooCode architecture
Last updated: 2026-05-25. **Navigation:** `AGENTS.md`. **Deep reference:** `CLAUDE.md`.
## System overview
```mermaid
flowchart TB
subgraph client [Browser]
SPA["apps/web React SPA"]
end
subgraph edge [Edge]
Caddy --> Authelia
end
subgraph host ["Host 100.114.205.53"]
subgraph docker [Docker boocode_net]
BooChat["boocode container<br/>apps/server + built web<br/>:9500"]
BooTerm["booterm container<br/>apps/booterm<br/>:9501"]
PG[("boocode_db<br/>Postgres 16<br/>database: boochat<br/>host :5500")]
CC["codecontext sidecar<br/>:8080 internal"]
end
BooCoder["boocoder.service<br/>apps/coder<br/>:9502"]
Agents["Host CLI agents<br/>opencode goose claude qwen"]
LLM["llama-swap<br/>100.101.41.16:8401"]
end
Authelia --> SPA
SPA -->|"HTTP /api WS /api/ws"| BooChat
SPA -->|"WS /ws/term"| BooTerm
SPA -->|"HTTP /api/coder proxy<br/>WS direct"| BooCoder
BooChat --> PG
BooTerm --> PG
BooCoder --> PG
BooChat -->|"HTTP tools"| CC
BooChat -->|"streamText"| LLM
BooCoder -->|"native inference"| LLM
BooCoder -->|"ACP or PTY spawn"| Agents
Agents --> LLM
```
## Three surfaces, one database
| Surface | Code | Runtime | Primary role |
|---------|------|---------|--------------|
| BooChat | `apps/server` + `apps/web` | Docker | Read-only chat, file tools, MCP client, skills |
| BooTerm | `apps/booterm` + terminal panes in `apps/web` | Docker | tmux + xterm.js PTY panes |
| BooCoder | `apps/coder` + `CoderPane` in `apps/web` | Host systemd | Write tools, task queue, ACP/PTY agent dispatch |
All surfaces share Postgres (`boochat` DB). Cross-surface joins link chats, tasks, and sessions.
## BooChat request path
```mermaid
sequenceDiagram
participant U as User
participant W as apps/web
participant S as apps/server
participant DB as Postgres
participant L as llama-swap
U->>W: POST message
W->>S: /api/sessions/:id/messages
S->>DB: user + assistant streaming rows
S->>S: inference.enqueue()
loop outer step loop
S->>L: streamText
L-->>S: deltas / tool calls
S->>W: WS frames via broker
opt tool calls
S->>S: executeToolPhase
S->>DB: message_parts
end
end
S->>DB: finalize message
S->>W: session_updated user frame
```
Key modules: `services/inference/turn.ts` (outer loop), `stream-phase.ts` (AI SDK adapter), `tool-phase.ts`, `services/broker.ts`, `hooks/useSessionStream.ts`.
## BooCoder execution paths
```mermaid
flowchart LR
Msg["User message<br/>CoderPane"] --> Route{"provider?"}
Route -->|boocode| Inf["In-process inference<br/>pending_changes queue"]
Route -->|external| Task["tasks row<br/>dispatcher poll"]
Task --> ACP["ACP dispatch<br/>opencode goose"]
Task --> PTY["PTY dispatch<br/>claude qwen"]
ACP --> Host["spawn install_path<br/>on host"]
PTY --> Host
Inf --> Apply["apply_pending → disk"]
```
Since v2.1.0, BooCoder runs on the host (not Docker). Agent binaries spawn directly — no SSH tunnel.
## Supporting services
| Service | Reachability | Purpose |
|---------|--------------|---------|
| codecontext | `http://codecontext:8080` from Docker network | Code graph / symbol analysis (Go sidecar) |
| llama-swap | `LLAMA_SWAP_URL` env | Local LLM inference + model props |
| SearXNG | `SEARXNG_URL` (Tailscale Fathom) | `web_search` / `web_fetch` when enabled |
| MCP servers | `/data/mcp.json` config | Optional tools (e.g. Context7), read-only in BooChat |
## Config and data files
| Path | Role |
|------|------|
| `data/AGENTS.md` | Global agent registry (bind-mounted `/data/AGENTS.md`) |
| `data/mcp.json` | MCP server config (opencode-compatible shape) |
| `data/skills/` | On-demand skill library |
| `BOOCHAT.md` / `BOOCODER.md` | Container guidance (mtime-cached into system prompt) |
| `apps/server/src/schema.sql` | Canonical DB schema |
## Deploy topology
- **BooChat + BooTerm + Postgres + codecontext:** `docker compose up --build -d` from `/opt/boocode`
- **BooCoder:** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
- **Ports bind to Tailscale IP** `100.114.205.53`, not `0.0.0.0` — use that IP for host smoke curls

312
docs/DEFERRED-WORK.md Normal file
View File

@@ -0,0 +1,312 @@
# Deferred work — post stale cleanup (2026-05-26)
This document describes work intentionally **not** shipped in the 2026-05-26 stale/simplify batch. Each item needs a product or architecture decision before implementation. See also [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) for what was resolved in that batch.
Last updated: 2026-05-26
---
## Summary
| Item | Category | User impact | Effort | Risk if left alone |
|------|----------|-------------|--------|-------------------|
| Task cancel → abort ACP/PTY child | Correctness / UX | High — Stop does not kill external agents | Medium | Zombie processes, stuck `running` tasks, orphaned worktrees |
| ~~Skip ACP cold probe when DB fresh~~ | ~~Performance~~ | ~~Medium~~ | ~~Medium~~ | **RESOLVED — v2.3 provider lifecycle** |
| Unified `packages/types` | Maintainability | Low (dev-only) | MediumHigh | Type drift between server, coder, web |
| Large file splits | Maintainability | None directly | Medium per file | Harder reviews, merge conflicts |
| Retire `apps/coder/web/` fallback SPA | Scope / ops | Low — Sam uses CoderPane | Medium | Dual UI maintenance, divergent API client |
---
## 1. Task cancel → abort external ACP/PTY child
### Current behavior
External agent tasks (opencode, cursor, claude, qwen, goose, etc.) flow through `apps/coder/src/services/dispatcher.ts` path B:
1. Create git worktree (`worktrees.ts` → local `hostExec`)
2. Spawn ACP or PTY child with an **`AbortController`** (`ac`) passed as `signal`
3. Stream assistant output over session WS
4. Diff worktree → `pending_changes`
5. Cleanup worktree
The abort signal is **created per task** but **never registered anywhere cancel can reach**:
```typescript
// dispatcher.ts — ac is local to dispatchExternal(); no Map<taskId, AbortController>
const ac = new AbortController();
// ...
await dispatchViaAcp({ ..., signal: ac.signal });
// or
await dispatchViaPty({ ..., signal: ac.signal });
```
Cancel paths today:
| Route | What it does | Gap |
|-------|--------------|-----|
| `POST /api/tasks/:id/cancel` | Sets DB `state = 'cancelled'`, calls `cancelPendingPermission`, tries `inference.cancel()` on session chats | **`inference.cancel` only affects native boocode streaming** — not ACP/PTY children |
| `POST /api/sessions/:sessionId/stop` | Same — loops open chats, calls `inference.cancel` | Same gap for external tasks tied to that session |
| Dispatcher `stop()` on shutdown | Sets `stopping = true`; in-flight external task may finish or mark cancelled at end | No immediate child kill |
`dispatchViaAcp` and `dispatchViaPty` **do** honor `signal` when aborted (worktree ops, stream read, spawn teardown). The wiring from HTTP cancel → `ac.abort()` is missing.
There is also **no frontend** calling task cancel today (`grep` across `apps/web` finds no `cancelTask` usage). Stop in CoderPane targets native inference only.
### Symptoms users would see
- Click Stop / cancel task → DB says `cancelled` but agent process keeps running on host
- Assistant row may stay `streaming` until the child exits naturally
- Worktree under `/tmp/booworktrees/<taskId>` may linger until dispatcher cleanup or manual removal
- Permission prompts cancel correctly (`cancelPendingPermission`) — that part works
### Proposed implementation
**Phase A — backend registry (minimum viable)**
1. Add `Map<taskId, AbortController>` (or `{ ac, childPid? }`) in dispatcher module scope
2. On `dispatchExternal` start: `activeAbort.set(taskId, ac)`
3. On task completion/failure/cleanup: `activeAbort.delete(taskId)`
4. Export `cancelExternalTask(taskId: string): boolean` from dispatcher
5. In `POST /api/tasks/:id/cancel`:
- Call `cancelExternalTask(taskId)` **before** or **instead of** `inference.cancel` when task is external (`execution_path` or agent !== boocode)
- Mark assistant message `cancelled` / `failed` and publish `message_complete` + `chat_status: idle` on session WS
6. Wire `POST /api/sessions/:sessionId/stop` to find **running external tasks** for that session and abort them too
**Phase B — child process kill**
- PTY: store `child` ref from `spawn`; on abort, `child.kill('SIGTERM')` then SIGKILL after timeout
- ACP: `acp-dispatch` already uses signal; ensure `createAcpNdJsonStream` cancel kills the underlying spawn (verify in `acp-stream.ts`)
- Worktree: on abort mid-flight, call `cleanupWorktree` in `finally` block
**Phase C — frontend**
- CoderPane Stop button: if `provider !== 'boocode'` and a `task_id` is active, call `POST /api/coder/tasks/:id/cancel` (needs API client method)
- Show cancelled state in UI (task row or composer status)
### Files likely touched
- `apps/coder/src/services/dispatcher.ts` — registry, export cancel
- `apps/coder/src/routes/tasks.ts` — call dispatcher cancel
- `apps/coder/src/routes/messages.ts` — session stop for external tasks
- `apps/coder/src/services/acp-dispatch.ts`, `pty-dispatch.ts` — verify signal → kill
- `apps/web/src/api/client.ts`, `CoderPane.tsx` — Stop wiring
### Acceptance criteria
- Cancel a running opencode/cursor task → child process gone within 5s (`ps` / `pgrep` on host)
- Task row `state = 'cancelled'`, assistant message not left `streaming`
- Worktree directory removed (best-effort)
- Native boocode cancel unchanged
- No regression on permission-denied / blocked flows
### Open questions
- Should cancel be **best-effort** (mark cancelled even if kill fails) or **fail closed** until process dies?
- Arena parallel tasks: cancel one contestant vs whole arena?
- Blocked task waiting on permission: cancel already resolves waiter — confirm ACP session teardown order
---
## 2. ~~Skip ACP cold probe when DB models are fresh~~ **RESOLVED — v2.3 provider lifecycle**
Addressed in [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/design.md). The v2.3 snapshot module (`provider-snapshot.ts`) uses DB `available_agents` models as the warm path and only cold-probes on explicit `POST /api/providers/refresh`. Opening the provider picker no longer triggers any probe. `PROVIDER_PROBE_TTL_MS` env var (default 24h) controls stale-model self-heal.
---
## 3. Unified `packages/types` for provider snapshot JSON
### Current behavior
Provider snapshot shapes are **duplicated** (not byte-identical exports):
| Location | Types |
|----------|-------|
| `apps/coder/src/services/provider-types.ts` | `ProviderSnapshotEntry`, `AgentCommand`, `ProviderModel`, … |
| `apps/web/src/api/types.ts` | Same names, hand-maintained |
| WS frames | `agent_commands` frame in `ws-frames.ts` (server + web copy) |
Today drift is caught by:
- TypeScript at compile time when fields are added to one side only
- `provider-snapshot.test.ts` on coder
- Manual review during v2.2 batch
There is **no** `packages/` workspace yet (monorepo is `apps/server`, `apps/web`, `apps/coder`, `apps/booterm` only).
### Options
**A. Zod schema + inferred types (lighter)**
- Add `ProviderSnapshotEntrySchema` in server or coder
- Web imports schema for runtime validation on fetch (optional)
- Parity test: `expect(webTypeKeys).toEqual(zodShapeKeys)` — similar to existing `ws-frames.test.ts`
**B. Shared `packages/types` package**
```
packages/types/
package.json # @boocode/types
src/provider.ts # interfaces + zod
src/ws-frames.ts # move duplicated unions here
```
- `apps/coder`, `apps/web`, `apps/server` depend on `workspace:*`
- Requires NodeNext export map, build order (`tsc` for types package first)
- Biggest payoff if WS frames + provider + task types all move
**C. Status quo + discipline**
- Keep duplicated TS interfaces
- Add one test file importing both sides and asserting structural equality
### Tradeoffs
| Approach | Setup cost | Runtime safety | Best when |
|----------|------------|----------------|-----------|
| A — Zod in coder | Low | Validate API responses | Snapshot is coder-only API |
| B — packages/types | High | Full shared source | Many cross-app types growing |
| C — parity test | Lowest | Compile-time only | Rare schema changes |
### Recommendation
Start with **A or C** unless planning a broader “shared types” initiative (tasks, permissions, arena). Full `packages/types` is justified when a third consumer appears or WS frame duplication becomes painful again.
### Files likely touched (option B)
- New `packages/types/`
- Root `pnpm-workspace.yaml`
- `apps/web/tsconfig`, `apps/coder/tsconfig` — path references
- Strip duplicate blocks from `apps/web/src/api/types.ts`
---
## 4. Large file splits (refactor when touched)
Not user-facing defects — deferred to avoid scope creep in the stale batch. Split when the next feature touches the file heavily.
### 4.1 `CoderPane.tsx` (~663 lines)
**Responsibilities today (single component):**
- Session WS + polling fallback for messages
- Pending changes fetch/apply/discard
- External vs native send paths (`AgentComposerBar`, slash commands, skill invoke)
- Permission card + agent commands hint
- Provider snapshotdriven composer state
**Suggested extractions:**
| Hook / module | Owns |
|---------------|------|
| `useCoderMessages(sessionId)` | WS subscribe, poll-when-disconnected, temp-id dedup |
| `usePendingChanges(sessionId)` | List, apply, discard, diff preview trigger |
| `CoderComposerFooter` | AgentComposerBar + hints + permission card layout |
**Trigger to do it:** next CoderPane feature (e.g. task cancel UI, multi-task inbox) or when file exceeds ~800 lines.
### 4.2 `ChatInput.tsx` (~669 lines)
**Responsibilities:** attachments, drag/drop, paste-as-file, `@` mentions, slash picker, agent picker row, context bar, send/submit, registry for send-to-chat.
**Suggested extractions:**
| Hook / module | Owns |
|---------------|------|
| `useSlashCommandInput(...)` | `slashState`, `isSlashCommandToken`, picker open/close |
| `useFileMention(...)` | `@` detection, file index fetch, popover state |
| `useAttachments(...)` | chips, drop, paste, MAX_ATTACHMENTS |
Slash helpers already live in `lib/slash-command.ts`; hooks would consume them.
### 4.3 `dispatcher.ts` (~483 lines)
**Two paths in one file:**
- Path A: native boocode — enqueue inference, wait for message completion
- Path B: external — worktree, ACP/PTY, diff, pending_changes
**Suggested split:**
- `dispatcher-native.ts` — poll loop pick-up for `provider = boocode` or no agent
- `dispatcher-external.ts` — path B + abort registry (pairs with item §1)
- `dispatcher.ts` — thin poll loop + routing
### 4.4 `AgentComposerBar.tsx` (~323 lines)
Optional split: **provider/model/mode/thinking pickers** vs **persistence** (`AgentSessionConfig` localStorage/session). Lower priority — file is manageable.
### 4.5 `acp-dispatch.ts` (~294 lines)
Stream setup already extracted to `acp-stream.ts`. Remaining split candidate: **session lifecycle** (create → prompt loop → teardown) vs **permission/tool side effects**.
### General rule
Split only when:
1. A new feature needs a clear seam, or
2. Review feedback repeatedly hits the same file, or
3. Tests need isolated units (e.g. dispatcher external path)
Avoid drive-by splits — they churn blame without shipping user value.
---
## 5. Retire `apps/coder/web/` fallback SPA
### What it is
Standalone Vite React app (`@boocode/coder-web`) built into `apps/coder/web/dist/` and served by BooCoder Fastify at `:9502` when no BooChat proxy is in front.
**Primary UI:** `CoderPane` inside BooChat SPA (`apps/web`) — API via `/api/coder/*` proxy, WS to `:9502`.
**Fallback UI:** direct visit to `http://100.114.205.53:9502` — own `api/client.ts`, `ChatPane`, `DiffPane`, duplicated types.
### Why it was kept
- Host-only debugging without full BooChat stack
- Historical path before CoderPane integration
- Zero dependency on Authelia-protected BooChat origin
### Cost of keeping
- Separate build step in coder deploy
- Duplicate message/stream types and API paths
- Features land in CoderPane first; fallback rots unless manually updated
### Options
| Option | When to choose |
|--------|----------------|
| **Keep** | Still use `:9502` directly for debugging or demos |
| **Freeze** | Stop feature work; build only on release for emergency access |
| **Remove** | Always use BooChat; `:9502` serves health + WS + API only (or minimal static “open in BooChat” page) |
### Removal checklist (if chosen)
1. Confirm Sam never uses standalone UI (bookmarks, systemd docs, BOOCODER.md)
2. Remove `apps/coder/web/` package and static serve from `apps/coder/src/index.ts`
3. Update Dockerfile/coder build scripts
4. Keep WS + REST routes — CoderPane depends on them
5. Optional: single-page static “Use code.indifferentketchup.com” at `/`
---
## Suggested batch ordering
If picking these up as openspec batches:
1. **Task cancel abort** — highest correctness gap; unblocks honest Stop button in CoderPane
2. **ACP probe skip** — quick win for provider picker latency once semantics agreed
3. **CoderPane hook extraction** — natural follow-on when adding cancel UI
4. **Zod parity or packages/types** — when next WS/provider field is added
5. **Retire coder/web** — only after explicit “I dont use :9502 UI” confirmation
---
## Related docs
- [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) — resolved stale items
- [`ARCHITECTURE.md`](./ARCHITECTURE.md) — BooChat / BooCoder surfaces
- [`openspec/changes/v2-2-paseo-providers/design.md`](../openspec/changes/v2-2-paseo-providers/design.md) — provider snapshot API
- [`BOOCODER.md`](../BOOCODER.md) — dispatch, worktrees, pending changes

77
docs/STALE-DEPRECATED.md Normal file
View File

@@ -0,0 +1,77 @@
# Stale / deprecated inventory
Review list for Sam — **do not delete blindly**. Items marked "candidate remove" have no known runtime imports; items marked "deprecated" are still referenced or documented.
Last updated: 2026-05-26 (stale cleanup batch)
---
## Resolved in stale cleanup (2026-05-26)
| Item | Resolution |
|------|------------|
| `ProviderPicker.tsx` | **Removed** — replaced by `AgentComposerBar` |
| `SkillSlashCommand.tsx` | **Removed** — inlined `SlashCommandPicker` in `ChatInput` |
| `Provider` type + `api.coder.providers()` | **Removed** |
| `GET /api/providers` (flat list) | **Removed** — snapshot is canonical |
| `ssh.ts` + worktree SSH | **Removed**`worktrees.ts` uses local `host-exec.ts` |
| `ProviderCommand` vs `AgentCommand` | **Consolidated**`AgentCommand` in `provider-types.ts` |
| `pty-dispatch` `AgentCommand` | **Renamed**`PtySpawnSpec` / `buildPtySpawnSpec` |
| `ProviderSnapshotStatus: 'unavailable'` | **Dropped** — only `'ready'` \| `'error'` |
| Skill invoke duplication | **Extracted**`@boocode/server/skill-invoke` |
| `resolveChatId` duplication | **Extracted**`apps/coder/src/routes/chat-resolve.ts` |
| Qwen settings parse | **Shared**`qwen-settings.ts` with `readFile` |
| ChatInput slash regex | **Shared**`lib/slash-command.ts` |
---
## Frontend (apps/web)
_No open stale items from the 2026-05-26 review._
---
## BooCoder backend (apps/coder)
| Item | Status | Notes |
|------|--------|-------|
| [`apps/coder/web/`](../apps/coder/web/) | **Fallback SPA** | Standalone BooCoder UI at `:9502`. Primary UI is `CoderPane` in BooChat SPA. Keep unless host-only access is dropped. |
---
## Large files (refactor when touched, not delete)
| File | ~Lines | Split suggestion |
|------|--------|------------------|
| [`CoderPane.tsx`](../apps/web/src/components/panes/CoderPane.tsx) | 660+ | Extract `useCoderMessages`, pending changes hook, composer footer |
| [`ChatInput.tsx`](../apps/web/src/components/ChatInput.tsx) | 670+ | Extract slash/mention hooks |
| [`dispatcher.ts`](../apps/coder/src/services/dispatcher.ts) | 480+ | Split native vs external paths |
| [`AgentComposerBar.tsx`](../apps/web/src/components/AgentComposerBar.tsx) | 320+ | Optional: prefs vs picker UI |
| [`acp-dispatch.ts`](../apps/coder/src/services/acp-dispatch.ts) | 300+ | Stream setup now in `acp-stream.ts`; session lifecycle could split further |
---
## Docs / plans superseded by v2.2
| Item | Notes |
|------|-------|
| [`docs/superpowers/plans/2026-05-25-provider-picker-backend.md`](../docs/superpowers/plans/2026-05-25-provider-picker-backend.md) | Pre-v2.2 flat `/api/providers` plan; header marks superseded. |
| Roadmap / CHANGELOG mentions of `ProviderPicker` | Historical truth for v2.1.0 — leave as release record. |
---
## Already cleaned in simplify pass (2026-05-26)
- Extracted [`acp-stream.ts`](../apps/coder/src/services/acp-stream.ts) (shared ACP NDJSON bridge)
- Deduped `mergeCommands` → [`provider-commands.ts`](../apps/coder/src/services/provider-commands.ts)
- Parallel ACP probes + singleflight in [`provider-snapshot.ts`](../apps/coder/src/services/provider-snapshot.ts)
- Removed dead `getPendingSessionId` from [`permission-waiter.ts`](../apps/coder/src/services/permission-waiter.ts)
- CoderPane: WS message dedup, slash helpers, poll only when WS disconnected
---
## Skipped (needs product decision)
- **Task cancel → abort external ACP/PTY child** — `AbortController` in dispatcher not wired to cancel route
- **Skip ACP cold probe when DB models fresh** — perf; changes snapshot semantics
- **Unified `packages/types`** for provider snapshot JSON — Zod parity test may suffice

View File

@@ -1,12 +1,14 @@
# BooCoder Provider Picker — Backend (Steps 13) # BooCoder Provider Picker — Backend (Steps 13)
> **Superseded:** Shipped as `v2.1.0-provider-picker` (2026-05-25). Agent discovery uses direct `exec()` on the host, not SSH. See `CHANGELOG.md` and `apps/coder/src/services/agent-probe.ts` for current implementation.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Expose a `GET /api/providers` endpoint on BooCoder (port 9502) that returns all available providers with their model lists, so the frontend can build a two-level provider → model picker. **Goal:** Expose a `GET /api/providers` endpoint on BooCoder (port 9502) that returns all available providers with their model lists, so the frontend can build a two-level provider → model picker.
**Architecture:** A static provider registry maps agent names to their metadata (transport, model source). The existing `agent-probe.ts` is extended to discover models for each agent and persist them in a new `models` JSONB column on `available_agents`. A new `/api/providers` route merges the registry with DB state and llama-swap models to produce the response. **Architecture:** A static provider registry maps agent names to their metadata (transport, model source). The existing `agent-probe.ts` is extended to discover models for each agent and persist them in a new `models` JSONB column on `available_agents`. A new `/api/providers` route merges the registry with DB state and llama-swap models to produce the response.
**Tech Stack:** Fastify, postgres (porsager), Zod, SSH exec to host for agent discovery. **Tech Stack:** Fastify, postgres (porsager), Zod, direct host exec for agent discovery (historical plan referenced SSH — superseded at v2.1.0).
--- ---

View File

@@ -2,6 +2,8 @@
Per-batch documentation convention adopted v1.13.15-openspec. Per-batch documentation convention adopted v1.13.15-openspec.
**Agent entry point:** `AGENTS.md` at repo root. **Architecture diagram:** `docs/ARCHITECTURE.md`.
Lift source: Fission-AI/OpenSpec directory layout. **No CLI dependency** — just Lift source: Fission-AI/OpenSpec directory layout. **No CLI dependency** — just
the folder shape. Full OpenSpec lifecycle adoption is a future v1.14+ batch. the folder shape. Full OpenSpec lifecycle adoption is a future v1.14+ batch.

View File

@@ -0,0 +1,5 @@
# v2.2-paseo-providers
**Status:** Shipped (`v2.2-paseo-providers`, `v2.2.1-pane-scoped-chats`). Archived.
Follow-up fixes shipped as `v2.2.1-pane-scoped-chats` (pane-scoped chats, tool UI, WS delta, inference payload).

View File

@@ -0,0 +1,726 @@
# v2.3 Provider lifecycle — design
Detailed implementation plan for Paseo-style provider registration, readiness probing, and enable/disable toggles in BooCoder.
**Audience:** Sam + future agents implementing the batch.
**Paseo reference:** `/opt/forks/paseo/packages/server/src/server/agent/` (registry, snapshot manager, generic ACP), `/opt/forks/paseo/packages/app/src/screens/settings/providers-section.tsx` (UI behavior).
---
## 1. Current state vs target
### 1.1 BooCode today (v2.2)
```
┌─────────────────┐ startup ┌──────────────────┐
│ provider- │ ───────────────► │ available_agents │ (which, version, models JSONB)
│ registry.ts │ agent-probe │ (Postgres) │
│ (7 hardcoded) │ └────────┬─────────┘
└────────┬────────┘ │
│ │
▼ ▼
┌─────────────────┐ cache miss ┌──────────────────┐
│ getProvider │ ──────────────► │ probeAcpProvider │ (full ACP session, 30s)
│ Snapshot() │ per agent │ per installed │
└────────┬────────┘ └──────────────────┘
Omit uninstalled ──► AgentComposerBar never sees them
No enabled flag
status: ready | error only
```
**Key files:**
| File | Role |
|------|------|
| `apps/coder/src/services/provider-registry.ts` | Static `PROVIDERS[]` |
| `apps/coder/src/services/agent-probe.ts` | Boot `which` + DB upsert |
| `apps/coder/src/services/provider-snapshot.ts` | Cache + cold probe + merge |
| `apps/coder/src/services/acp-spawn.ts` | Per-agent argv switch |
| `apps/coder/src/routes/providers.ts` | snapshot + refresh |
| `apps/web/src/components/AgentComposerBar.tsx` | Picker UI |
### 1.2 Target (Paseo-aligned, BooCode-native)
```
┌──────────────────┐
│ Built-in registry│──┐
│ (provider- │ │
│ registry.ts) │ │ merge at boot + on config reload
└──────────────────┘ │
┌──────────────────┐ ┌──────────────────┐
│ /data/coder- │─►│ ResolvedProvider │
│ providers.json │ │ Registry (in-mem) │
└──────────────────┘ └────────┬───────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
agent-probe (fast) getProviderSnapshot dispatch
which + version tier-1: isAvailable generic ACP for
→ available_agents tier-2: cold ACP config entries
enabled filter
Always emit entry per registered provider
loading → ready | unavailable | error
```
**Principles copied from Paseo** (`docs/providers.md` in fork):
1. **Registration ≠ installation** — config lists what you *want*; probe tells you whats *ready*.
2. **Warm until refresh** — no TTL re-probe on picker open; explicit `POST /api/providers/refresh` only.
3. **Disabled skips probe**`enabled: false``unavailable` without spawning.
4. **Config reload replaces registry** — no redeploy to add an ACP wrapper.
---
## 2. Config file: `/data/coder-providers.json`
### 2.1 Location and loading
| Env var | Default | Notes |
|---------|---------|-------|
| `CODER_PROVIDERS_PATH` | `/data/coder-providers.json` | Same bind-mount pattern as `SKILLS_ROOT`, `MCP_CONFIG_PATH` |
- BooCoder runs on **host systemd** — path resolves to `/opt/boocode/data/coder-providers.json` in dev (add to repo as `data/coder-providers.json` + `.env.host`).
- Missing file → `{}` (built-ins only, all enabled).
- Invalid JSON → log error, fall back to `{}` (do not crash boot).
- **Reload:** on `POST /api/providers/config` success, or `SIGHUP` optional later; v1: restart `boocoder.service` after manual edit is acceptable for solo use.
### 2.2 Schema (Zod)
New file: `apps/coder/src/services/provider-config.ts`
```typescript
const ProviderOverrideSchema = z.object({
extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends
label: z.string().min(1).optional(),
description: z.string().optional(),
command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args]
env: z.record(z.string()).optional(),
enabled: z.boolean().optional(), // default true
order: z.number().int().optional(), // UI sort key
models: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
});
const CoderProvidersFileSchema = z.object({
providers: z.record(ProviderOverrideSchema).default({}),
});
```
**Rules:**
| Case | Behavior |
|------|----------|
| Built-in id (e.g. `goose`) | Override merges: `enabled`, `label`, `command` (replace spawn), `env` |
| New id + `extends: "acp"` | New registry entry; requires `label` + `command` |
| New id without `extends` | Reject at load with log (v2.3) |
| `enabled: false` on built-in | Stays in registry; snapshot `enabled: false`, status `unavailable` |
| Custom id collision with built-in | Config wins for overrides only; cannot redefine `boocode` transport |
### 2.3 Example file (ship in `data/coder-providers.json`)
```json
{
"providers": {
"goose": { "enabled": true },
"copilot": { "enabled": false },
"amp-acp": {
"extends": "acp",
"label": "Amp",
"description": "ACP wrapper for Amp",
"command": ["amp-acp"],
"enabled": true
}
}
}
```
### 2.4 Paseo parity notes
Paseo uses `~/.paseo/config.json` under `agents.providers` with the same fields (`extends`, `command`, `enabled`, `models`, …). We intentionally use a **repo-adjacent data file** instead of dotfile — matches `AGENTS.md` / skills layout and survives container/host split (coder reads host path).
---
## 3. Resolved provider registry
### 3.1 New module: `provider-config-registry.ts`
**Responsibility:** Single in-memory source of truth after merge.
```typescript
export interface ResolvedProviderDef extends ProviderDef {
id: string;
enabled: boolean;
isBuiltin: boolean;
isCustomAcp: boolean;
/** Full argv for spawn: [binary, ...args] */
launchCommand: [string, ...string[]] | null;
env: Record<string, string> | undefined;
configLabel?: string;
configDescription?: string;
}
export function buildResolvedRegistry(
builtins: ProviderDef[],
config: CoderProvidersFile,
): Map<string, ResolvedProviderDef>;
export function loadProviderConfig(path: string): CoderProvidersFile;
export function reloadProviderConfig(): void; // called after PATCH
```
**Merge algorithm** (mirror Paseo `buildProviderRegistry` / `addDerivedProviders`):
1. For each built-in in `PROVIDERS`:
- Apply config override if present
- `enabled = override.enabled !== false`
- `launchCommand` = override.command ?? default from `acp-spawn` + `install_path` at dispatch time
2. For each config key not in built-ins:
- Require `extends: "acp"`, `label`, `command`
- Insert as `isCustomAcp: true`, `transport: 'acp'`, `modelSource: 'probe'`
3. **`boocode`** always enabled; ignore `enabled: false` with warn log
**Consumers:** `agent-probe`, `provider-snapshot`, `dispatcher`, `acp-dispatch`, routes.
### 3.2 agent-probe changes
File: `apps/coder/src/services/agent-probe.ts`
- Iterate **`getResolvedProviderIds()`** instead of `PROBED_AGENT_NAMES` only.
- For custom ACP: probe `command[0]` via `which` (not agent name).
- Upsert `available_agents` for custom ids (new rows).
- Store `label`, `transport: 'acp'` from resolved def.
- Skip probe entirely when `enabled: false` (optional: delete row or keep stale — **keep row**, set `install_path null` on disable refresh).
### 3.3 Schema migration (optional column)
File: `apps/coder/src/schema.sql`
```sql
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'builtin';
-- source: 'builtin' | 'config'
```
Mirror `enabled` from config on each probe pass. Custom providers get `source = 'config'`.
**Alternative (simpler v2.3.0):** dont add DB column; read `enabled` only from in-memory registry at snapshot time. DB holds install facts only. Prefer this for phase 1; add column if settings page needs to show state after coder restart without re-reading JSON.
---
## 4. Snapshot lifecycle
### 4.1 Type changes
Files: `apps/coder/src/services/provider-types.ts`, `apps/web/src/api/types.ts`
```typescript
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
export interface ProviderSnapshotEntry {
name: string;
label: string;
description?: string;
transport: string;
status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean; // binary found on last fast probe
models: ProviderModel[];
modes: ProviderMode[];
defaultModeId: string | null;
commands: AgentCommand[];
error?: string;
fetchedAt?: string; // ISO — when tier-2 probe completed
}
```
Restore `unavailable` (removed in stale cleanup — intentional regression for this batch).
### 4.2 `buildProviderEntry` rewrite
File: `apps/coder/src/services/provider-snapshot.ts`
**Stop returning `null` for uninstalled.** Always return an entry for every resolved registry id.
```
for each resolvedProvider:
if !enabled:
return { status: 'unavailable', enabled: false, installed: false, models: [], ... }
if native boocode:
return { status: 'ready', enabled: true, installed: true, models: llamaSwap, ... }
fast = agentRow?.install_path != null // or isCommandAvailable(launchCommand[0])
if !fast:
return { status: 'unavailable', enabled: true, installed: false, models: [], modes: manifest, commands: manifest }
if tier2_skipped: // see §4.3
return { status: 'ready', enabled: true, installed: true, models: from DB, modes: manifest or DB, ... }
cold ACP probe:
ok → ready + models/modes/commands merge
fail → error + error message
```
### 4.3 Two-tier probe (implements deferred work §2)
**Tier 1 — fast (always on cold read if enabled + installed):**
```typescript
async function isProviderAvailable(resolved: ResolvedProviderDef, agentRow: AgentRow): Promise<boolean> {
if (resolved.isNative) return true;
if (agentRow?.install_path) return true;
if (resolved.launchCommand) return isCommandAvailable(resolved.launchCommand[0]);
return false;
}
```
New util: `apps/coder/src/services/command-availability.ts``which`-style check (lift idea from Paseo `utils/executable.ts`, ~20 lines, no full port).
**Tier 2 — slow (ACP session):**
Run only when:
| Condition | Action |
|-----------|--------|
| `force === true` (`POST /refresh`) | Always cold probe installed enabled providers |
| `last_probed_at` older than `PROVIDER_PROBE_TTL_MS` (default 24h, env override) | Cold probe |
| DB models empty AND installed | Cold probe |
| Otherwise | Use `available_agents.models` + manifest modes/commands |
Env: `PROVIDER_PROBE_TTL_MS` default `86400000` (24h). Paseo uses warm-forever until refresh; 24h is a homelab compromise so stale model lists self-heal.
**Paseo contract (adopt explicitly):**
- Opening `AgentComposerBar` does **not** call refresh or force probe.
- `POST /api/providers/refresh` clears cache + forces tier-2 for home cwd.
- Document in `BOOCODER.md`.
### 4.4 Loading state
On cache miss, before async probe completes:
1. Return entries with `status: 'loading'` immediately (sync).
2. Singleflight inflight map (already exists) — on completion, flip to terminal status + emit…
**Tier 2 optional:** WS frame `provider_snapshot_updated` — defer to follow-up; v2.3 can rely on client polling 2s while any entry `loading` (CoderPane already polls when WS disconnected; extend: poll while snapshot has `loading`).
### 4.5 Cache keys
Keep cwd-keyed cache (`resolvedCwd = cwd ?? homedir()`). Settings UI uses snapshot with **no cwd** or explicit `cwd=~` — same as Paseo home-directory snapshot for provider management.
---
## 5. Generic ACP dispatch
### 5.1 Problem
`acp-spawn.ts` switch grows with every agent. Custom config entries cannot dispatch today.
### 5.2 Solution
File: `apps/coder/src/services/acp-spawn.ts`
```typescript
export function resolveLaunchSpec(
resolved: ResolvedProviderDef,
installPath: string | null,
): { binary: string; args: string[]; env?: Record<string, string> } | null {
if (resolved.launchCommand) {
return {
binary: resolved.launchCommand[0],
args: resolved.launchCommand.slice(1),
env: resolved.env,
};
}
// built-in fallback
const args = resolveAcpSpawnArgs(resolved.id);
if (!args || !installPath) return null;
return { binary: installPath, args, env: resolved.env };
}
```
File: `apps/coder/src/services/acp-dispatch.ts`
- Replace `resolveAcpSpawnArgs(agent)` + `spawn(installPath, args)` with `resolveLaunchSpec(resolved, installPath)`.
- Merge `env` into spawn `env: { ...process.env, ...spec.env }`.
- Dispatcher loads resolved def by task.agent name.
**Do not port** Paseo `GenericACPAgentClient` class — keep procedural dispatch + existing `acp-stream.ts`.
---
## 6. HTTP API
File: `apps/coder/src/routes/providers.ts`
| Method | Path | Body | Response |
|--------|------|------|----------|
| GET | `/api/providers/snapshot?cwd=` | — | `ProviderSnapshotEntry[]` (unchanged path) |
| POST | `/api/providers/refresh` | `{ providers?: string[] }` optional | `{ refreshed: number }` — if `providers` set, refresh subset only (Paseo pattern) |
| GET | `/api/providers/config` | — | `{ providers: Record<string, ProviderOverride> }` |
| PATCH | `/api/providers/config` | partial providers map | merged file written, registry reload, `{ ok: true }` |
| GET | `/api/providers/:id/diagnostic` | — | `{ diagnostic: string }` Tier 2 |
**PATCH semantics:** shallow merge at top level per provider id (same as Paseo `patchConfig`). Writing `enabled: false` triggers registry reload + snapshot reconcile (mark unavailable without probe).
**Proxy:** BooChat server may proxy `/api/coder/providers/*` — check `apps/server/src/index.ts` coder proxy prefix; add config routes if missing.
**Web client:** `apps/web/src/api/client.ts`
```typescript
coder: {
snapshot: ...
refreshProviders: (providers?: string[]) => ...
getProviderConfig: () => ...
patchProviderConfig: (patch) => ...
getProviderDiagnostic: (id) => ...
}
```
---
## 7. Web UI
### 7.1 Settings: Provider management drawer
New: `apps/web/src/components/coder/ProviderSettingsDrawer.tsx` (or section under existing settings)
**Behavior lifted from Paseo `providers-section.tsx`:**
| UI element | Action |
|------------|--------|
| Row per registered provider | Label, status dot, model count |
| Switch | `PATCH config { [id]: { enabled } }` |
| Refresh icon | `POST /api/providers/refresh` |
| Add provider | Opens catalog modal |
| Row click | Diagnostic sheet (optional phase 2) |
**Status labels:** Disabled · Loading · Available · Not installed · Error
Entry point: link from `AgentComposerBar` (gear icon) or CoderPane header.
### 7.2 AgentComposerBar filter
File: `apps/web/src/components/AgentComposerBar.tsx`
```typescript
const selectable = entries.filter(
(e) => e.enabled && e.status === 'ready' && e.models.length > 0
);
// boocode: allow ready with empty models if llama-swap down? keep current fallback
```
Show subtitle when current provider becomes unavailable (toast + reset to boocode).
### 7.3 Add provider modal
New: `apps/web/src/data/acp-provider-catalog.ts`
Curated entries (start with 510 you might install):
| id | command | installLink |
|----|---------|-------------|
| amp-acp | `["amp-acp"]` | github amp-acp |
| cline | `["npx","-y","cline@…","--acp"]` | cline.bot |
| pi-acp | from fork | … |
Copy **structure** from Paseo `acp-provider-catalog.ts` + `buildAcpProviderConfigPatch` — trim versions aggressively; pin only when youve verified on homelab.
Modal: search, Install → `patchProviderConfig(buildPatch(entry))``refreshProviders([entry.id])`.
**Do not port:** React Native components, remote SVG icon pipeline — use lucide fallback icon.
### 7.4 Loading UX
While any entry `status === 'loading'`, show spinner in composer provider dropdown; optional 2s poll until terminal state (reuse CoderPane poll pattern).
---
## 8. Diagnostics (Tier 2 in batch — lightweight)
Paseo `getDiagnostic()` runs version probe + short ACP initialize. For solo debugging:
File: `apps/coder/src/services/provider-diagnostic.ts`
```typescript
export async function getProviderDiagnostic(
resolved: ResolvedProviderDef,
agentRow: AgentRow | undefined,
cwd: string,
): Promise<string> {
// Plaintext report:
// - enabled, installed, binary path
// - last_probed_at, model count from DB
// - optional: 8s ACP initialize probe (reuse acp-probe with shorter timeout)
}
```
No need for Paseo `diagnostic-utils.ts` formatting library — a template string is fine.
---
## 9. Testing strategy
| Test | File |
|------|------|
| Config load + merge | `provider-config-registry.test.ts` |
| Snapshot: disabled → unavailable, no probe mock call | extend `provider-snapshot.test.ts` |
| Snapshot: uninstalled → unavailable, installed true/false | same |
| Tier-2 skip when fresh DB models | same |
| force refresh calls probe | same |
| PATCH config writes file | `routes/providers.test.ts` (optional integration) |
| resolveLaunchSpec custom command | `acp-spawn.test.ts` |
Run: `pnpm -C apps/coder test`, `npx tsc -p apps/web/tsconfig.app.json --noEmit`.
Smoke:
```bash
curl http://100.114.205.53:9502/api/providers/snapshot
curl -X PATCH http://100.114.205.53:9502/api/providers/config -d '{"providers":{"goose":{"enabled":false}}}'
curl -X POST http://100.114.205.53:9502/api/providers/refresh
```
---
## 10. Implementation phases
### Phase 1 — Config + registry (backend only)
- `provider-config.ts`, `provider-config-registry.ts`
- `data/coder-providers.json` + `CODER_PROVIDERS_PATH`
- Wire `agent-probe` to resolved ids
- Unit tests
**Exit:** custom entry in JSON → row in `available_agents` after restart.
### Phase 2 — Snapshot lifecycle
- Types: `loading`, `unavailable`, `enabled`
- Rewrite `buildProviderEntry` (never omit)
- Tier-1 fast availability
- Tier-2 skip when DB fresh
- Restore warm-cache + force refresh semantics
**Exit:** disabled goose visible in API as unavailable; picker filters it out.
### Phase 3 — Generic dispatch
- `resolveLaunchSpec`
- Dispatcher passes resolved def
- Smoke: dispatch task for config-only provider (amp-acp if installed)
### Phase 4 — HTTP config API
- GET/PATCH config
- Reload registry on PATCH
- Subset refresh
### Phase 5 — Web UI
- Provider settings drawer + toggle
- AgentComposerBar filter
- Catalog modal (minimal list)
### Phase 6 — Docs + deploy
- `BOOCODER.md` section: Provider config
- `CHANGELOG.md` entry
- `docs/DEFERRED-WORK.md` — mark cold-probe item resolved
- `pnpm -C apps/coder build && sudo systemctl restart boocoder`
---
## 11. Tier 2 follow-ups (document, dont build in v2.3)
| Item | Paseo source | When |
|------|--------------|------|
| WS `provider_snapshot_updated` | `ProviderSnapshotManager` EventEmitter | When loading poll feels hacky |
| MCP `list_providers` / `inspect_provider` | `mcp-server.ts` | When BooCoder MCP orchestration matures |
| Profile overrides (`extends: "claude"`) | `provider-registry.ts` derived providers | When you run Z.AI / multi-endpoint |
| `order` field UI sort | config schema | When catalog >10 entries |
| Per-workspace snapshot in picker | cwd param | Already partial — verify project path passed from CoderPane |
---
## 12. Tier 3 reference — what Paseo has and why we dont port it
This section is **reference only**. These are large subsystems in `/opt/forks/paseo` that solve problems BooCode doesnt have at solo scale, or that BooCode already solved differently.
### 12.1 `ACPAgentClient` base class (~2,800 lines)
**Path:** `packages/server/src/server/agent/providers/acp-agent.ts`
**What it does:** Full ACP lifecycle — spawn, initialize, session/new, streaming, permissions, tool calls, MCP injection, revert, persisted agent import, probe sessions.
**Why Paseo needs it:** Paseo is the primary runtime for dozens of providers; one abstraction reduces duplication across copilot, cursor, generic ACP, etc.
**Why BooCode skips it:** `acp-dispatch.ts` + `acp-stream.ts` + `acp-probe.ts` already cover dispatch and probe as **scripts** (~400 lines total). Replacing with the class hierarchy is a multi-week rewrite with high regression risk on v2.2 dispatch that works on homelab.
**What we take instead:** Patterns only — `isAvailable()` = resolve binary; permission waiter (already shipped); derive models/modes (already shipped).
---
### 12.2 Per-provider client classes (claude, codex, opencode, pi, copilot, cursor…)
**Paths:** `packages/server/src/server/agent/providers/*/agent.ts`, `codex-app-server-agent.ts` (5,000+ lines)
**What they do:** Native SDK/RPC integration — not just CLI spawn. Codex uses app-server RPC; Claude uses Claude Agent SDK; OpenCode manages a sidecar server.
**Why Paseo needs it:** Deep integration — voice, revert, persisted sessions, feature toggles, OAuth diagnostics.
**Why BooCode skips it:** BooCode **delegates** to existing CLIs in worktrees. No embedded SDKs. PTY path for claude/qwen is stdin pipe; ACP path uses `@agentclientprotocol/sdk` at dispatch boundary only.
**Lift risk:** Importing codex-app-server-agent would drag thousands of lines + unknown deps.
---
### 12.3 `ProviderSnapshotManager` class (full port)
**Path:** `packages/server/src/server/agent/provider-snapshot-manager.ts`
**What it does:** Per-cwd Maps, loading states, singleflight, event emitter, reconcile on registry replace, settings vs workspace refresh split.
**Why not full port:** BooCodes `provider-snapshot.ts` is ~250 lines and already has cache + inflight. **Selective lift:** loading status, reconcile on config reload, subset refresh — not a class-for-class rewrite.
---
### 12.4 React Native settings app (`packages/app`)
**Paths:** `providers-section.tsx`, `add-provider-modal.tsx`, `use-providers-snapshot.ts`
**What it does:** Mobile/desktop cross-platform provider UI with Unistyles, native Switch, adaptive sheets.
**Why BooCode skips it:** BooChat is React web + Tailwind. Port **interaction design** (toggle, status dots, add flow), not components.
---
### 12.5 Daemon config system (`patchConfig`, migrations, Zod wire messages)
**Path:** `packages/server/src/shared/messages.ts` (4000+ lines), daemon config patch RPC
**What it does:** Every settings change is a typed WS/HTTP patch to daemon with validation, persistence, broadcast.
**Why BooCode simplifies:** Single-user — PATCH writes JSON file + reloads in-process Map. No multi-client sync requirement. If BooChat and CLI both edit, last-write-wins on file is acceptable.
---
### 12.6 Full ACP catalog (30+ providers, version-pinned npx)
**Path:** `packages/app/src/data/acp-provider-catalog.ts` (~400 lines)
**Why trim:** Maintenance burden — every upstream version bump is a PR in Paseo. Solo homelab: 510 entries you actually install, update when you install.
---
### 12.7 Voice provider stack
**Path:** `packages/server/src/server/speech/*`
**Why skip:** BooCode has no voice surface; unrelated to coder provider lifecycle.
---
### 12.8 Workspace git service inside agents
**Path:** Codex client integration with `WorkspaceGitService`
**Why skip:** BooCode worktrees (`worktrees.ts`) are explicit per-task; agents run in worktree cwd. Different architecture.
---
### 12.9 OpenCode server manager sidecar
**Path:** `packages/server/src/server/agent/providers/opencode/server-manager.ts`
**What it does:** Manages long-lived OpenCode server process.
**Why skip:** BooCode spawns `opencode acp` per dispatch — stateless, simpler, good enough for single user.
---
### 12.10 Pi RPC agent + session import from JSONL
**Paths:** `packages/server/src/server/agent/providers/pi/agent.ts` (1,500+ lines)
**Why skip until needed:** Only lift if you add `pi` as a built-in with import/revert requirements. Otherwise generic ACP + `extends: "acp"` + pi-acp catalog entry suffices.
---
### 12.11 Summary table
| Paseo subsystem | Lines (approx) | BooCode v2.3 approach |
|-----------------|----------------|------------------------|
| ACPAgentClient | 2,800 | Keep acp-dispatch |
| Codex app server agent | 5,500 | Don't import |
| Provider registry merge | 700 | New 200-line module |
| Snapshot manager | 490 | Extend existing snapshot |
| Generic ACP agent | 300 | resolveLaunchSpec only |
| RN providers UI | 400 | Web drawer ~200 lines |
| MCP list_providers | 200 | Defer |
| Config wire protocol | 4,000+ | JSON file PATCH |
**Rule of thumb for solo project:** Lift **data models and lifecycle rules**, not **class hierarchies**.
---
## 13. Risk register
| Risk | Mitigation |
|------|------------|
| Custom npx provider slow cold start | Show loading; subset refresh; dont block picker on whole snapshot |
| Config file edit while coder running | PATCH API primary; manual edit requires restart (document) |
| `enabled: false` but task in flight | Allow running task to finish; block new sends (picker filter) |
| Type drift web/coder | Update both `provider-types.ts` and `api/types.ts`; optional zod parity test |
| Security: arbitrary command in config | Single-user trusted path; same trust as `AGENTS.md` — no app-layer auth |
| Re-enabling cold probe slowness on refresh | Expected; refresh is explicit user action |
---
## 14. File map (new + touched)
| Action | Path |
|--------|------|
| **New** | `apps/coder/src/services/provider-config.ts` |
| **New** | `apps/coder/src/services/provider-config-registry.ts` |
| **New** | `apps/coder/src/services/command-availability.ts` |
| **New** | `apps/coder/src/services/provider-diagnostic.ts` |
| **New** | `apps/coder/src/services/__tests__/provider-config-registry.test.ts` |
| **New** | `data/coder-providers.json` |
| **New** | `apps/web/src/data/acp-provider-catalog.ts` |
| **New** | `apps/web/src/components/coder/ProviderSettingsDrawer.tsx` |
| **New** | `apps/web/src/components/coder/AddProviderModal.tsx` |
| **Edit** | `apps/coder/src/services/provider-snapshot.ts` |
| **Edit** | `apps/coder/src/services/agent-probe.ts` |
| **Edit** | `apps/coder/src/services/acp-spawn.ts` |
| **Edit** | `apps/coder/src/services/acp-dispatch.ts` |
| **Edit** | `apps/coder/src/services/dispatcher.ts` |
| **Edit** | `apps/coder/src/routes/providers.ts` |
| **Edit** | `apps/coder/src/config.ts``CODER_PROVIDERS_PATH` |
| **Edit** | `apps/coder/.env.host` |
| **Edit** | `apps/coder/src/services/provider-types.ts` |
| **Edit** | `apps/web/src/api/types.ts` |
| **Edit** | `apps/web/src/api/client.ts` |
| **Edit** | `apps/web/src/components/AgentComposerBar.tsx` |
| **Edit** | `BOOCODER.md` |
| **Edit** | `docs/DEFERRED-WORK.md` |
---
## 15. Attribution
Design patterns from [Paseo](https://github.com/getpaseo/paseo) (`/opt/forks/paseo`), especially:
- `provider-registry.ts` — merge built-ins + config + `enabled`
- `provider-snapshot-manager.ts` — loading/unavailable/ready lifecycle
- `provider-launch-config.ts` — override schema
- `providers-section.tsx` — settings UX
- `public-docs/custom-providers.md` — config file semantics
BooCode implementation remains original code — no copy-paste of Paseo sources required; licensing treated as irrelevant per project owner directive.

View File

@@ -0,0 +1,61 @@
# v2.3 Provider lifecycle (Paseo-style registry)
**Status:** Planned
**Depends on:** v2.2 Paseo providers (snapshot, modes, commands, ACP dispatch)
**Reference fork:** `/opt/forks/paseo`
**Related deferred work:** [`docs/DEFERRED-WORK.md`](../../../docs/DEFERRED-WORK.md) §2 (cold-probe skip)
## Why
BooCode v2.2 copied Paseos **snapshot wire shape** (modes, thinking, commands) but not Paseos **provider lifecycle**:
- Providers are hardcoded in `provider-registry.ts`; adding one requires a code change and redeploy.
- Uninstalled agents **disappear** from the picker instead of showing “not installed.”
- There is no **enable/disable** toggle — every probed binary appears.
- Every snapshot cache miss runs a **full cold ACP probe** for all installed agents (530s).
Paseos model (see `/opt/forks/paseo/public-docs/providers.md`) treats providers as **registered entries** in a config-backed registry, then probes the machine for readiness, then lets the user toggle visibility. That fits a one-person homelab: edit JSON, refresh, flip a switch — no TypeScript deploy for each new ACP CLI.
## Scope
### In scope
1. **Config file** `/data/coder-providers.json` — add/disable/custom ACP providers without code changes
2. **Merged registry** — built-ins + config overrides at runtime
3. **Snapshot lifecycle**`loading` | `ready` | `unavailable` | `error`; always list registered providers; `enabled` flag
4. **Two-tier probe** — fast binary check vs slow ACP session (DB `last_probed_at` gate)
5. **Generic ACP dispatch** — config entries spawn via `{ command, env }` without new `acp-spawn` cases
6. **HTTP API** — read/patch config, per-provider refresh, optional diagnostic
7. **Web UI** — settings drawer: provider list, enable toggle, refresh, add-from-catalog (curated ~510 entries)
8. **Tests + docs** — snapshot unit tests, `BOOCODER.md` refresh contract
### Out of scope (this batch)
- Full Paseo ACP catalog (30+ agents) — curate a small local catalog only
- React Native settings app port
- Replacing `acp-dispatch.ts` with Paseos `ACPAgentClient` hierarchy
- Voice provider stack
- MCP `list_providers` / `inspect_provider` tools (Tier 2 follow-up)
- WS push of snapshot updates (Tier 2 follow-up)
## Non-goals
- Multi-user provider prefs (single-user homelab)
- Installing CLIs from the UI (link to install instructions only, like Paseo)
- Removing `available_agents` table — keep it as probe cache, extend with `enabled` or mirror config
## Success criteria
- Add `amp-acp` via catalog → appears in picker after refresh without coder redeploy
- Disable goose in settings → gone from picker, still visible as “Disabled” in settings
- opencode not on PATH → shows “Not installed” in settings, hidden from picker
- Second snapshot open within warm window completes in &lt;500ms (no ACP spawns)
- `POST /api/providers/refresh` still runs full cold probe
- Existing v2.2 dispatch (cursor, opencode, claude, qwen) unchanged for built-ins
## Deliverables
| Doc | Purpose |
|-----|---------|
| [`design.md`](./design.md) | Full architecture, schemas, file map, Tier 3 reference |
| [`tasks.md`](./tasks.md) | Numbered implementation checklist |

View File

@@ -0,0 +1,75 @@
# v2.3 Provider lifecycle — tasks
Implement in phase order from [`design.md`](./design.md). Do not commit unless Sam asks.
## Phase 1 — Config + registry
- [ ] 1.1 Add `CODER_PROVIDERS_PATH` to `apps/coder/src/config.ts` (default `/data/coder-providers.json`)
- [ ] 1.2 Add `data/coder-providers.json` example + wire in `apps/coder/.env.host`
- [ ] 1.3 Implement `provider-config.ts` (Zod schema + load/merge/save)
- [ ] 1.4 Implement `provider-config-registry.ts` (`buildResolvedRegistry`, module singleton + reload)
- [ ] 1.5 Unit tests: built-in override, custom ACP add, enabled false, invalid entry skipped
- [ ] 1.6 Update `agent-probe.ts` to iterate resolved registry (include custom ids, respect enabled)
## Phase 2 — Snapshot lifecycle
- [ ] 2.1 Extend `ProviderSnapshotEntry` / status union in coder + web types (`loading`, `unavailable`, `enabled`)
- [ ] 2.2 Add `command-availability.ts` (`isCommandAvailable`)
- [ ] 2.3 Rewrite `buildProviderEntry`: never return null; handle disabled/uninstalled/loading
- [ ] 2.4 Implement tier-2 skip using `available_agents.last_probed_at` + `PROVIDER_PROBE_TTL_MS`
- [ ] 2.5 Return `loading` entries synchronously on cache miss; complete via inflight promise
- [ ] 2.6 Extend `provider-snapshot.test.ts` for disabled, uninstalled, fresh DB skip, force refresh
- [ ] 2.7 Verify warm cache: second snapshot call does not invoke `probeAcpProvider` (mock assert)
## Phase 3 — Generic dispatch
- [ ] 3.1 Add `resolveLaunchSpec()` to `acp-spawn.ts`
- [ ] 3.2 Wire `acp-dispatch.ts` to use launch spec + env merge
- [ ] 3.3 Wire `dispatcher.ts` to load resolved def by agent name
- [ ] 3.4 Unit test: custom command argv reaches spawn
- [ ] 3.5 Smoke: task dispatch for one custom catalog provider (if installed on host)
## Phase 4 — HTTP API
- [ ] 4.1 `GET /api/providers/config`
- [ ] 4.2 `PATCH /api/providers/config` (merge + write file + reload registry + clear snapshot cache)
- [ ] 4.3 `POST /api/providers/refresh` optional body `{ providers?: string[] }`
- [ ] 4.4 `GET /api/providers/:id/diagnostic` (plaintext report)
- [ ] 4.5 Extend `apps/web/src/api/client.ts` coder namespace
- [ ] 4.6 Confirm BooChat proxy forwards new routes (or document direct :9502)
## Phase 5 — Web UI
- [ ] 5.1 Create `apps/web/src/data/acp-provider-catalog.ts` (510 curated entries)
- [ ] 5.2 `AddProviderModal.tsx` — search, install → patch + refresh subset
- [ ] 5.3 `ProviderSettingsDrawer.tsx` — list, status, toggle, refresh, link to add
- [ ] 5.4 Entry point from CoderPane / AgentComposerBar (gear or settings link)
- [ ] 5.5 Filter `AgentComposerBar` selectable providers (`enabled && ready`)
- [ ] 5.6 Loading state while snapshot entries `loading` (poll or one-shot refetch)
- [ ] 5.7 `npx tsc -p apps/web/tsconfig.app.json --noEmit`
## Phase 6 — Docs, deploy, closeout
- [ ] 6.1 `BOOCODER.md` — config file, refresh contract, enable/disable
- [ ] 6.2 Update `docs/DEFERRED-WORK.md` — mark tier-2 cold-probe item addressed
- [ ] 6.3 `CHANGELOG.md` entry when tagged
- [ ] 6.4 `pnpm -C apps/coder test && pnpm -C apps/coder build`
- [ ] 6.5 `sudo systemctl restart boocoder`
- [ ] 6.6 Smoke via Tailscale:
- `curl http://100.114.205.53:9502/api/providers/snapshot`
- PATCH disable goose → absent from composer, visible in settings
- POST refresh → models repopulate
- Add catalog entry → appears after refresh
## Optional (same batch if time)
- [ ] O.1 WS frame `provider_snapshot_updated` (skip polling)
- [ ] O.2 `available_agents.enabled` column mirror
- [ ] O.3 Diagnostic sheet UI (row click → modal)
## Explicitly out of scope
- Port Paseo `ACPAgentClient` / per-provider SDK clients (see design §12)
- Full 30+ ACP catalog
- MCP `list_providers` tools
- Voice providers