Compare commits

...

10 Commits

Author SHA1 Message Date
93d3f86c2b 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>
2026-05-26 15:18:31 +00:00
04673eaf59 v2.1.1: roadmap cleanup + README update + openspec archive
- Archive all 10 shipped openspec changes to openspec/changes/archived/
- Update boocode_roadmap.md: date, shipped status for v1.14/v1.15/v2.0, add v2.1.0 section
- Update README.md: 3-app monorepo, add services table, add What's shipped section
- Remove stale active openspec folders (all work shipped)
2026-05-25 20:23:22 +00:00
d8ffee1950 v2.1.0-provider-picker: BooCoder systemd migration + provider picker
- BooCoder moves from Docker to host systemd service (boocoder.service)
- Agent dispatch (ACP + PTY) switches from SSH to direct spawn/exec
- SSH helpers marked @deprecated (kept for one release cycle)
- Provider registry (5 providers: boocode, opencode, goose, claude, qwen)
- Agent probe with direct which/exec + model discovery (qwen settings, static claude models)
- GET /api/providers route with installed status, models, transport fallback
- ProviderPicker frontend component in CoderPane header
- External provider messages route through tasks row instead of inference enqueue
- Smart scroll: MessageList only auto-scrolls when near bottom (150px threshold)
- DB: available_agents gets models, label, transport columns
- Bug fix: loadContext SELECT includes allowed_read_paths
- Bug fix: cap hit sentinel inserted before buildMessagesPayload
- docker-compose.yml: boocoder service commented out, BOOCODER_URL env var added
- CLAUDE.md: updated docs for systemd, provider registry, JSONB gotcha, loadContext
2026-05-25 19:20:53 +00:00
e423579e99 v2.0.5: FAST_MODEL routing + tool-use summaries + Qwen dispatch + Arena
Source-level recon of QwenLM/qwen-code (Apache-2.0) informed 4 lifts:

1. FAST_MODEL config: optional env var routes cheap LLM calls (titles,
   summaries, labeling) to a smaller model on llama-swap. auto_name.ts
   uses ctx.config.FAST_MODEL ?? session.model. Set FAST_MODEL=nemotron-
   nano-4b to avoid loading the 35B model for 20-token title generation.

2. Tool-use summaries (services/inference/tool-summaries.ts): utility
   that generates "git-commit-subject-style" labels for tool batches via
   a fast-model LLM call. System prompt + truncation logic ported from
   Qwen Code's toolUseSummary.ts. Exported via @boocode/server/inference
   for BooCoder's dispatcher to call after task completion.

3. Qwen as dispatchable agent: added to agent-probe.ts KNOWN_AGENTS.
   PTY dispatch builds: qwen -p "<task>" --output-format stream-json
   (NDJSON structured events over stdout). Env: OPENAI_BASE_URL +
   OPENAI_API_KEY points Qwen Code at llama-swap. execution_path CHECK
   constraint extended with 'qwen'.

4. Arena routes (routes/arena.ts): POST /api/arena dispatches the same
   task to N contestants (2-5, each with different agent/model), each
   getting its own task row linked by arena_id UUID. GET /api/arena/:id
   shows all contestants. POST /api/arena/:id/select/:task_id marks
   winner. Schema: arena_id column added to tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:05:59 +00:00
06116f31b3 v2.0.4-hardening: fuzz suite + integration tests + production readiness
Phase 8 of v2.0. Final hardening pass before production tag.

Path-guard fuzz suite (34 tests): traversal attacks (../ all depths,
encoded %2e%2e, null bytes, absolute escapes, prefix-without-separator,
backslash), secret-file deny list (.env, *.pem, id_rsa*, *.key,
credentials.json, *.kdbx, .netrc), valid-path positives, edge cases
(empty, whitespace, very long, triple-dot, multiple slashes).

write_guard.ts hardened: added null-byte rejection and whitespace-only
rejection (previously only checked empty string).

Pending-changes integration test skeleton: 4 tests covering the full
queue→apply→rewind cycle against a real DB + filesystem. Gated on
DATABASE_URL via describe.runIf (same pattern as apps/server's
tool_cost_stats.test.ts). Skips cleanly when unset.

57 tests passing (23 existing + 34 fuzz), 4 integration skipped.
All builds clean. All services healthy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:31:22 +00:00
47abbb6e3c v2.0.3: CLI client + human inbox + cost tracking + Boomerang new_task
Phase 7 of v2.0. BooCoder gains a terminal-driven UX and subagent
isolation primitive.

CLI (src/cli.ts): standalone entry point for terminal use.
- boocode run "task" [--agent x] [--model y] — create + stream output
- boocode ls [--state x] — formatted task table
- boocode attach <id> — WS stream of running task
- boocode send <id> "msg" — follow-up message to task session
Connects to BOOCODER_URL (default http://100.114.205.53:9502).

Human inbox (routes/inbox.ts): GET /api/inbox (failed/blocked tasks),
POST /api/inbox/:id/retry (reset to pending for re-dispatch).

Cost tracking: dispatcher aggregates tokens_used from all messages in
the task's session after completion, stores in tasks.cost_tokens.
GET /api/stats/costs?group_by=project|agent|day for aggregation.

Boomerang subagent isolation (3 new tools):
- new_task: creates child task with parent_task_id linkage, runs in
  fresh isolated session. Orchestrator sees only output_summary.
- list_tasks: query child tasks of current parent
- check_task_status: read task state + output_summary

The orchestrator pattern: an agent with tools: [new_task, list_tasks,
check_task_status] can ONLY dispatch — can't read files or MCP. This
is the Roo Code Boomerang Tasks capability-restriction principle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:25:18 +00:00
f53c6d6cb9 v2.0.2: BooCoder MCP server — 6 tools over stdio
Phase 6 of v2.0. BooCoder exposes its task primitives as MCP tools
so external agents (Sam's opencode in Termius) can drive the task
queue without going through the web UI.

6 MCP tools registered via McpServer + StdioServerTransport:
- boocoder.create_task — INSERT pending task
- boocoder.list_pending_changes — SELECT pending changes
- boocoder.apply — apply a specific pending change to disk
- boocoder.reject — reject a pending change
- boocoder.dispatch_external_agent — create task with agent for Path B
- boocoder.list_worktrees — list active worktrees from running tasks

Activated by --mcp CLI flag: `node dist/index.js --mcp` starts the
MCP server over stdio instead of the HTTP server. Configure in
opencode: {"mcpServers":{"boocoder":{"type":"stdio","command":"docker",
"args":["exec","-i","boocoder","node","dist/index.js","--mcp"]}}}

Uses McpServer class from @modelcontextprotocol/sdk/server/mcp.js
(high-level .tool() registration API). Zod schemas for input
validation. Process blocks on stdin close, cleanly shuts down DB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:17:28 +00:00
3d6055518b v2.0.1: ACP dispatch + PTY fallback + worktree management
Phase 5 of v2.0. External agent dispatch via SSH to host.

ACP dispatch (acp-dispatch.ts): spawns agent via SSH with JSON-RPC
stdio pipe. Wraps opencode/goose in ACP mode. Captures structured
events (file operations, tool calls) mapped to parts taxonomy.
Falls back to PTY if ACP handshake fails.

PTY dispatch (pty-dispatch.ts): raw SSH spawn for agents without ACP
support (claude, pi). Captures stdout/stderr as plain text. Simpler
but less structured than ACP.

SSH helper (ssh.ts): shared spawn wrapper for SSH commands to
samkintop@100.114.205.53 (Tailscale IP, same as booterm). Uses
openssh-client installed in the runtime Dockerfile stage.

Worktree management (worktrees.ts): createWorktree (git worktree add
via SSH), diffWorktree (git diff HEAD...task-branch), cleanupWorktree
(git worktree remove --force). One worktree per task at
/tmp/booworktrees/<taskId>.

Dispatcher updated: checks available_agents.supports_acp to pick
transport. Path B flow: create worktree → dispatch agent → diff
worktree → queue diff into pending_changes → cleanup worktree →
mark task complete.

Agent probe updated: probes via SSH to find host-installed agents
(which opencode && opencode --version over SSH).

Dockerfile: openssh-client added to runtime stage.
Config: SSH_HOST env var (default 100.114.205.53).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:10:46 +00:00
752ea74f43 v2.0.0-final: dispatcher + task queue + agent probing
Phase 4 of v2.0. BooCoder can now queue tasks and dispatch them
through the inference loop autonomously.

Dispatcher (services/dispatcher.ts): in-process setInterval(5s) polls
tasks WHERE state='pending', picks one at a time, creates an isolated
session+chat, enqueues inference with the task's input as the user
message, polls for completion, marks state completed/failed with
output_summary. Single-task-at-a-time for v2.0.0; parallel dispatch
is a Phase 5+ concern. Respects onClose hook for graceful shutdown.

Task routes (routes/tasks.ts): POST /api/tasks (create), GET /api/tasks
(list with state/project filters), GET /api/tasks/:id (detail),
POST /api/tasks/:id/cancel (marks cancelled, aborts if running).

Agent probe (services/agent-probe.ts): on startup, probes PATH for
opencode/goose/claude/pi via which + --version. UPSERTs into
available_agents table. Finds nothing inside the container (expected —
Phase 5 addresses host-agent access via ACP/PTY).

Schema: ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id (links
task to its auto-created inference session for isolation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:55:18 +00:00
73b53089b0 CLAUDE.md: v2.0.0 architecture docs — BooCoder, DB rename, MCP config, workspace deps
Session learnings applied:
- Database renamed boochat (from boocode), new tables documented
- BooCoder architecture section: workspace dep pattern, write tools,
  coder pane integration, proxy routing
- Environment: MCP_CONFIG_PATH, BooCoder health at :9502
- Workflow: Go binary at /snap/go/current/bin, codecontext fork location
- Conventions: workspace exports with types conditions, Docker build order

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:51:24 +00:00
148 changed files with 10342 additions and 3518 deletions

View File

@@ -1,6 +1,6 @@
NODE_ENV=production
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
PROJECT_ROOT_WHITELIST=/opt
BOOTSTRAP_ROOT=/opt/projects

5
.gitignore vendored
View File

@@ -1,6 +1,11 @@
node_modules
dist
.env
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
.claude/
.cursor/
.cursorignore
CLAUDE.local.md
*.log
.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,62 @@
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
Provider picker: BooCoder moves from Docker container to host systemd service (`boocoder.service`). All agent dispatch (ACP + PTY) switches from SSH tunnel to direct `spawn`/`exec` — no more `sshSpawn`/`sshExec`/`sshSpawnWithStdin` (marked `@deprecated`). New provider registry (`provider-registry.ts`) with 5 providers (boocode, opencode, goose, claude, qwen), per-provider model discovery (llama-swap for ACP agents, `~/.qwen/settings.json` for qwen, static for claude), and `agent-probe.ts` runs direct `which`/`exec` instead of SSH. `GET /api/providers` route assembles the provider list with installed status, models, and transport (ACP→PTY fallback if `supports_acp` is false). Frontend `ProviderPicker` component in CoderPane header lets users pick provider/model per message; messages route through `tasks` row for external providers instead of inference enqueue. Smart scroll: `MessageList` only auto-scrolls when user is near bottom (150px threshold). DB schema adds `models`, `label`, `transport` columns to `available_agents`. Bug fixes: `loadContext` SELECT now includes `allowed_read_paths` (cross-repo read grants were silently failing), cap hit sentinel insertion moved before `buildMessagesPayload` call.
## v2.0.5 — 2026-05-25
FAST_MODEL routing: optional `FAST_MODEL` env var routes cheaper models (titles, summaries, labeling) to a small model on llama-swap (e.g. `nemotron-nano-4b`) instead of loading the 35B for 20-token calls. Falls back to session model or DEFAULT_MODEL. Tool-use summaries: `runCapHitSummary` now writes the cap_hit sentinel before building the summary payload (bug fix — sentinel was written after, causing it to appear after the summary text in the message list). Qwen Code dispatch: `qwen -p "<task>" --output-format stream-json` via PTY (non-interactive mode, no `--yolo` flag needed). Arena: `POST /api/arena` dispatches the same task to N models/agents in parallel, each with its own task + worktree; `GET /api/arena/:id` for results; `POST /api/arena/:id/select/:task_id` picks winner.
## v2.0.4-hardening — 2026-05-25
Path-guard fuzz suite: 25+ traversal-attack tests covering ../ sequences (all depths), encoded traversal (%2e%2e), null byte injection, absolute path escape, prefix-without-separator, backslash traversal, and the full secret-file deny list (.env, *.pem, id_rsa*, *.key, credentials.json, *.kdbx, .netrc). Plus 5 valid-path positive tests confirming normal writes aren't blocked and 5 edge-case tests (empty, whitespace-only, very long path, triple-dot, multiple slashes). Null-byte and whitespace-only guards added to `resolveWritePath` (previously only checked empty string). DB-integration test skeleton for pending_changes full-cycle (queue create/edit/delete, apply, rewind) gated on DATABASE_URL via `describe.runIf`. Production readiness verified: all services healthy, all builds clean, 57 tests passing (23 existing + 34 new).
## v2.0.3 — 2026-05-25
CLI client (`apps/coder/src/cli.ts`, 249 lines) for headless agent interaction. Human inbox view (`human_inbox` view) surfaces tasks in `blocked`/`failed` state. Cost tracking: `tool_cost_stats` view with per-tool 100-call rolling window. `new_task` tool (Boomerang pattern): creates tasks with project context and optional arena contestants. `check_task_status` and `list_tasks` tools for task lifecycle management. Stats routes (`GET /api/stats`) for cost aggregation. Dispatcher extended to support new task states.
## v2.0.2 — 2026-05-25
BooCoder MCP server (`mcp-server.ts`, 201 lines) exposing 6 write-capable tools over stdio: `edit_file`, `create_file`, `delete_file`, `view_pending_changes`, `apply_pending`, `rewind`. Registered in `apps/coder/src/index.ts` as an MCP stdio server. Enables external agents (opencode, claude, qwen) to call BooCoder's write tools through the MCP protocol.
## v2.0.1 — 2026-05-25
ACP dispatch (`acp-dispatch.ts`, 271 lines): runs ACP-capable agents (opencode, goose) via SSH tunnel wrapping stdio into NDJSON streams for `@agentclientprotocol/sdk` JSON-RPC sessions. PTY dispatch (`pty-dispatch.ts`, 139 lines): runs non-ACP agents (claude, qwen) via SSH with stdin pipe for non-interactive mode. Worktree management (`worktrees.ts`, 118 lines): per-task git worktree creation and cleanup. SSH helper (`ssh.ts`, 126 lines): `sshSpawn`, `sshExec`, `sshSpawnWithStdin` for host command execution. Dispatcher extended to route tasks to ACP vs PTY based on agent capability. Agent probe updated to verify ACP support.
## v2.0.0-final — 2026-05-25
Dispatcher (`dispatcher.ts`, 191 lines): task queue with polling loop, Path A (native inference) and Path B (external agent dispatch). Task routes (`tasks.ts`, 138 lines): CRUD for tasks with state transitions. Agent probe (`agent-probe.ts`, 51 lines): startup scan of host for installed agents (opencode, goose, claude, pi, qwen), version detection, ACP capability verification. Schema adds `tasks` table. CLAUDE.md updated with v2.0.0 architecture docs covering BooCoder, DB rename, MCP config, workspace deps.
## v2.0.0 — 2026-05-25
BooCoder frontend: `CoderPane.tsx` (432 lines) as a `'coder'` pane type within BooChat's SPA — chat pane + diff pane (pending changes) + session picker. Standalone fallback SPA in `apps/coder/web/` (Vite + React) served at `:9502` directly. Session streaming via `useSessionStream` WS hook. API client with typed endpoints. Workspace pane persistence via `useWorkspacePanes`. Server routes for pending changes (`PATCH/POST /api/coder/sessions/:id/pending`). Verification discipline rules + chat naming from assistant response.
## v2.0.0-beta — 2026-05-25
Write tools: `edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind` — queue in `pending_changes` table, nothing hits disk until applied. `write_guard.ts` validates paths (resolve + prefix-check, no realpath for creates). Inference loop integration via `inference_context.ts` (bridges inference turn state to tool execution). API routes: `messages.ts` (POST /api/coder/sessions/:id/messages), `pending.ts` (GET/POST /api/coder/sessions/:id/pending). WebSocket support (`ws.ts`) for real-time pending changes updates. Tool adapter (`adapter.ts`) converts inference tool calls to tool execution. Write guard tests (115 lines). Server-side inference loop wired to BooCoder tools.
## v2.0.0-alpha — 2026-05-25
BooCoder foundation: Docker container (`apps/coder/Dockerfile`), docker-compose service, host env file. Schema: `sessions`, `chats`, `messages`, `pending_changes`, `tasks`, `message_parts` tables. DB renamed from `boocode` to `boochat`. Config module, PostgreSQL connection (porsager/postgres). Initial Fastify server with health endpoint. BOOCODER.md guidance file. Implementation plan (8 phases). Proposal updated with AGENTS.md extensions, Boomerang pattern, observation hooks.
## v2.0-proposal — 2026-05-24
v2.0 proposal: BooCoder write tools, pending-changes queue, ACP dispatch, MCP server. Openspec proposal (`proposal.md`, 274 lines) and task breakdown (`tasks.md`, 130 lines) defining the v2.0 feature scope — write-capable coding agent with file operations, external agent dispatch via ACP/PTY, and MCP server for tool exposure.
## v1.16.0-codesight-merge — 2026-05-24
Ports codesight's highest-value analysis capabilities into the codecontext sidecar as 4 new MCP tools. Tier 1 (graph queries on existing edges, no re-parsing): `get_blast_radius` (BFS reverse-edge traversal — "what breaks if I change this file?", with depth tracking) and `get_hot_files` (most-imported files ranked by incoming edge count — change-risk indicators). Tier 2 (tree-sitter AST re-parsing on demand): `get_routes` (Fastify/Express HTTP route extraction with method, path, file, line, inferred tags for db/auth/cache) and `get_middleware` (middleware registration detection via import-name heuristics and app.register/addHook/setErrorHandler patterns, classifying as auth/cors/rate-limit/security/error-handler/logging/validation). All 4 tools use `defer s.graphMu.RUnlock()` for consistent mutex discipline (reviewer caught that the initial implementation released the lock early on the Tier 2 tools). Route object-property extraction delegates to `extractStringValue` for template-literal handling (reviewer catch). codecontext sidecar rebuilt from `/opt/forks/codecontext` commit `b19e646`, tagged `v1.16.0-codesight-merge`. BooCode wrapper tools follow the existing codecontext pattern — 4 new files in `apps/server/src/services/tools/codecontext/`, registered in ALL_TOOLS. 29 new Go tests + 363/363 BooCode server tests passing. No schema changes, no frontend changes.

View File

@@ -2,6 +2,8 @@
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
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).
@@ -66,9 +68,25 @@ Key services:
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
- **`services/provider-registry.ts`** — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
- **`services/agent-probe.ts`** — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
### BooCoder (`apps/coder/src/`)
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
### Frontend (`apps/web/src/`)
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
@@ -105,14 +123,20 @@ Sessions hold 15 panes (chat / empty / placeholder terminal+agent). v1.12.1 m
## Database
PostgreSQL 16. Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `message_parts` (v1.13.0). Views: `messages_with_parts` (v1.13.1-B parts-merge read path), `tool_cost_stats` (v1.13.10 per-tool 100-call rolling window). (`session_panes` was dropped in v1.12.1; workspace pane state lives in `sessions.workspace_panes jsonb`.) Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. The older anonymous `messages_status_check` (without 'cancelled') and `messages_role_check` (without 'system') were dropped in v1.12.1; only the `_chk` variants remain.
PostgreSQL 16. Database name: `boochat` (renamed from `boocode` in v2.0.0-alpha; Docker service name stays `boocode_db`). Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `message_parts` (v1.13.0), `pending_changes` (v2.0.0), `tasks` (v2.0.0), `available_agents` (v2.0.0). Views: `messages_with_parts` (v1.13.1-B parts-merge read path), `tool_cost_stats` (v1.13.10 per-tool 100-call rolling window), `human_inbox` (v2.0.0 — tasks WHERE state IN blocked/failed). (`session_panes` was dropped in v1.12.1; workspace pane state lives in `sessions.workspace_panes jsonb`.) Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. The older anonymous `messages_status_check` (without 'cancelled') and `messages_role_check` (without 'system') were dropped in v1.12.1; only the `_chk` variants remain.
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
## Environment
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist).
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (optional; default `/data/mcp.json` — JSON config for MCP servers matching opencode's `mcpServers` shape; file missing = no MCP).
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL when unset. Set to a small model on llama-swap (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 required on host (container stays Node 20; 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.
## Workflow
@@ -123,7 +147,7 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
@@ -134,6 +158,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild: `docker compose build --no-cache codecontext`.
- Go binary: `/snap/go/current/bin/go` (not on PATH by default). Use `export PATH=$PATH:/snap/go/current/bin` or full path for Go commands.
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
## Conventions
@@ -156,3 +182,6 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
- **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.

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

@@ -1,6 +1,8 @@
# boocode
Self-hosted single-user developer chat app. v1: chat only.
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
@@ -13,6 +15,8 @@ Self-hosted single-user developer chat app. v1: chat only.
- `apps/server` — Fastify API + WebSocket + inference loop + file-read tools
- `apps/web` — React frontend; served by Fastify in production, Vite in dev
- `apps/booterm` — Fastify + node-pty + tmux for in-browser terminal panes
- `apps/coder` — Fastify write tools + ACP/PTY dispatcher + MCP server (BooCoder)
## Local dev
@@ -28,7 +32,7 @@ cp .env.example .env
docker compose up -d boocode_db
# 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 \
pnpm dev:server
@@ -49,11 +53,18 @@ docker compose up --build -d
Binds to `100.114.205.53:9500` (Tailscale). Authelia is expected to gate the
upstream and inject `Remote-User`. Postgres binds loopback only.
## What v1 has
## Services
Project sidebar, sessions per project, chat with streaming responses over
WebSocket, four file-read tools scoped to the project root (`view_file`,
`list_dir`, `grep`, `find_files`), and a model picker driven by llama-swap's
`/v1/models`.
|Service|Port|Description|
|---|---|---|
|BooChat|`100.114.205.53:9500`|Read-only chat + SPA |
|BooTerm|`100.114.205.53:9501`|PTY/tmux terminal panes |
|BooCoder|host:9502|Write tools + agent dispatch + MCP server (systemd service, not Docker) |
|Postgres|`127.0.0.1:5500`|Shared database (`boochat`; Docker service `boocode_db`) |
|codecontext|internal `:8080`|Code graph sidecar (Docker network only) |
What v1 does not have lives in v2 (terminal pane) and v3 (Coder pane).
## What's shipped
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (Context7 + multi-server), tool-cost tracking, skills system, agent registry, provider picker with model discovery
- **BooTerm**: in-browser terminal panes via tmux + xterm.js, per-session tmux sessions, SSH-out support
- **BooCoder**: write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`), pending-changes queue with diff UI, ACP/PTY dual-path agent dispatch, MCP server (6 tools, stdio), CLI client, human inbox, Boomerang orchestration, path-guard fuzz suite

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

@@ -0,0 +1,15 @@
NODE_ENV=production
PORT=9502
HOST=100.114.205.53
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat
LLAMA_SWAP_URL=http://100.101.41.16:8401
PROJECT_ROOT_WHITELIST=/opt
BOOTSTRAP_ROOT=/opt/projects
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
LOG_LEVEL=info
SEARXNG_URL=http://100.114.205.53:8888
GITEA_BASE_URL=https://git.indifferentketchup.com
GITEA_USER=indifferentketchup
GITEA_SSH_HOST=100.114.205.53:2222
MCP_CONFIG_PATH=/data/mcp.json
SKILLS_ROOT=/opt/boocode/data/skills

View File

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

View File

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

249
apps/coder/src/cli.ts Normal file
View File

@@ -0,0 +1,249 @@
#!/usr/bin/env node
/**
* BooCoder CLI client.
*
* Usage:
* boocode run "task description" [--agent opencode] [--model claude-opus-4-7] [--project <id>]
* boocode ls [--state pending|running|completed|failed]
* boocode attach <task-id>
* boocode send <task-id> "message"
*/
import { WebSocket } from 'ws';
const BASE_URL = process.env.BOOCODER_URL ?? 'http://100.114.205.53:9502';
// ─── Arg parsing ─────────────────────────────────────────────────────────────
function getFlag(args: string[], name: string): string | undefined {
const idx = args.indexOf(name);
if (idx === -1 || idx + 1 >= args.length) return undefined;
return args[idx + 1];
}
function hasFlag(args: string[], name: string): boolean {
return args.includes(name);
}
// ─── HTTP helpers ────────────────────────────────────────────────────────────
async function api(method: string, path: string, body?: unknown): Promise<unknown> {
const url = `${BASE_URL}${path}`;
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${path}${res.status}: ${text}`);
}
return res.json();
}
// ─── WS streaming ────────────────────────────────────────────────────────────
function streamSession(sessionId: string): void {
const wsUrl = BASE_URL.replace(/^http/, 'ws') + `/api/ws/sessions/${sessionId}`;
const ws = new WebSocket(wsUrl);
ws.on('message', (data) => {
try {
const frame = JSON.parse(data.toString()) as { type: string; content?: string; name?: string; arguments?: string };
if (frame.type === 'delta' && frame.content) {
process.stdout.write(frame.content);
} else if (frame.type === 'tool_call') {
process.stdout.write(`\n[tool: ${frame.name ?? '?'}(${(frame.arguments ?? '').slice(0, 80)})]\n`);
} else if (frame.type === 'tool_result') {
process.stdout.write(`[tool_result]\n`);
} else if (frame.type === 'status' || frame.type === 'chat_status') {
// Silent
}
} catch {
// Non-JSON frame, ignore
}
});
ws.on('error', (err) => {
process.stderr.write(`WS error: ${err.message}\n`);
});
ws.on('close', () => {
process.stdout.write('\n');
process.exit(0);
});
process.on('SIGINT', () => {
ws.close();
process.exit(0);
});
}
// ─── Commands ────────────────────────────────────────────────────────────────
async function cmdRun(args: string[]): Promise<void> {
const input = args.find((a) => !a.startsWith('--'));
if (!input) {
process.stderr.write('Usage: boocode run "task description" [--agent X] [--model X] [--project X]\n');
process.exit(1);
}
const agent = getFlag(args, '--agent');
const model = getFlag(args, '--model');
const project_id = getFlag(args, '--project');
if (!project_id) {
process.stderr.write('Error: --project <uuid> is required\n');
process.exit(1);
}
const result = (await api('POST', '/api/tasks', {
project_id,
input,
...(agent && { agent }),
...(model && { model }),
})) as { id: string; state: string };
process.stdout.write(`Task created: ${result.id} (state: ${result.state})\n`);
// Poll until task has session_id, then stream; or poll until terminal state
const POLL_MS = 2000;
for (;;) {
await sleep(POLL_MS);
const task = (await api('GET', `/api/tasks/${result.id}`)) as {
id: string; state: string; session_id?: string; output_summary?: string;
};
if (task.session_id) {
process.stdout.write(`Streaming session ${task.session_id}...\n`);
streamSession(task.session_id);
return; // streamSession handles exit
}
if (task.state === 'completed') {
process.stdout.write(`\nCompleted: ${task.output_summary ?? '(no summary)'}\n`);
return;
}
if (task.state === 'failed') {
process.stderr.write(`\nFailed: ${task.output_summary ?? '(no summary)'}\n`);
process.exit(1);
}
if (task.state === 'cancelled') {
process.stderr.write(`\nCancelled.\n`);
process.exit(1);
}
}
}
async function cmdLs(args: string[]): Promise<void> {
const state = getFlag(args, '--state');
const query = state ? `?state=${state}` : '';
const tasks = (await api('GET', `/api/tasks${query}`)) as Array<{
id: string; state: string; agent: string | null; input: string; created_at: string;
}>;
if (tasks.length === 0) {
process.stdout.write('No tasks.\n');
return;
}
// Table header
process.stdout.write(
pad('ID', 38) + pad('STATE', 12) + pad('AGENT', 14) + pad('INPUT', 52) + 'CREATED\n',
);
process.stdout.write('-'.repeat(120) + '\n');
for (const t of tasks) {
process.stdout.write(
pad(t.id, 38) +
pad(t.state, 12) +
pad(t.agent ?? '-', 14) +
pad(t.input.slice(0, 50), 52) +
(t.created_at?.slice(0, 19) ?? '') + '\n',
);
}
}
async function cmdAttach(args: string[]): Promise<void> {
const taskId = args[0];
if (!taskId) {
process.stderr.write('Usage: boocode attach <task-id>\n');
process.exit(1);
}
const task = (await api('GET', `/api/tasks/${taskId}`)) as { session_id?: string };
if (!task.session_id) {
process.stderr.write('Task has no session yet (still pending?).\n');
process.exit(1);
}
streamSession(task.session_id);
}
async function cmdSend(args: string[]): Promise<void> {
const taskId = args[0];
const message = args[1];
if (!taskId || !message) {
process.stderr.write('Usage: boocode send <task-id> "message"\n');
process.exit(1);
}
const task = (await api('GET', `/api/tasks/${taskId}`)) as { session_id?: string };
if (!task.session_id) {
process.stderr.write('Task has no session yet.\n');
process.exit(1);
}
// Find active chat
const sessionId = task.session_id;
// POST message to the session's chat (the messages route expects session_id in path)
await api('POST', `/api/sessions/${sessionId}/messages`, { content: message });
// Then attach to stream the response
streamSession(sessionId);
}
// ─── Utils ───────────────────────────────────────────────────────────────────
function pad(s: string, width: number): string {
return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── Main ────────────────────────────────────────────────────────────────────
const [cmd, ...rest] = process.argv.slice(2);
switch (cmd) {
case 'run':
cmdRun(rest).catch(fatal);
break;
case 'ls':
cmdLs(rest).catch(fatal);
break;
case 'attach':
cmdAttach(rest).catch(fatal);
break;
case 'send':
cmdSend(rest).catch(fatal);
break;
default:
process.stdout.write(
'BooCoder CLI\n\n' +
'Commands:\n' +
' run "task" [--agent X] [--model X] [--project <id>] Create and stream a task\n' +
' ls [--state pending|running|completed|failed] List tasks\n' +
' attach <task-id> Stream a running task\n' +
' send <task-id> "message" Send input to a task\n' +
'\n' +
`Base URL: ${BASE_URL} (set BOOCODER_URL to override)\n`,
);
if (cmd && cmd !== '--help' && cmd !== '-h') process.exit(1);
}
function fatal(err: unknown): void {
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}

View File

@@ -23,6 +23,11 @@ const ConfigSchema = z.object({
GITEA_TOKEN: z.string().optional(),
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
MCP_CONFIG_PATH: z.string().optional(),
// v2.0.5: cheaper model for titles, summaries, labeling.
FAST_MODEL: z.string().optional(),
// SSH access to the host for external agent dispatch (Phase 5)
BOOCODER_SSH_HOST: z.string().default('100.114.205.53'),
BOOCODER_SSH_USER: z.string().default('samkintop'),
});
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -9,6 +9,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { loadConfig } from './config.js';
import { getSql, applySchema, pingDb, closeDb } from './db.js';
import { startMcpServer } from './services/mcp-server.js';
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
// inference loop, broker, and tool registry without duplication.
import { createInferenceRunner } from '@boocode/server/inference';
@@ -22,10 +23,31 @@ import { adaptWriteTool } from './services/tools/adapter.js';
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
// Routes
import { registerMessageRoutes } from './routes/messages.js';
import { registerSkillRoutes } from './routes/skills.js';
import { registerPendingRoutes } from './routes/pending.js';
import { registerTaskRoutes } from './routes/tasks.js';
import { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js';
import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.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() {
// MCP mode: stdio transport, no HTTP server
if (process.argv.includes('--mcp')) {
const config = loadConfig();
const sql = getSql(config);
await applySchema(sql);
await startMcpServer(sql);
return;
}
const config = loadConfig();
const app = Fastify({
@@ -54,6 +76,31 @@ async function main() {
// Broker: in-memory pub/sub for session + user channel streaming.
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 ---
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
@@ -113,9 +160,33 @@ async function main() {
});
});
// Phase 4: probe available agents on startup
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
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
dispatcher.start();
app.addHook('onClose', () => dispatcher.stop());
// Register routes
registerMessageRoutes(app, sql, broker, inferenceApi);
registerSkillRoutes(app, sql, broker, inferenceApi);
registerPendingRoutes(app, sql);
registerTaskRoutes(app, sql, inferenceApi);
registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql);
registerProviderRoutes(app, sql, config);
registerWebSocket(app, sql, broker);
// Serve static frontend (built web app). In production, the dist/ is

View File

@@ -0,0 +1,136 @@
/**
* v2.0.5: Arena routes — competitive dispatch of the same task to multiple agents.
*
* POST /api/arena — create an arena with 2-5 contestants
* GET /api/arena/:id — get all tasks in an arena
* POST /api/arena/:id/select/:task_id — mark a task as the arena winner
*/
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
const ContestantSchema = z.object({
agent: z.string().max(100).optional(),
model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
});
const CreateArenaBody = z.object({
project_id: z.string().uuid(),
input: z.string().min(1).max(64_000),
contestants: z.array(ContestantSchema).min(2).max(5),
});
interface TaskRow {
id: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
state: string;
}
export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
// POST /api/arena — create a new arena
app.post('/api/arena', async (req, reply) => {
const parsed = CreateArenaBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, input, contestants } = parsed.data;
const arenaId = crypto.randomUUID();
const tasks: TaskRow[] = [];
for (const contestant of contestants) {
const [task] = await sql<TaskRow[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, arena_id)
VALUES (
${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!);
}
reply.code(201);
return {
arena_id: arenaId,
tasks: tasks.map((t) => ({
id: t.id,
agent: t.agent,
model: t.model,
mode_id: t.mode_id,
thinking_option_id: t.thinking_option_id,
state: t.state,
})),
};
});
// GET /api/arena/:arena_id — list all tasks in an arena
app.get<{ Params: { arena_id: string } }>('/api/arena/:arena_id', async (req, reply) => {
const { arena_id } = req.params;
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(arena_id)) {
reply.code(400);
return { error: 'invalid arena_id format' };
}
const tasks = await sql`
SELECT id, project_id, state, input, output_summary, agent, model, mode_id, thinking_option_id, execution_path, session_id, started_at, ended_at, created_at, arena_id
FROM tasks
WHERE arena_id = ${arena_id}
ORDER BY created_at
`;
if (tasks.length === 0) {
reply.code(404);
return { error: 'arena not found' };
}
return { arena_id, tasks };
});
// POST /api/arena/:arena_id/select/:task_id — mark the winner
app.post<{ Params: { arena_id: string; task_id: string } }>(
'/api/arena/:arena_id/select/:task_id',
async (req, reply) => {
const { arena_id, task_id } = req.params;
// Verify the task belongs to this arena
const rows = await sql<{ id: string; state: string; arena_id: string | null }[]>`
SELECT id, state, arena_id FROM tasks WHERE id = ${task_id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'task not found' };
}
const task = rows[0]!;
if (task.arena_id !== arena_id) {
reply.code(409);
return { error: 'task does not belong to this arena' };
}
// Mark as selected via output_summary prefix (lightweight — no schema change)
await sql`
UPDATE tasks
SET output_summary = COALESCE('[SELECTED] ' || output_summary, '[SELECTED]')
WHERE id = ${task_id}
`;
return { selected: true, task_id, arena_id };
}
);
}

View File

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

@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
export function registerInboxRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/inbox — tasks needing human attention (blocked or failed)
app.get('/api/inbox', async () => {
return sql`
SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, session_id, started_at, ended_at, created_at
FROM human_inbox
ORDER BY created_at DESC
LIMIT 100
`;
});
// POST /api/inbox/:id/retry — reset a blocked/failed task to pending for re-dispatch
app.post<{ Params: { id: string } }>('/api/inbox/:id/retry', async (req, reply) => {
const taskId = req.params.id;
const result = await sql`
UPDATE tasks
SET state = 'pending', started_at = NULL, ended_at = NULL, output_summary = NULL
WHERE id = ${taskId} AND state IN ('blocked', 'failed')
RETURNING id, state
`;
if (result.length === 0) {
reply.code(404);
return { error: 'task not found or not in retryable state' };
}
return { id: result[0]!.id, state: result[0]!.state };
});
}

View File

@@ -3,10 +3,16 @@ 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 { resolveChatId } from './chat-resolve.js';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
chat_id: z.string().uuid(),
pane_id: z.string().min(1).max(200),
chat_id: z.string().uuid().optional(),
provider: z.string().max(100).optional(),
model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
});
interface InferenceApi {
@@ -15,12 +21,100 @@ interface InferenceApi {
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(
app: FastifyInstance,
sql: Sql,
broker: Broker,
inference: InferenceApi,
): 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
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/messages',
@@ -32,73 +126,96 @@ export function registerMessageRoutes(
}
const sessionId = req.params.sessionId;
const { content, chat_id: chatId } = parsed.data;
const { content, pane_id, chat_id: explicitChatId, provider, model, mode_id, thinking_option_id } =
parsed.data;
const isExternal = provider && provider !== 'boocode';
// Validate session exists
const sessionRows = await sql<{ id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId}
const sessionRows = await sql<{ id: string; project_id: string }[]>`
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
// Validate chat belongs to session and is open
const chatRows = await sql<{ id: string; session_id: string }[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
`;
if (chatRows.length === 0) {
const resolved = await resolveChatId(sql, sessionId, pane_id);
if (!resolved) {
reply.code(404);
return { error: 'chat not found or not open in this session' };
return { error: 'pane not found' };
}
// Reject if inference is already running on this chat
if (inference.hasActive(chatId)) {
reply.code(409);
return { error: 'inference already running on this chat' };
let chatId = resolved;
if (explicitChatId) {
const chatRows = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE id = ${explicitChatId} AND session_id = ${sessionId} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found or not open in this session' };
}
chatId = explicitChatId;
}
// Create user message + streaming assistant row in a transaction
const result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
});
if (!isExternal) {
// Reject if inference is already running on this chat
if (inference.hasActive(chatId)) {
reply.code(409);
return { error: 'inference already running on this chat' };
}
}
// Publish user message frames so WS subscribers see it immediately
// Create user message
const [userMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
RETURNING id
`;
await sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
// Publish user message frames
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: result.user_message_id,
message_id: userMsg!.id,
chat_id: chatId,
role: 'user',
} as unknown as WsFrame);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: result.user_message_id,
message_id: userMsg!.id,
chat_id: chatId,
content,
} as unknown as WsFrame);
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: result.user_message_id,
message_id: userMsg!.id,
chat_id: chatId,
} as unknown as WsFrame);
// Enqueue inference — the runner will stream assistant deltas via broker
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
if (isExternal) {
// External provider: create a task for the dispatcher
const projectId = sessionRows[0]!.project_id;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
RETURNING id, state
`;
reply.code(202);
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
}
// Native provider: create streaming assistant row + enqueue inference
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
reply.code(202);
return result;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
},
);

View File

@@ -0,0 +1,17 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.js';
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
const cwd = req.query.cwd;
return getProviderSnapshot(sql, config, cwd);
});
app.post('/api/providers/refresh', async (_req, _reply) => {
clearProviderSnapshotCache();
const entries = await getProviderSnapshot(sql, config, undefined, true);
return { refreshed: entries.length };
});
}

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

@@ -0,0 +1,48 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
const CostQuery = z.object({
group_by: z.enum(['project', 'agent', 'day']).default('project'),
});
export function registerStatsRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/stats/costs — aggregate cost_tokens by project, agent, or day
app.get('/api/stats/costs', async (req, reply) => {
const parsed = CostQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid query', details: parsed.error.flatten() };
}
const { group_by } = parsed.data;
switch (group_by) {
case 'project':
return sql`
SELECT project_id, COUNT(*)::int AS task_count, COALESCE(SUM(cost_tokens), 0)::int AS total_tokens
FROM tasks
WHERE cost_tokens IS NOT NULL
GROUP BY project_id
ORDER BY total_tokens DESC
`;
case 'agent':
return sql`
SELECT COALESCE(agent, 'native') AS agent, COUNT(*)::int AS task_count, COALESCE(SUM(cost_tokens), 0)::int AS total_tokens
FROM tasks
WHERE cost_tokens IS NOT NULL
GROUP BY agent
ORDER BY total_tokens DESC
`;
case 'day':
return sql`
SELECT DATE(created_at) AS day, COUNT(*)::int AS task_count, COALESCE(SUM(cost_tokens), 0)::int AS total_tokens
FROM tasks
WHERE cost_tokens IS NOT NULL
GROUP BY DATE(created_at)
ORDER BY day DESC
LIMIT 90
`;
}
});
}

View File

@@ -0,0 +1,184 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
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 {
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
}
const CreateBody = z.object({
project_id: z.string().uuid(),
input: z.string().min(1).max(64_000),
agent: z.string().max(100).optional(),
model: z.string().max(200).optional(),
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({
state: z.enum(['pending', 'running', 'completed', 'failed', 'blocked', 'cancelled']).optional(),
project_id: z.string().uuid().optional(),
});
export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: InferenceApi): void {
// POST /api/tasks — create a new task
app.post('/api/tasks', async (req, reply) => {
const parsed = CreateBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, input, agent, model, mode_id, thinking_option_id } = parsed.data;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id)
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null})
RETURNING id, state
`;
reply.code(201);
return { id: task!.id, state: task!.state };
});
// GET /api/tasks — list tasks with optional filters
app.get('/api/tasks', async (req, _reply) => {
const parsed = ListQuery.safeParse(req.query);
if (!parsed.success) {
return { error: 'invalid query', details: parsed.error.flatten() };
}
const { state, project_id } = parsed.data;
// Build query with optional filters
if (state && project_id) {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
WHERE state = ${state} AND project_id = ${project_id}
ORDER BY created_at DESC
LIMIT 100
`;
} else if (state) {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
WHERE state = ${state}
ORDER BY created_at DESC
LIMIT 100
`;
} else if (project_id) {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
WHERE project_id = ${project_id}
ORDER BY created_at DESC
LIMIT 100
`;
} else {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
ORDER BY created_at DESC
LIMIT 100
`;
}
});
// GET /api/tasks/:id — single task detail
app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => {
const rows = await sql`
SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, execution_path, worktree_path, session_id, cost_tokens, started_at, ended_at, created_at
FROM tasks
WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'task not found' };
}
return rows[0];
});
// POST /api/tasks/:id/cancel — cancel a pending or running task
app.post<{ Params: { id: string } }>('/api/tasks/:id/cancel', async (req, reply) => {
const taskId = req.params.id;
// Get current task state + session info
const rows = await sql<{ id: string; state: string; session_id: string | null }[]>`
SELECT id, state, session_id FROM tasks WHERE id = ${taskId}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'task not found' };
}
const task = rows[0]!;
if (task.state !== 'pending' && task.state !== 'running' && task.state !== 'blocked') {
reply.code(409);
return { error: `cannot cancel task in state '${task.state}'` };
}
cancelPendingPermission(taskId);
// If running, try to cancel inference
if ((task.state === 'running' || task.state === 'blocked') && task.session_id) {
// Find active chat in the task's session
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
`;
for (const chat of chats) {
await inference.cancel(task.session_id, chat.id);
}
}
await sql`
UPDATE tasks
SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId} AND state IN ('pending', 'running', 'blocked')
`;
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
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,
summary, tail_start_id, compacted_at
FROM messages_with_parts

View File

@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS tasks (
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty'))
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen'))
);
CREATE TABLE IF NOT EXISTS available_agents (
@@ -43,6 +43,31 @@ CREATE TABLE IF NOT EXISTS available_agents (
last_probed_at TIMESTAMPTZ
);
-- v2.0.0 Phase 4: link tasks to their inference sessions.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id);
-- v2.0.5: add 'qwen' to execution_path CHECK + arena_id column.
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_execution_path_chk;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tasks_execution_path_chk') THEN
ALTER TABLE tasks ADD CONSTRAINT tasks_execution_path_chk
CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen'));
END IF;
END $$;
-- v2.0.5: arena support — group tasks into competitive arenas.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS arena_id UUID;
-- Human inbox: tasks needing attention
CREATE OR REPLACE VIEW human_inbox AS
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
-- v2.1.0: provider picker — extend available_agents with model discovery.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
-- 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,96 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { readFile, rm, mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import postgres from 'postgres';
import { queueCreate, queueEdit, queueDelete, applyOne, rewindOne, listPending } from '../pending_changes.js';
/**
* Integration test for the full pending-changes lifecycle.
* Requires DATABASE_URL env var pointing to a running postgres instance.
* Skips cleanly when DATABASE_URL is not set.
*
* Run with:
* DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/coder test
*/
describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () => {
let sql: ReturnType<typeof postgres>;
const testDir = '/tmp/boocode-pending-changes-test-' + Date.now();
const projectRoot = testDir;
const testSessionId = '00000000-0000-0000-0000-000000000001';
beforeAll(async () => {
sql = postgres(process.env.DATABASE_URL!, { max: 3 });
// Apply schema
const schemaPath = resolve(__dirname, '../../schema.sql');
const ddl = readFileSync(schemaPath, 'utf8');
await sql.unsafe(ddl);
// Create temp project directory
await mkdir(testDir, { recursive: true });
});
afterAll(async () => {
// Cleanup test data
await sql`DELETE FROM pending_changes WHERE session_id = ${testSessionId}`;
await sql.end({ timeout: 5 });
// Remove temp directory
await rm(testDir, { recursive: true, force: true });
});
it('queueCreate → listPending → applyOne → verify file exists', async () => {
const change = await queueCreate(sql, testSessionId, null, 'hello.txt', 'hello world', projectRoot);
expect(change.status).toBe('pending');
expect(change.operation).toBe('create');
const pending = await listPending(sql, testSessionId);
expect(pending.some((p) => p.id === change.id)).toBe(true);
const result = await applyOne(sql, change.id, projectRoot);
expect(result.success).toBe(true);
const content = await readFile(resolve(testDir, 'hello.txt'), 'utf8');
expect(content).toBe('hello world');
});
it('queueEdit → apply → verify content changed', async () => {
// Setup: create a file first
const createChange = await queueCreate(sql, testSessionId, null, 'editable.txt', 'original content here', projectRoot);
await applyOne(sql, createChange.id, projectRoot);
// Queue an edit
const editChange = await queueEdit(sql, testSessionId, null, 'editable.txt', 'original', 'modified', projectRoot);
expect(editChange.operation).toBe('edit');
const result = await applyOne(sql, editChange.id, projectRoot);
expect(result.success).toBe(true);
const content = await readFile(resolve(testDir, 'editable.txt'), 'utf8');
expect(content).toBe('modified content here');
});
it('queueDelete → apply → verify file gone', async () => {
// Setup: create a file
const createChange = await queueCreate(sql, testSessionId, null, 'deleteme.txt', 'goodbye', projectRoot);
await applyOne(sql, createChange.id, projectRoot);
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(true);
// Queue a delete
const deleteChange = await queueDelete(sql, testSessionId, null, 'deleteme.txt', projectRoot);
const result = await applyOne(sql, deleteChange.id, projectRoot);
expect(result.success).toBe(true);
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
});
it('rewindOne → verify reverted', async () => {
// Setup: create and apply a file
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);
await applyOne(sql, createChange.id, projectRoot);
// Rewind the create (should delete the file)
const result = await rewindOne(sql, createChange.id, projectRoot);
expect(result.success).toBe(true);
expect(existsSync(resolve(testDir, 'rewindable.txt'))).toBe(false);
});
});

View File

@@ -0,0 +1,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,193 @@
import { describe, it, expect } from 'vitest';
import { resolveWritePath } from '../write_guard.js';
const projectRoot = '/opt/testproject';
describe('write_guard fuzz — traversal attacks', () => {
// Basic traversal
it('rejects ../', () => {
expect(() => resolveWritePath(projectRoot, '../etc/passwd')).toThrow();
});
it('rejects ../../', () => {
expect(() => resolveWritePath(projectRoot, '../../etc/passwd')).toThrow();
});
it('rejects deeply nested ../../../', () => {
expect(() => resolveWritePath(projectRoot, '../../../../../../../etc/shadow')).toThrow();
});
// Encoded traversal — resolve() doesn't decode percent-encoding, so these
// stay as literal filenames. The guard must still not let them escape.
it('rejects %2e%2e/ (literal percent-encoded dots)', () => {
// resolve('/opt/testproject', '%2e%2e/etc/passwd') stays inside root
// because Node's resolve treats the literal characters, not decoded.
// The file would be /opt/testproject/%2e%2e/etc/passwd which IS inside root.
// This test confirms it doesn't throw (it resolves inside) — defense in depth
// is that the filesystem won't have this path, but no traversal occurs.
const result = resolveWritePath(projectRoot, '%2e%2e/etc/passwd');
expect(result).toContain(projectRoot);
});
it('rejects ..%2f (literal percent-encoded slash)', () => {
// '../%2fetc/passwd' — the ../ IS real traversal
expect(() => resolveWritePath(projectRoot, '../%2fetc/passwd')).toThrow();
});
// Null byte injection
it('rejects null bytes', () => {
expect(() => resolveWritePath(projectRoot, 'file.txt\x00.jpg')).toThrow();
});
// Absolute path escape
it('rejects /etc/passwd', () => {
expect(() => resolveWritePath(projectRoot, '/etc/passwd')).toThrow();
});
it('rejects /opt/other-project/file', () => {
expect(() => resolveWritePath(projectRoot, '/opt/other-project/file.ts')).toThrow();
});
// Path that starts with project root as prefix but isn't under it
it('rejects prefix match without separator', () => {
expect(() => resolveWritePath(projectRoot, '/opt/testproject-evil/file.ts')).toThrow();
});
// Double slashes / traversal after valid prefix
it('rejects /opt/testproject/../etc/passwd via double-dot after valid prefix', () => {
expect(() => resolveWritePath(projectRoot, '/opt/testproject/../etc/passwd')).toThrow();
});
// Windows-style (defense-in-depth on Linux)
it('rejects backslash traversal', () => {
// On POSIX, backslash is a valid filename char, so '..\\etc\\passwd' resolves
// as a single segment inside projectRoot. Not a traversal, but test that it
// doesn't crash and stays within root.
const result = resolveWritePath(projectRoot, '..\\etc\\passwd');
// Node resolve on POSIX treats this as a literal filename segment containing backslashes
// that starts with '..' — resolve normalizes: /opt/testproject/..\\etc\\passwd
// Wait: resolve('/opt/testproject', '..\\etc\\passwd') — on POSIX backslash
// is NOT a separator, so this is a file named '..\\etc\\passwd' inside projectRoot.
// Actually no — resolve splits on '/' only on POSIX. '..' at start triggers parent.
// Let's check: the string starts with '..' but the next char is '\\' not '/'.
// Node's path.resolve on POSIX: the string '..\\etc\\passwd' does NOT contain '/'
// so it IS treated as a single path component? No — resolve still splits on '/'.
// '..\\etc\\passwd' has no '/', so resolve('/opt/testproject', '..\\etc\\passwd')
// = resolve('/opt/testproject/..\\etc\\passwd') — but wait, resolve processes
// segments separated by '/'. With no '/', the whole thing is one segment.
// Actually wrong: path.resolve calls normalizeString which handles '.' and '..'
// only when they are full segments delimited by '/'. Since there's no '/' in
// '..\\etc\\passwd', it treats the entire string as one filename.
// So: /opt/testproject/..\\etc\\passwd — inside root. No throw.
expect(result).toContain(projectRoot);
});
// Secret files (deny list)
it('rejects .env', () => {
expect(() => resolveWritePath(projectRoot, '.env')).toThrow();
});
it('rejects nested .env', () => {
expect(() => resolveWritePath(projectRoot, 'config/.env')).toThrow();
});
it('rejects .env.local', () => {
expect(() => resolveWritePath(projectRoot, '.env.local')).toThrow();
});
it('rejects id_rsa', () => {
expect(() => resolveWritePath(projectRoot, '.ssh/id_rsa')).toThrow();
});
it('rejects id_ed25519', () => {
expect(() => resolveWritePath(projectRoot, '.ssh/id_ed25519')).toThrow();
});
it('rejects *.pem', () => {
expect(() => resolveWritePath(projectRoot, 'certs/server.pem')).toThrow();
});
it('rejects *.key', () => {
expect(() => resolveWritePath(projectRoot, 'certs/private.key')).toThrow();
});
it('rejects credentials.json', () => {
expect(() => resolveWritePath(projectRoot, 'credentials.json')).toThrow();
});
it('rejects *.p12', () => {
expect(() => resolveWritePath(projectRoot, 'certs/client.p12')).toThrow();
});
it('rejects .netrc', () => {
expect(() => resolveWritePath(projectRoot, '.netrc')).toThrow();
});
it('rejects *.kdbx', () => {
expect(() => resolveWritePath(projectRoot, 'secrets/passwords.kdbx')).toThrow();
});
// Valid paths (should NOT throw)
it('allows simple relative path', () => {
expect(resolveWritePath(projectRoot, 'src/index.ts')).toBe('/opt/testproject/src/index.ts');
});
it('allows nested path', () => {
expect(resolveWritePath(projectRoot, 'src/services/tools/edit_file.ts')).toContain(projectRoot);
});
it('allows dotfile that is not in deny list', () => {
expect(resolveWritePath(projectRoot, '.gitignore')).toContain(projectRoot);
});
it('allows absolute path inside project', () => {
expect(resolveWritePath(projectRoot, '/opt/testproject/new-file.ts')).toBe('/opt/testproject/new-file.ts');
});
it('allows path with safe internal ../', () => {
expect(resolveWritePath(projectRoot, 'src/../lib/utils.ts')).toBe('/opt/testproject/lib/utils.ts');
});
});
describe('write_guard fuzz — edge cases', () => {
it('throws on empty string', () => {
expect(() => resolveWritePath(projectRoot, '')).toThrow();
});
it('throws on whitespace-only', () => {
expect(() => resolveWritePath(projectRoot, ' ')).toThrow();
});
it('throws when path IS the project root itself', () => {
// Writing to the directory itself makes no sense for a file write
expect(() => resolveWritePath(projectRoot, '/opt/testproject')).not.toThrow();
// The guard allows it (resolve === projectRoot passes the check).
// This is acceptable because the filesystem write will fail on a directory.
// If we want to block this, that's a separate concern.
});
it('handles very long path without crashing', () => {
const longSegment = 'a'.repeat(255);
const longPath = Array(20).fill(longSegment).join('/');
// Should not crash — may throw or succeed, but must not buffer-overflow
expect(() => resolveWritePath(projectRoot, longPath)).not.toThrow();
});
it('handles path with only dots', () => {
// Single dot resolves to projectRoot itself
const result = resolveWritePath(projectRoot, './src/file.ts');
expect(result).toBe('/opt/testproject/src/file.ts');
});
it('rejects triple-dot trick (... is not special but ../ within is)', () => {
// '.../etc' is a literal directory name, not traversal
const result = resolveWritePath(projectRoot, '.../etc');
expect(result).toContain(projectRoot);
});
it('rejects path with multiple consecutive slashes', () => {
// resolve normalizes these; should still be inside root
const result = resolveWritePath(projectRoot, 'src///file.ts');
expect(result).toBe('/opt/testproject/src/file.ts');
});
});

View File

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

@@ -0,0 +1,382 @@
/**
* ACP dispatch — runs ACP-capable agents directly on the host.
*
* v2.3: Paseo-aligned tool lifecycle — stable toolCallId, merge on
* tool_call_update, reasoning stream, worktree FS client, persist-ready snapshots.
*/
import type { FastifyBaseLogger } from 'fastify';
import {
ClientSideConnection,
type Client,
type SessionNotification,
type RequestPermissionRequest,
type RequestPermissionResponse,
type ReadTextFileRequest,
type ReadTextFileResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
type CreateTerminalRequest,
type CreateTerminalResponse,
type SessionConfigOption,
type ClientSideConnection as ConnectionType,
} 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 { 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 {
exitCode: number;
output: string;
toolSnapshots: AcpToolSnapshot[];
reasoningText: string;
stopReason: string;
}
export interface AcpDispatchOpts {
agent: string;
task: string;
worktreePath: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
taskId?: string;
sessionId?: string;
chatId?: string;
messageId?: string;
broker?: Broker;
installPath?: string;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
async function applySessionOverrides(
connection: ConnectionType,
acpSessionId: string,
configOptions: SessionConfigOption[] | null | undefined,
opts: Pick<AcpDispatchOpts, 'model' | 'modeId' | 'thinkingOptionId' | 'log'>,
): 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:
break;
}
}
buildClient(agent: string, modeId: string | undefined, taskId: string | undefined, sessionId: string | undefined): Client {
return {
sessionUpdate: (params) => this.handleSessionUpdate(params),
requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
if (taskId && sessionId) {
return waitForPermissionResponse(taskId, sessionId, agent, modeId, params);
}
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' };
},
};
}
}
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
const {
agent,
task,
worktreePath,
installPath,
signal,
log,
taskId,
modeId,
sessionId,
chatId,
messageId,
broker,
} = opts;
const args = resolveAcpSpawnArgs(agent);
if (!args) {
return {
exitCode: 1,
output: `Agent '${agent}' does not support ACP.`,
toolSnapshots: [],
reasoningText: '',
stopReason: 'error',
};
}
const binary = installPath ?? agent;
log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
const child = spawn(binary, args, {
cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
const streamCtx = new AcpStreamContext(
{ broker, sessionId, chatId, messageId, taskId },
worktreePath,
);
let killed = false;
const cleanup = () => {
if (!killed) {
killed = true;
streamCtx.markAborted();
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 5_000);
}
if (taskId) cancelPendingPermission(taskId);
};
if (signal) {
if (signal.aborted) {
cleanup();
return {
exitCode: 130,
output: 'Aborted before start',
toolSnapshots: streamCtx.snapshots,
reasoningText: '',
stopReason: 'cancelled',
};
}
signal.addEventListener('abort', cleanup, { once: true });
}
try {
const stream = createAcpNdJsonStream(child);
const connection = new ClientSideConnection(
() => streamCtx.buildClient(agent, modeId, taskId, sessionId),
stream,
);
await connection.initialize({
protocolVersion: 1,
clientInfo: { name: 'boocoder', version: '2.3.0' },
clientCapabilities: {},
});
const acpSession = await connection.newSession({ cwd: worktreePath, mcpServers: [] });
log.info({ sessionId: acpSession.sessionId }, 'acp-dispatch: session created');
await applySessionOverrides(connection, acpSession.sessionId, acpSession.configOptions, opts);
const promptResult = await connection.prompt({
sessionId: acpSession.sessionId,
prompt: [{ type: 'text', text: task }],
});
const stopReason = promptResult.stopReason ?? 'end_turn';
log.info(
{ agent, stopReason, toolCallCount: streamCtx.snapshots.length, reasoningChars: streamCtx.reasoningText.length },
'acp-dispatch: prompt completed',
);
await connection.closeSession({ sessionId: acpSession.sessionId }).catch(() => {});
return {
exitCode: 0,
output: streamCtx.output,
toolSnapshots: streamCtx.snapshots,
reasoningText: streamCtx.reasoningText,
stopReason,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.error({ agent, err: message }, 'acp-dispatch: error');
return {
exitCode: 1,
output: message,
toolSnapshots: streamCtx.snapshots,
reasoningText: streamCtx.reasoningText,
stopReason: 'error',
};
} finally {
if (signal) signal.removeEventListener('abort', cleanup);
cleanup();
await new Promise<void>((resolve) => {
child.on('close', resolve);
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

@@ -0,0 +1,115 @@
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
import { exec as execCb } from 'node:child_process';
import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME, 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);
async function resolveInstallPath(agentName: string): Promise<string | null> {
const candidates = resolveAcpProbeBinaries(agentName);
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.
*/
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
clearProviderSnapshotCache();
log.info('agent-probe: scanning for known agents');
for (const agentName of PROBED_AGENT_NAMES) {
try {
const installPath = await resolveInstallPath(agentName);
if (!installPath) continue;
let version: string | null = null;
try {
const { stdout: verOut } = await exec(`"${installPath}" --version`, { timeout: 15_000 });
version = verOut.trim().slice(0, 100);
} catch {
/* optional */
}
const providerDef = PROVIDERS_BY_NAME.get(agentName);
let supportsAcp = providerDef?.transport === 'acp';
if (supportsAcp) {
supportsAcp = await detectAcpSupport(agentName, installPath);
}
let models: Array<{ id: string; label: string }> = [];
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
models = providerDef.staticModels;
}
if (agentName === 'qwen') {
models = await readQwenSettingsModels();
}
const label = providerDef?.label ?? agentName;
const transport =
providerDef?.transport === 'acp' && !supportsAcp ? 'pty' : (providerDef?.transport ?? 'pty');
await sql`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
VALUES (${agentName}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${label}, ${transport})
ON CONFLICT (name) DO UPDATE SET
install_path = EXCLUDED.install_path,
version = EXCLUDED.version,
supports_acp = EXCLUDED.supports_acp,
last_probed_at = EXCLUDED.last_probed_at,
models = EXCLUDED.models,
label = EXCLUDED.label,
transport = EXCLUDED.transport
`;
log.info({ agent: agentName, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.debug({ agent: agentName, err: msg }, 'agent-probe: not found');
}
}
log.info('agent-probe: scan complete');
}

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

@@ -0,0 +1,487 @@
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
import { dispatchViaAcp } from './acp-dispatch.js';
import { dispatchViaPty } from './pty-dispatch.js';
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
import { getManifestCommands } from './provider-commands.js';
import { persistExternalAgentTurn } from './agent-turn-persist.js';
interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean;
}
interface Deps {
sql: Sql;
inference: InferenceRunner;
broker: Broker;
log: FastifyBaseLogger;
config: Config;
}
const POLL_INTERVAL_MS = 5_000;
const COMPLETION_POLL_MS = 2_000;
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
const { sql, inference, broker, log, config } = deps;
let timer: ReturnType<typeof setInterval> | null = null;
let running = false;
let stopping = false;
let inflightPromise: Promise<void> | null = null;
async function poll(): Promise<void> {
if (running || stopping) return;
// Grab one pending task
const rows = await sql<{
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
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
WHERE state = 'pending'
ORDER BY created_at
LIMIT 1
`;
if (rows.length === 0) return;
const task = rows[0]!;
running = true;
inflightPromise = runTask(task).finally(() => {
running = false;
inflightPromise = null;
});
}
async function runTask(task: {
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
}): Promise<void> {
const taskId = task.id;
// Determine execution path: if agent is specified AND exists in available_agents → Path B
if (task.agent) {
const [agentRow] = await sql<{ name: string; supports_acp: boolean; install_path: string | null }[]>`
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
`;
if (agentRow) {
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
return;
}
// Agent specified but not available — fall through to Path A with a warning
log.warn({ taskId, agent: task.agent }, 'dispatcher: specified agent not available, falling back to native');
}
// Path A — native inference (existing behavior)
await runNativeInference(task);
}
// ─── Path A: Native Inference ───────────────────────────────────────────────
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
const taskId = task.id;
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
try {
// Mark running
await sql`
UPDATE tasks
SET state = 'running', started_at = clock_timestamp(), execution_path = 'native'
WHERE id = ${taskId}
`;
// Create session + chat for this task
const model = task.model ?? config.DEFAULT_MODEL;
const sessionName = 'Task: ' + task.input.slice(0, 40);
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${model}, 'open')
RETURNING id
`;
const sessionId = session!.id;
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'Task execution', 'open')
RETURNING id
`;
const chatId = chat!.id;
// Link task to session
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
// Create user message + streaming assistant
await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
// Enqueue inference
inference.enqueue(sessionId, chatId, assistantId, 'default');
// Wait for inference to complete (poll message status)
const finalStatus = await waitForCompletion(assistantId);
if (stopping) {
await sql`
UPDATE tasks
SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId}
`;
return;
}
// Aggregate token cost for the task's session
const [costRow] = await sql<{ total: number | null }[]>`
SELECT SUM(tokens_used)::int AS total
FROM messages
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
`;
const costTokens = costRow?.total ?? null;
if (finalStatus === 'complete') {
const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId}
`;
const summary = (msg?.content ?? '').slice(0, 500);
await sql`
UPDATE tasks
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}
WHERE id = ${taskId}
`;
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
} else {
const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId}
`;
const summary = (msg?.content ?? 'Inference failed').slice(0, 500);
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}
WHERE id = ${taskId}
`;
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)');
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {});
}
}
// ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>─────────────────────────────────
async function runExternalAgent(
task: {
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
},
supportsAcp: boolean,
installPath: string | null,
): Promise<void> {
const taskId = task.id;
const agent = task.agent!;
const executionPath = supportsAcp ? 'acp' : 'pty';
log.info({ taskId, agent, executionPath }, 'dispatcher: starting task (path B — external)');
// Resolve the project's root path
const [project] = await sql<{ path: string | null }[]>`
SELECT path FROM projects WHERE id = ${task.project_id}
`;
const projectPath = project?.path;
if (!projectPath) {
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
WHERE id = ${taskId}
`;
return;
}
// Create an abort controller for this task
const ac = new AbortController();
try {
// Mark running
await sql`
UPDATE tasks
SET state = 'running', started_at = clock_timestamp(), execution_path = ${executionPath}
WHERE id = ${taskId}
`;
let sessionId: string;
let chatId: string;
if (task.session_id) {
sessionId = task.session_id;
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
`;
if (chats.length === 0) {
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
} else {
chatId = chats[0]!.id;
}
} else {
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
RETURNING id
`;
sessionId = session!.id;
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
}
if (!task.session_id) {
await sql`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
`;
}
// Step 1: Create worktree
log.info({ taskId, projectPath }, 'dispatcher: creating worktree');
const worktreePath = await createWorktree(projectPath, taskId, { signal: ac.signal });
log.info({ taskId, worktreePath }, 'dispatcher: worktree created');
// Step 2: Dispatch to agent
let outputSummary: string;
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) {
const result = await dispatchViaAcp({
agent,
task: task.input,
worktreePath,
installPath: installPath ?? 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,
log,
});
assistantContent = result.output.slice(0, 50_000);
acpReasoning = result.reasoningText.slice(0, 200_000);
outputSummary = result.output.slice(0, 500);
await persistExternalAgentTurn(sql, assistantId, result.toolSnapshots, acpReasoning);
} else {
const result = await dispatchViaPty({
agent,
task: task.input,
worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined,
modeId: task.mode_id ?? undefined,
thinkingOptionId: task.thinking_option_id ?? undefined,
signal: ac.signal,
log,
});
assistantContent = (result.stdout || result.stderr || '(no output)').slice(0, 50_000);
outputSummary = (result.stdout || result.stderr).slice(0, 500);
if (assistantContent) {
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
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) {
await sql`
UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}
`;
await cleanupWorktree(projectPath, taskId);
return;
}
// Step 3: Diff the worktree and queue pending changes
log.info({ taskId }, 'dispatcher: diffing worktree');
const diff = await diffWorktree(worktreePath, projectPath, { signal: ac.signal });
if (diff) {
// Queue a single pending_change entry with the full unified diff
await sql`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff})
`;
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff queued as pending change');
} else {
log.info({ taskId }, 'dispatcher: no changes detected in worktree');
}
// Step 4: Cleanup worktree
await cleanupWorktree(projectPath, taskId);
// Step 5: Aggregate token cost
const [extCostRow] = await sql<{ total: number | null }[]>`
SELECT SUM(tokens_used)::int AS total
FROM messages
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
`;
const extCostTokens = extCostRow?.total ?? null;
// Step 6: Mark task completed
await sql`
UPDATE tasks
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${outputSummary}, cost_tokens = ${extCostTokens}
WHERE id = ${taskId}
`;
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.error({ taskId, agent, err: errMsg }, 'dispatcher: external agent error');
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {});
// Best-effort cleanup
await cleanupWorktree(projectPath, taskId);
clearTaskCommands(taskId);
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
async function waitForCompletion(assistantId: string): Promise<string> {
for (;;) {
if (stopping) return 'cancelled';
const [row] = await sql<{ status: string }[]>`
SELECT status FROM messages WHERE id = ${assistantId}
`;
const status = row?.status ?? 'failed';
if (status !== 'streaming') return status;
await sleep(COMPLETION_POLL_MS);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
return {
start() {
log.info('dispatcher: starting poll loop');
timer = setInterval(() => {
poll().catch((err) => {
log.error({ err }, 'dispatcher: poll error');
});
}, POLL_INTERVAL_MS);
},
async stop() {
stopping = true;
if (timer) {
clearInterval(timer);
timer = null;
}
if (inflightPromise) {
log.info('dispatcher: waiting for in-flight task');
await inflightPromise;
}
log.info('dispatcher: stopped');
},
};
}

View File

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

@@ -0,0 +1,232 @@
/**
* BooCoder MCP Server — exposes task primitives as MCP tools.
*
* Started when `--mcp` flag is passed to the entry point. Runs stdio transport
* so external tools (opencode in Termius) can drive the task queue.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import type { Sql } from '../db.js';
import { applyOne, rejectOne } from './pending_changes.js';
// --- Tool handlers -----------------------------------------------------------
interface TaskRow {
id: string;
state: string;
}
interface PendingRow {
id: string;
file_path: string;
operation: string;
diff: string;
session_id: string;
}
interface WorktreeRow {
id: string;
worktree_path: string;
agent: string;
started_at: string;
}
interface ProjectPathRow {
path: string;
}
function textResult(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
// --- Public entry ------------------------------------------------------------
export async function startMcpServer(sql: Sql): Promise<void> {
const server = new McpServer(
{ name: 'boocoder', version: '2.0.2' },
{ capabilities: { tools: {} } },
);
// 1. boocoder.create_task
server.tool(
'boocoder.create_task',
'Create a new task in the BooCoder task queue',
{
project_id: z.string().describe('Project UUID'),
input: z.string().describe('Task description / prompt for the agent'),
agent: z.string().optional().describe('Agent name (optional — uses default if omitted)'),
model: z.string().optional().describe('Model override (optional)'),
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
},
async (args) => {
const [row] = await sql<TaskRow[]>`
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},
${args.mode_id ?? null},
${args.thinking_option_id ?? null},
'pending'
)
RETURNING id, state
`;
return textResult({
task_id: row!.id,
state: row!.state,
mode_id: args.mode_id ?? null,
thinking_option_id: args.thinking_option_id ?? null,
});
},
);
// 2. boocoder.list_pending_changes
server.tool(
'boocoder.list_pending_changes',
'List pending changes awaiting review',
{
session_id: z.string().optional().describe('Optional session filter'),
},
async (args) => {
let rows: PendingRow[];
if (args.session_id) {
rows = await sql<PendingRow[]>`
SELECT id, file_path, operation, diff, session_id
FROM pending_changes
WHERE status = 'pending' AND session_id = ${args.session_id}
ORDER BY created_at ASC
`;
} else {
rows = await sql<PendingRow[]>`
SELECT id, file_path, operation, diff, session_id
FROM pending_changes
WHERE status = 'pending'
ORDER BY created_at ASC
`;
}
const items = rows.map((r) => ({
id: r.id,
file_path: r.file_path,
operation: r.operation,
diff_preview: r.diff.slice(0, 200),
}));
return textResult(items);
},
);
// 3. boocoder.apply
server.tool(
'boocoder.apply',
'Apply a pending change (write to disk)',
{
change_id: z.string().describe('Pending change UUID'),
},
async (args) => {
// Resolve projectRoot from the change's session → project path
const [proj] = await sql<ProjectPathRow[]>`
SELECT p.path FROM pending_changes pc
JOIN sessions s ON pc.session_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE pc.id = ${args.change_id}
`;
if (!proj) {
return textResult({ success: false, file_path: '', error: 'change not found or project path unresolved' });
}
const result = await applyOne(sql, args.change_id, proj.path);
return textResult({ success: result.success, file_path: result.file_path, error: result.error });
},
);
// 4. boocoder.reject
server.tool(
'boocoder.reject',
'Reject a pending change (mark as rejected, no disk write)',
{
change_id: z.string().describe('Pending change UUID'),
},
async (args) => {
await rejectOne(sql, args.change_id);
return textResult({ success: true });
},
);
// 5. boocoder.dispatch_external_agent
server.tool(
'boocoder.dispatch_external_agent',
'Create a task targeting a specific external agent (ACP or PTY dispatch)',
{
project_id: z.string().describe('Project UUID'),
input: z.string().describe('Task prompt'),
agent: z.string().describe('Agent name (must match available_agents registry)'),
model: z.string().optional().describe('Model override (optional)'),
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
},
async (args) => {
const [row] = await sql<TaskRow[]>`
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},
${args.mode_id ?? null},
${args.thinking_option_id ?? null},
'pending'
)
RETURNING id, state
`;
// Determine execution path from available_agents
const [agentRow] = await sql<{ supports_acp: boolean }[]>`
SELECT supports_acp FROM available_agents WHERE name = ${args.agent}
`;
const executionPath = agentRow?.supports_acp ? 'acp' : 'pty';
return textResult({
task_id: row!.id,
state: row!.state,
execution_path: executionPath,
mode_id: args.mode_id ?? null,
thinking_option_id: args.thinking_option_id ?? null,
});
},
);
// 6. boocoder.list_worktrees
server.tool(
'boocoder.list_worktrees',
'List active worktrees from running tasks',
{},
async () => {
const rows = await sql<WorktreeRow[]>`
SELECT id, worktree_path, agent, started_at
FROM tasks
WHERE worktree_path IS NOT NULL AND state = 'running'
ORDER BY started_at DESC
`;
const items = rows.map((r) => ({
task_id: r.id,
worktree_path: r.worktree_path,
agent: r.agent,
started_at: r.started_at,
}));
return textResult(items);
},
);
// Connect via stdio
const transport = new StdioServerTransport();
await server.connect(transport);
// Block until stdin closes (transport handles lifecycle)
await new Promise<void>((resolve) => {
process.stdin.on('end', resolve);
process.stdin.on('close', resolve);
});
await sql.end({ timeout: 5 });
}

View File

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

@@ -0,0 +1,73 @@
export interface ProviderDef {
name: string;
label: string;
transport: 'native' | 'acp' | 'pty';
modelSource: 'llama-swap' | 'static' | 'probe';
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[] = [
{
name: 'boocode',
label: 'BooCoder',
transport: 'native',
modelSource: 'llama-swap',
},
{
name: 'cursor',
label: 'Cursor Agent',
transport: 'acp',
modelSource: 'probe',
},
{
name: 'opencode',
label: 'OpenCode',
transport: 'acp',
modelSource: 'probe',
mergeLlamaSwap: true,
},
{
name: 'goose',
label: 'Goose',
transport: 'acp',
modelSource: 'probe',
},
{
name: 'claude',
label: 'Claude Code',
transport: 'pty',
modelSource: 'static',
staticModels: [
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
],
},
{
name: 'qwen',
label: 'Qwen Code',
transport: 'acp',
modelSource: 'probe',
},
{
name: 'copilot',
label: 'GitHub Copilot',
transport: 'acp',
modelSource: 'probe',
},
];
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

@@ -0,0 +1,137 @@
/**
* PTY dispatch — runs external agents directly on the host.
*/
import type { FastifyBaseLogger } from 'fastify';
import { spawn } from 'node:child_process';
export interface DispatchResult {
exitCode: number;
stdout: string;
stderr: string;
}
export interface PtyDispatchOpts {
agent: string;
task: string;
worktreePath: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
installPath?: string;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
interface PtySpawnSpec {
binary: string;
args: string[];
stdin?: string;
}
function buildPtySpawnSpec(
agent: string,
task: string,
model?: string,
modeId?: string,
thinkingOptionId?: string,
installPath?: string,
): PtySpawnSpec | null {
const binary = installPath ?? agent;
switch (agent) {
case 'claude': {
const args = ['-p'];
if (model) args.push('--model', model);
if (modeId) args.push('--permission-mode', modeId);
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':
return {
binary,
args: model ? ['--model', model] : [],
stdin: task,
};
case 'goose':
return {
binary,
args: model ? ['run', '--text', task, '--model', model] : ['run', '--text', task],
};
default:
return null;
}
}
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
const { agent, task, worktreePath, model, modeId, thinkingOptionId, installPath, signal, log } = opts;
const cmd = buildPtySpawnSpec(agent, task, model, modeId, thinkingOptionId, installPath);
if (!cmd) {
return {
exitCode: 1,
stdout: '',
stderr: `Agent '${agent}' is not yet supported for PTY dispatch.`,
};
}
log.info({ agent, binary: cmd.binary, worktreePath, modeId }, 'pty-dispatch: starting');
return new Promise<DispatchResult>((resolve, reject) => {
const child = spawn(cmd.binary, cmd.args, {
cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
if (cmd.stdin) {
child.stdin!.write(cmd.stdin);
}
child.stdin!.end();
let stdout = '';
let stderr = '';
let killed = false;
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
const cleanup = () => {
if (!killed) {
killed = true;
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 5_000);
}
};
if (signal) {
if (signal.aborted) {
cleanup();
resolve({ exitCode: 130, stdout: '', stderr: 'Aborted before start' });
return;
}
signal.addEventListener('abort', cleanup, { once: true });
}
child.on('close', (code) => {
if (signal) signal.removeEventListener('abort', cleanup);
log.info({ agent, exitCode: code }, 'pty-dispatch: completed');
resolve({ exitCode: code ?? 1, stdout, stderr });
});
child.on('error', (err) => {
if (signal) signal.removeEventListener('abort', cleanup);
log.error({ agent, err: err.message }, 'pty-dispatch: spawn error');
reject(err);
});
});
}

View File

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

@@ -0,0 +1,50 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
const CheckTaskStatusInput = z.object({
task_id: z.string().uuid().describe('ID of the task to check'),
});
type CheckTaskStatusInputT = z.infer<typeof CheckTaskStatusInput>;
export const checkTaskStatusTool: ToolDef<CheckTaskStatusInputT> = {
name: 'check_task_status',
description: 'Check the status and output of a subtask by ID. Returns state, output_summary, and timing.',
inputSchema: CheckTaskStatusInput,
jsonSchema: {
type: 'function',
function: {
name: 'check_task_status',
description: 'Check the status and output of a subtask by ID.',
parameters: {
type: 'object',
properties: {
task_id: { type: 'string', description: 'ID of the task to check' },
},
required: ['task_id'],
},
},
},
async execute(input: CheckTaskStatusInputT, _projectRoot: string, context: ToolContext): Promise<unknown> {
const { sql } = context;
const [task] = await sql<{ id: string; state: string; output_summary: string | null; started_at: string | null; ended_at: string | null }[]>`
SELECT id, state, output_summary, started_at, ended_at
FROM tasks
WHERE id = ${input.task_id}
`;
if (!task) {
return { error: `Task ${input.task_id} not found` };
}
return {
id: task.id,
state: task.state,
output_summary: task.output_summary,
started_at: task.started_at,
ended_at: task.ended_at,
};
},
};

View File

@@ -4,6 +4,9 @@ import { createFileTool } from './create_file.js';
import { deleteFileTool } from './delete_file.js';
import { applyPendingTool } from './apply_pending.js';
import { rewindTool } from './rewind.js';
import { newTaskTool } from './new_task.js';
import { listTasksTool } from './list_tasks.js';
import { checkTaskStatusTool } from './check_task_status.js';
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
@@ -16,6 +19,11 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
deleteFileTool,
editFileTool,
rewindTool,
// Boomerang subtask tools — orchestrator agents call these to spawn/monitor child tasks.
// An "Orchestrator" agent profile would whitelist [new_task, list_tasks, check_task_status].
newTaskTool,
listTasksTool,
checkTaskStatusTool,
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -23,4 +31,4 @@ export const WRITE_TOOLS_BY_NAME: ReadonlyMap<string, ToolDef<any>> = new Map(
WRITE_TOOLS.map((t) => [t.name, t]),
);
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool };
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool };

View File

@@ -0,0 +1,56 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { getInferenceContext } from './inference_context.js';
const ListTasksInput = z.object({
parent_task_id: z.string().uuid().optional().describe('Filter by parent task ID. Omit to list children of current task.'),
});
type ListTasksInputT = z.infer<typeof ListTasksInput>;
export const listTasksTool: ToolDef<ListTasksInputT> = {
name: 'list_tasks',
description: 'List child tasks of the current task (or a specified parent). Returns id, state, input preview, and output_summary.',
inputSchema: ListTasksInput,
jsonSchema: {
type: 'function',
function: {
name: 'list_tasks',
description: 'List child tasks of the current task (or a specified parent).',
parameters: {
type: 'object',
properties: {
parent_task_id: { type: 'string', description: 'Filter by parent task ID. Omit to list children of current task.' },
},
required: [],
},
},
},
async execute(input: ListTasksInputT, _projectRoot: string, context: ToolContext): Promise<unknown> {
const { sql } = context;
const ctx = getInferenceContext();
const parentId = input.parent_task_id ?? ctx.taskId;
if (!parentId) {
return { tasks: [], note: 'No parent task context — not running inside a task.' };
}
const rows = await sql<{ id: string; state: string; input: string; output_summary: string | null }[]>`
SELECT id, state, input, output_summary
FROM tasks
WHERE parent_task_id = ${parentId}
ORDER BY created_at DESC
LIMIT 50
`;
return {
tasks: rows.map((r) => ({
id: r.id,
state: r.state,
input_preview: r.input.slice(0, 100),
output_summary: r.output_summary,
})),
};
},
};

View File

@@ -0,0 +1,65 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { getInferenceContext } from './inference_context.js';
const NewTaskInput = z.object({
input: z.string().min(1).describe('Task description for the child subtask'),
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
model: z.string().optional().describe('Optional: model override for the subtask'),
});
type NewTaskInputT = z.infer<typeof NewTaskInput>;
export const newTaskTool: ToolDef<NewTaskInputT> = {
name: 'new_task',
description:
'Spawn a subtask that runs in isolation. The subtask gets its own session and ' +
'worktree. Use check_task_status to monitor progress. Only the output_summary is ' +
'accessible to the parent — full isolation (Boomerang pattern).',
inputSchema: NewTaskInput,
jsonSchema: {
type: 'function',
function: {
name: 'new_task',
description:
'Spawn a subtask that runs in isolation. The subtask gets its own session and ' +
'worktree. Use check_task_status to monitor progress.',
parameters: {
type: 'object',
properties: {
input: { type: 'string', description: 'Task description for the child subtask' },
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
model: { type: 'string', description: 'Optional: model override for the subtask' },
},
required: ['input'],
},
},
},
async execute(input: NewTaskInputT, _projectRoot: string, context: ToolContext): Promise<unknown> {
const { sql } = context;
// Get the current task's project_id from the inference context
const ctx = getInferenceContext();
const currentTaskId = ctx.taskId;
// Look up the project_id from the current session
const [session] = await sql<{ project_id: string }[]>`
SELECT project_id FROM sessions WHERE id = ${ctx.sessionId}
`;
if (!session) {
return { error: 'Cannot determine project_id from current session' };
}
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
RETURNING id, state
`;
return {
message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
task_id: task!.id,
state: task!.state,
};
},
};

View File

@@ -0,0 +1,118 @@
/**
* Git worktree management for external agent dispatch.
*
* Each dispatched task gets its own git worktree so the external agent
* can modify files freely without touching the main working tree.
* After the agent completes, we diff the worktree against HEAD and
* queue the diff into pending_changes.
*/
import { hostExec } from './host-exec.js';
const WORKTREE_BASE = '/tmp/booworktrees';
/**
* Create a git worktree for a task on the host.
* Returns the absolute path to the worktree directory.
*/
export async function createWorktree(
projectPath: string,
taskId: string,
opts?: { signal?: AbortSignal },
): Promise<string> {
const worktreePath = `${WORKTREE_BASE}/${taskId}`;
const branchName = `task-${taskId}`;
// Ensure the base directory exists
await hostExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
// Create the worktree with a new branch from HEAD
const result = await hostExec(
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
{ signal: opts?.signal, timeoutMs: 30_000 },
);
if (result.exitCode !== 0) {
throw new Error(`Failed to create worktree: ${result.stderr.trim() || result.stdout.trim()}`);
}
return worktreePath;
}
/**
* Get the unified diff of changes made in the worktree vs the parent branch (HEAD).
* Returns an empty string if there are no changes.
*/
export async function diffWorktree(
worktreePath: string,
projectPath: string,
opts?: { signal?: AbortSignal },
): Promise<string> {
// First, commit any uncommitted changes in the worktree so we can diff branches
// Stage all changes
const addResult = await hostExec(
`cd ${shellEscape(worktreePath)} && git add -A`,
{ signal: opts?.signal, timeoutMs: 30_000 },
);
if (addResult.exitCode !== 0) {
throw new Error(`Failed to stage worktree changes: ${addResult.stderr.trim()}`);
}
// Check if there are staged changes
const statusResult = await hostExec(
`cd ${shellEscape(worktreePath)} && git diff --cached --quiet`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
if (statusResult.exitCode === 0) {
// No changes
return '';
}
// Commit staged changes (needed to produce a clean branch diff)
await hostExec(
`cd ${shellEscape(worktreePath)} && git -c user.email=boocoder@local -c user.name=BooCoder commit -m "task changes" --allow-empty`,
{ signal: opts?.signal, timeoutMs: 15_000 },
);
// Diff the worktree branch against the parent commit (HEAD of main tree)
const diffResult = await hostExec(
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
{ signal: opts?.signal, timeoutMs: 60_000 },
);
if (diffResult.exitCode !== 0) {
throw new Error(`Failed to diff worktree: ${diffResult.stderr.trim()}`);
}
return diffResult.stdout;
}
/**
* Remove a worktree and its associated branch.
* Best-effort — does not throw on failure (task may have already been cleaned up).
*/
export async function cleanupWorktree(
projectPath: string,
taskId: string,
): Promise<void> {
const worktreePath = `${WORKTREE_BASE}/${taskId}`;
const branchName = `task-${taskId}`;
// Remove the worktree (--force handles dirty state)
await hostExec(
`git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`,
{ timeoutMs: 15_000 },
).catch(() => {});
// Delete the task branch
await hostExec(
`git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`,
{ timeoutMs: 10_000 },
).catch(() => {});
}
/** Minimal shell escape for paths (single-quote wrapping). */
function shellEscape(s: string): string {
// Replace single quotes with escaped version, wrap in single quotes
return "'" + s.replace(/'/g, "'\\''") + "'";
}

View File

@@ -54,10 +54,14 @@ export function isSecretPath(filePath: string): boolean {
* checks the result stays within projectRoot.
*/
export function resolveWritePath(projectRoot: string, filePath: string): string {
if (!filePath || filePath.length === 0) {
if (!filePath || filePath.trim().length === 0) {
throw new WriteGuardError('file path is required');
}
if (filePath.includes('\x00')) {
throw new WriteGuardError('file path contains null byte');
}
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
const normalized = resolve(candidate); // normalizes ../ segments

View File

@@ -19,7 +19,9 @@
"./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" },
"./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": {
"dev": "tsx watch src/index.ts",

View File

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

View File

@@ -14,6 +14,7 @@ import { registerArtifactRoutes } from './routes/artifacts.js';
import { registerChatRoutes } from './routes/chats.js';
import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js';
import { registerCoderProxy } from './routes/coder-proxy.js';
import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js';
@@ -212,36 +213,10 @@ async function main() {
});
registerWebSocket(app, sql, broker);
// v2.0.0: reverse proxy /api/coder/* to the boocoder container. Keeps the
// SPA's HTTP requests going through a single origin (avoids CORS). WS for
// the coder pane connects directly to boocoder:9502 from the browser (same
// Tailscale network — no CORS issue for WebSocket upgrade requests).
// v2.0.0: reverse proxy /api/coder/* to boocoder (HTTP + WS). CoderPane
// connects WS through /api/coder/ws/sessions/:id on the same origin.
const BOOCODER_ORIGIN = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
app.all('/api/coder/*', async (req, reply) => {
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' });
}
});
registerCoderProxy(app, BOOCODER_ORIGIN);
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
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([
'chat',
'terminal',
'agent',
'coder',
'agent', // legacy alias — normalized to coder on write
'empty',
'settings',
'markdown_artifact',
@@ -307,9 +308,12 @@ export function registerSessionRoutes(
reply.code(400);
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[]>`
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()
WHERE id = ${req.params.id}
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 { z } from 'zod';
import type { Sql } from '../db.js';
import type { Chat } from '../types/api.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
// 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(),
});
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
export function registerSkillsRoutes(
app: FastifyInstance,
sql: Sql,
@@ -62,7 +64,9 @@ export function registerSkillsRoutes(
return { error: 'invalid body', details: parsed.error.flatten() };
}
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[]>`
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}` };
}
const toolCallId = randomUUID();
const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }];
const toolResults = { tool_call_id: toolCallId, output: body, 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 (${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,
};
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
sessionId,
chatId: chat.id,
skillName: skill_name,
skillBody: body,
userText,
});
// Synthetic frames so useSessionStream's reducer reflects the new
// history without a refetch. Frame shapes match the streaming-inference
// protocol (see services/inference.ts InferenceFrame).
handlers.publishSessionFrame(sessionId, {
type: 'message_started',
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,
});
for (const frame of buildSkillInvokeSyntheticFrames(chat.id, result, toolCall, body)) {
handlers.publishSessionFrame(sessionId, frame);
}
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
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' });
});
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 () => {
const session = makeSession();
const project = makeProject();

View File

@@ -309,6 +309,14 @@ export function parseAgentsMd(content: string): ParseResult {
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 ----------------------------------------
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' });
errors.push(...r.errors);
}
if (projectContent !== null) {
if (projectContent !== null && isAgentRegistryMarkdown(projectContent)) {
const r = parseAgentsMd(projectContent);
for (const a of r.agents) byName.set(a.name, { ...a, source: 'project' });
errors.push(...r.errors);

View File

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

View File

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

View File

@@ -37,6 +37,34 @@ export interface OpenAiMessage {
// omit it and exercise the byte-stability surface directly through
// buildSystemPromptWithFingerprint. The observer Map in system-prompt.ts
// 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(
session: Session,
project: Project,
@@ -97,6 +125,10 @@ export async function buildMessagesPayload(
if (m.role === 'tool') {
const tr = m.tool_results;
if (!tr) continue;
const ownerIdx = findAssistantOwnerForToolCall(history, i, tr.tool_call_id);
if (ownerIdx == null || !assistantToolCallsArePayloadComplete(history, ownerIdx)) {
continue;
}
const outputText = tr.error
? `error: ${tr.error}`
: typeof tr.output === 'string'
@@ -115,11 +147,15 @@ export async function buildMessagesPayload(
content: m.content && m.content.length > 0 ? m.content : null,
};
if (m.tool_calls && m.tool_calls.length > 0) {
msg.tool_calls = m.tool_calls.map((tc) => ({
id: tc.id,
type: 'function' as const,
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
}));
if (assistantToolCallsArePayloadComplete(history, i)) {
msg.tool_calls = m.tool_calls.map((tc) => ({
id: tc.id,
type: 'function' as const,
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
// returns them ordered by sequence; multiple reasoning parts on one
@@ -127,6 +163,11 @@ export async function buildMessagesPayload(
if (m.reasoning_parts && m.reasoning_parts.length > 0) {
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);
continue;
}
@@ -142,7 +183,7 @@ export async function loadContext(
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled
agent_id, web_search_enabled, allowed_read_paths
FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) return null;

View File

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

View File

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

View File

@@ -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
// 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 LIST_CACHE_TTL_MS = 60_000;

View File

@@ -85,6 +85,13 @@ export const DeltaFrame = z.object({
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({
type: z.literal('tool_call'),
message_id: Uuid,
@@ -256,6 +263,37 @@ export const ProjectDeletedFrame = z.object({
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 ---------------------------------------------------
export const WsFrameSchema = z.discriminatedUnion('type', [
@@ -263,6 +301,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
SnapshotFrame,
MessageStartedFrame,
DeltaFrame,
ReasoningDeltaFrame,
ToolCallFrame,
ToolResultFrame,
MessageCompleteFrame,
@@ -271,6 +310,9 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
ChatRenamedFrame,
CompactedFrame,
ErrorFrame,
PermissionRequestedFrame,
PermissionResolvedFrame,
AgentCommandsFrame,
// per-user
ChatStatusFrame,
SessionUpdatedFrame,
@@ -300,6 +342,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'snapshot',
'message_started',
'delta',
'reasoning_delta',
'tool_call',
'tool_result',
'message_complete',
@@ -308,6 +351,9 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'chat_renamed',
'compacted',
'error',
'permission_requested',
'permission_resolved',
'agent_commands',
'chat_status',
'session_updated',
'session_renamed',

View File

@@ -13,6 +13,13 @@ import type {
Skill,
AskUserAnswer,
ToolCostStat,
ProviderSnapshotEntry,
CoderSendMessageBody,
CoderSendMessageResponse,
CoderMessageWire,
CoderTaskDetail,
PermissionPrompt,
AgentCommand,
} from './types';
export class ApiError extends Error {
@@ -298,6 +305,49 @@ export const api = {
models: () => request<ModelInfo[]>('/api/models'),
coder: {
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: {
list: (projectId: string) =>
request<AgentsResponse>(`/api/projects/${projectId}/agents`),

View File

@@ -206,6 +206,100 @@ export interface ModelInfo {
[key: string]: unknown;
}
export interface ProviderModel {
id: string;
label: string;
description?: string;
isDefault?: boolean;
thinkingOptions?: ThinkingOption[];
defaultThinkingOptionId?: string;
}
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;
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 | 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 {
id: string;
name: string;

View File

@@ -85,6 +85,13 @@ export const DeltaFrame = z.object({
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({
type: z.literal('tool_call'),
message_id: Uuid,
@@ -256,6 +263,37 @@ export const ProjectDeletedFrame = z.object({
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 ---------------------------------------------------
export const WsFrameSchema = z.discriminatedUnion('type', [
@@ -263,6 +301,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
SnapshotFrame,
MessageStartedFrame,
DeltaFrame,
ReasoningDeltaFrame,
ToolCallFrame,
ToolResultFrame,
MessageCompleteFrame,
@@ -271,6 +310,9 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
ChatRenamedFrame,
CompactedFrame,
ErrorFrame,
PermissionRequestedFrame,
PermissionResolvedFrame,
AgentCommandsFrame,
// per-user
ChatStatusFrame,
SessionUpdatedFrame,
@@ -300,6 +342,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'snapshot',
'message_started',
'delta',
'reasoning_delta',
'tool_call',
'tool_result',
'message_complete',
@@ -308,6 +351,9 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'chat_renamed',
'compacted',
'error',
'permission_requested',
'permission_resolved',
'agent_commands',
'chat_status',
'session_updated',
'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 { AgentPicker } from '@/components/AgentPicker';
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 type { Message } from '@/api/types';
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
// the input and stays open while the input is `/<word>` with no whitespace.
// Disabled entirely when the caller doesn't pass onSlashCommand.
// v1.12 CP7.5: anchorRect was a snapshot taken at open time. SkillSlashCommand
// now reads the live textarea rect via inputRef (textareaRef below) so it can
// recompute on visualViewport changes (iOS keyboard open/close), so the
// anchorRect field is no longer needed in this state.
// SlashCommandPicker reads the live textarea rect via inputRef (textareaRef below)
// so it can recompute on visualViewport changes (iOS keyboard open/close).
const [slashState, setSlashState] = useState<{
query: string;
} | 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
// slash names (literal text) or when slash dispatch isn't wired.
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
if (match && skillsLookup.has(match[1]!)) {
const skillName = match[1]!;
const args = (match[2] ?? '').trim();
const parsed = parseSlashInput(text);
if (parsed && skillsLookup.has(parsed.cmdName)) {
setBusy(true);
try {
await onSlashCommand(skillName, args);
await onSlashCommand(parsed.cmdName, parsed.args);
setValue('');
setAttachments([]);
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
// skill name). Hand off to args mode the moment a space appears or the
// slash leaves position 0.
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) {
const query = newValue.slice(1);
if (onSlashCommand && isSlashCommandToken(newValue)) {
const query = slashQuery(newValue);
if (!slashState) {
setSlashState({ query });
} else if (slashState.query !== query) {
@@ -496,7 +493,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
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.
if (slashState) return;
// 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 && (
<SkillSlashCommand
<SlashCommandPicker
query={slashState.query}
skills={skills}
items={skills}
inputRef={textareaRef}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
emptyLabel="No skills available"
/>
)}
</div>

View File

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

View File

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

View File

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

View File

@@ -29,13 +29,13 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New chat
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New terminal
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
<Code size={14} /> New coder
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</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 { 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 { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -26,6 +26,7 @@ import { useViewport } from '@/hooks/useViewport';
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import type { SidebarProject } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls';
import { isCoderSessionName } from '@/lib/coder-session';
import { cn } from '@/lib/utils';
const EXPANDED_KEY = 'boocode.sidebar.expanded';
@@ -382,7 +383,11 @@ export function ProjectSidebar() {
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)}`}
>
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
{isCoderSessionName(s.name) ? (
<Code 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="text-xs text-muted-foreground shrink-0 tabular-nums">
{relTime(s.updated_at)}

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 { 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 { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { terminalsRegistry } from '@/lib/events';
@@ -34,6 +34,8 @@ interface Props {
// v1.9: passed through to SettingsPane when one is mounted in the grid.
session: Session;
project: Project | null;
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
}
export function Workspace({
@@ -45,6 +47,7 @@ export function Workspace({
chatsHook,
session,
project,
onAddPane,
}: Props) {
const {
panes,
@@ -59,6 +62,7 @@ export function Workspace({
showLandingPage,
addSplitPane,
removePane,
isPaneChatPending,
handlePaneDragStart,
handlePaneDragOver,
handlePaneDragLeave,
@@ -134,44 +138,11 @@ export function Workspace({
return out;
}, [panes]);
// Per-coder-pane WS connection (status dot lives in the pane header).
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
return (
<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
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
style={
@@ -185,6 +156,7 @@ export function Workspace({
{panes.map((pane, idx) => {
const isSettings = pane.kind === 'settings';
const isTerminal = pane.kind === 'terminal';
const isCoder = pane.kind === 'coder';
const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
@@ -197,9 +169,8 @@ export function Workspace({
}
return null;
}
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
// are not drag-reorderable for now — keeps the layout grid simple.
const isChromeless = isSettings || isTerminal || isArtifact;
// Terminal + coder panes own their tab strip (no chats, no ChatTabBar).
const isChromeless = isSettings || isTerminal || isCoder || isArtifact;
return (
<div
key={pane.id}
@@ -233,13 +204,66 @@ export function Workspace({
onCloseAll={() => closeAllTabs(idx)}
onAddPane={(kind) => {
if (kind === 'chat') void createChat(idx);
else addSplitPane(kind);
else onAddPane(kind);
}}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
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 && (
<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" />
@@ -259,14 +283,14 @@ export function Workspace({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
<MessageSquare size={14} /> New chat
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
<Terminal size={14} /> New terminal
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('coder')}>
<Code size={14} /> New coder
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -323,7 +347,18 @@ export function Workspace({
active={idx === activePaneIdx}
/>
) : 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 ? (
<MarkdownArtifactPane
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,15 +1,21 @@
// v2.0.0: BooCoder pane — renders the BooCoder chat + diff interface inside
// BooChat's multi-pane workspace.
// BooCoder pane — chat + diff inside BooChat's multi-pane workspace.
//
// Architecture:
// - REST calls go through /api/coder/* which BooChat's server proxies to
// 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.
// REST: /api/coder/* proxied by BooChat to host boocoder.service (:9502).
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
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 { MarkdownRenderer } from '@/components/MarkdownRenderer';
import { AgentComposerBar } from '@/components/AgentComposerBar';
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';
// ---------------------------------------------------------------------------
@@ -21,16 +27,26 @@ interface CoderMessage {
role: 'user' | 'assistant' | 'system';
content: string;
status?: 'streaming' | 'complete' | 'failed';
reasoning_text?: string;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
tool_results?: {
}
interface CoderToolMessage {
id: string;
role: 'tool';
tool_results: {
tool_call_id: string;
content: string;
output: unknown;
truncated?: boolean;
error?: string;
};
}
type CoderTimelineMessage = CoderMessage | CoderToolMessage;
interface PendingChange {
id: string;
file_path: string;
@@ -42,24 +58,106 @@ interface PendingChange {
interface Props {
sessionId: string;
paneId: string;
chatId?: string;
chatPending?: boolean;
projectPath?: string;
onConnectedChange?: (connected: boolean) => void;
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
interface WsHandlers {
onPermissionRequested?: (prompt: PermissionPrompt) => void;
onPermissionResolved?: (taskId: string) => void;
onAssistantComplete?: () => void;
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
onConnectedChange?: (connected: boolean) => void;
}
function useCoderMessages(sessionId: string) {
const [messages, setMessages] = useState<CoderMessage[]>([]);
type RawCoderMessage = {
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 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(() => {
// Fetch existing messages on mount
fetch(`/api/coder/sessions/${sessionId}/messages`)
.then((res) => res.ok ? res.json() : [])
.then((data: CoderMessage[]) => setMessages(data))
.catch(() => {/* noop — coder backend may not be running */});
}, [sessionId]);
void loadMessages();
}, [loadMessages]);
useEffect(() => {
// WS connects to the coder backend. In production, this goes through the
@@ -76,38 +174,137 @@ function useCoderMessages(sessionId: string) {
ws.onmessage = (ev) => {
try {
const frame = JSON.parse(ev.data as string);
if (frame.type === 'message_started') {
setMessages((prev) => [
...prev,
{ id: frame.message_id, role: frame.role ?? 'assistant', content: '', status: 'streaming' },
]);
const scopedChatId = chatIdRef.current;
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,
{ id: frame.message_id, role, content: '', status: 'streaming' },
];
});
} else if (frame.type === 'delta') {
setMessages((prev) =>
prev.map((m) =>
m.id === frame.message_id
? { ...m, content: m.content + (frame.content ?? '') }
: m
)
prev.map((m) => {
if (m.id !== frame.message_id || m.role === 'tool') return m;
const chunk = frame.content ?? '';
if (m.role === 'user') {
return { ...m, content: chunk || m.content };
}
return { ...m, content: m.content + chunk };
}),
);
} else if (frame.type === 'message_complete') {
setMessages((prev) =>
prev.map((m) =>
m.id === frame.message_id ? { ...m, status: 'complete' } : m
)
);
setMessages((prev) => {
const completed = prev.find(
(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') {
const tc = frame.tool_call as { id: string; name: string; args?: Record<string, unknown> } | undefined;
if (tc?.id) {
setMessages((prev) =>
prev.map((m) =>
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,
tool_results: {
tool_call_id: frame.tool_call_id,
output: frame.output,
truncated: frame.truncated,
...(frame.error ? { error: frame.error } : {}),
},
}
: 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,
tool_calls: [
...(m.tool_calls ?? []),
{ id: frame.tool_call_id, function: { name: frame.name, arguments: frame.arguments ?? '' } },
],
}
: 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 {
@@ -121,7 +318,11 @@ function useCoderMessages(sessionId: string) {
};
}, [sessionId]);
return { messages, setMessages, connected };
useEffect(() => {
handlersRef.current.onConnectedChange?.(connected);
}, [connected]);
return { messages, setMessages, connected, loadMessages };
}
function usePendingChanges(sessionId: string) {
@@ -164,48 +365,6 @@ function usePendingChanges(sessionId: string) {
// 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({
changes,
loading,
@@ -295,102 +454,272 @@ function DiffPanel({
// Main component
// ---------------------------------------------------------------------------
export function CoderPane({ sessionId }: Props) {
const { messages, setMessages, connected } = useCoderMessages(sessionId);
export function CoderPane({
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 [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(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
useEffect(() => {
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === 'assistant' && lastMsg.status === 'complete') {
const lastAssistant = [...messages].reverse().find(
(m): m is CoderMessage => m.role === 'assistant',
);
if (lastAssistant?.status === 'complete') {
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 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('');
setSlashState(null);
setSending(true);
setPermissionPrompt(null);
setLiveTaskCommands([]);
// Optimistic user message
const tempId = `temp-${Date.now()}`;
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
try {
const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content: text }),
const data = await api.coder.sendMessage(sessionId, {
content: text,
pane_id: paneId,
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) {
setMessages((prev) =>
prev.map((m) => m.id === tempId ? { ...m, id: data.user_message_id } : m)
);
}
if (data.user_message_id) {
setMessages((prev) =>
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
);
}
} catch {
// The WS will bring the real messages; optimistic is good enough
if (data.task_id) {
setActiveTaskId(data.task_id);
} else {
setActiveTaskId(null);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to send');
} finally {
setSending(false);
}
}, [input, sending, sessionId, 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(
(e: React.KeyboardEvent) => {
if (slashState) return;
if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
},
[handleSend]
[handleSend, slashState]
);
return (
<div className="flex flex-col h-full bg-background">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/30 shrink-0">
<Code size={14} className="text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">BooCoder</span>
<span
className={cn(
'inline-block w-1.5 h-1.5 rounded-full ml-auto',
connected ? 'bg-green-500' : 'bg-red-500'
)}
title={connected ? 'Connected' : 'Disconnected'}
/>
</div>
{/* Chat area */}
<div className="flex-1 min-h-0 overflow-y-auto">
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
<div className="flex-1 min-h-0 flex flex-col">
{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" />
<p>Send a message to start coding</p>
<p>{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}</p>
</div>
) : (
<div className="py-2">
{messages.map((msg) => (
<CoderMessageBubble key={msg.id} message={msg} />
))}
<div ref={messagesEndRef} />
</div>
<CoderMessageList
messages={messages as CoderTimelineWire[]}
footer={
activeTaskId && !permissionPrompt && sending === false ? (
<p className="text-xs text-muted-foreground animate-pulse">Agent running</p>
) : undefined
}
/>
)}
</div>
{permissionPrompt && (
<PermissionCard
prompt={permissionPrompt}
onRespond={(id) => void handlePermissionRespond(id)}
busy={permissionBusy}
/>
)}
{/* Diff panel — only shows when there are pending changes */}
{changes.filter((c) => c.status === 'pending').length > 0 && (
<div className="h-48 shrink-0">
@@ -404,28 +733,46 @@ export function CoderPane({ sessionId }: Props) {
</div>
)}
{/* Input */}
<div className="shrink-0 border-t border-border p-2">
{/* Composer + input */}
<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">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Ask BooCoder to write code..."
placeholder="Type / for commands…"
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]"
/>
<button
type="button"
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"
aria-label="Send message"
>
<Send size={16} />
</button>
</div>
</div>
{slashState && (
<SlashCommandPicker
query={slashState.query}
items={displayedCommands}
inputRef={inputRef}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
/>
)}
</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] };
}
case 'delta': {
const next = state.messages.map((m) =>
m.id === frame.message_id ? { ...m, content: m.content + frame.content } : m
);
const next = state.messages.map((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 };
}
case 'tool_call': {

View File

@@ -32,19 +32,19 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
// v1.10 booterm: terminal panes carry no chats. Their `id` is used as the
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They
// 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 };
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
return kind === 'coder' ? 'BooCoder' : 'Terminal';
}
// v2.0.0: coder pane — renders the BooCoder interface (chat + diff panel).
// Like terminal panes, carries no chats — the CoderPane component manages
// its own session/messages via the /api/coder proxy.
function coderPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'coder', chatIds: [], activeChatIdx: -1 };
function scopedPane(id: string, kind: 'coder' | 'terminal', chatId: string): WorkspacePane {
return { id, kind, chatId, chatIds: [chatId], activeChatIdx: 0 };
}
/** 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
@@ -79,8 +79,20 @@ function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
// 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
// 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[] {
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.
@@ -128,6 +140,8 @@ export interface UseWorkspacePanesResult {
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: 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;
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => 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
// round-trip). If a PATCH would echo this exact payload, we skip the call.
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.
useEffect(() => {
@@ -159,7 +221,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const session = await api.sessions.get(sessionId);
if (cancelled) return;
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
// 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));
setPanes(next);
setActivePaneIdx(0);
seedEmptyScopedPanes(next);
} finally {
if (!cancelled) hydratedRef.current = true;
}
})();
return () => { cancelled = true; };
}, [sessionId]);
}, [sessionId, seedEmptyScopedPanes]);
// v1.12.1: live cross-device sync. Replace local state when another device
// (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) => {
if (ev.type !== 'session_workspace_updated') 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);
if (json === lastRemoteJsonRef.current) return;
lastRemoteJsonRef.current = json;
setPanes(incoming.length > 0 ? incoming : [emptyPane()]);
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
// 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) => {
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 pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
return next;
});
@@ -408,16 +476,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return prev;
}
const newPane =
kind === 'terminal' ? terminalPane(newPaneId) :
kind === 'coder' ? coderPane(newPaneId) :
emptyPane(newPaneId);
kind === 'terminal'
? { id: newPaneId, kind: 'terminal' as const, chatIds: [] as string[], activeChatIdx: -1 }
: kind === 'coder'
? { id: newPaneId, kind: 'coder' as const, chatIds: [] as string[], activeChatIdx: -1 }
: emptyPane(newPaneId);
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
success = true;
if (kind === 'terminal' || kind === 'coder') {
queueMicrotask(() => void seedPaneChat(newPaneId, kind));
}
return next;
});
return success ? newPaneId : null;
}, []);
}, [seedPaneChat]);
const toggleSettingsPane = useCallback(() => {
setPanes((prev) => {
@@ -476,19 +549,39 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const validatePanes = useCallback((validChatIds: Set<string>) => {
setPanes((prev) => {
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));
if (nextIds.length === pane.chatIds.length) return pane;
if (nextIds.length === 0) {
return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
if (pane.kind === 'chat') {
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);
return { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx] };
});
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) => {
setPanes((prev) => prev.map((p) => {
@@ -574,6 +667,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
removeChatFromPanes,
initializeFirstChatIfEmpty,
validatePanes,
isPaneChatPending,
handlePaneDragStart,
handlePaneDragOver,
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 { 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 { api } from '@/api/client';
import type { Project as ProjectType, Session } from '@/api/types';
import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSessions } from '@/hooks/useSessions';
import { isCoderSessionName } from '@/lib/coder-session';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport';
@@ -124,7 +125,11 @@ export function Project() {
{sessions.map((s) => (
<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">
<MessageSquare className="size-3.5 opacity-70 shrink-0" />
{isCoderSessionName(s.name) ? (
<Code 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="text-xs text-muted-foreground font-mono ml-2 shrink-0">
{s.model}

Some files were not shown because too many files have changed in this diff Show More