Compare commits

..

8 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
148 changed files with 9983 additions and 3569 deletions

View File

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

5
.gitignore vendored
View File

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

109
AGENTS.md Normal file
View File

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

View File

@@ -2,6 +2,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. 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 ## 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. 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. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Cursor agents:** start with `AGENTS.md` (navigation) and `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference.
## What is BooCode ## What is BooCode
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side). Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
@@ -66,16 +68,24 @@ Key services:
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields. - **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes. - **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply. - **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
- **`services/provider-registry.ts`** — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
- **`services/agent-probe.ts`** — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`. Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
### BooCoder (`apps/coder/src/`) ### BooCoder (`apps/coder/src/`)
- Write-capable coding agent. Separate Fastify server at port 9502, same docker network (`boocode_net`). - Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST (Dockerfile builds server → coder). - **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers. - `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates). - Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to `http://boocoder:3000/api/*`. WS connects directly to `:9502`. - Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
### Frontend (`apps/web/src/`) ### Frontend (`apps/web/src/`)
@@ -122,7 +132,11 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ...
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (optional; default `/data/mcp.json` — JSON config for MCP servers matching opencode's `mcpServers` shape; file missing = no MCP). Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (optional; default `/data/mcp.json` — JSON config for MCP servers matching opencode's `mcpServers` shape; file missing = no MCP).
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailscale IP binding as BooChat. Health reports tool count: `{"ok":true,"db":true,"tools":30}`. BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL when unset. Set to a small model on llama-swap (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 required on host (container stays Node 20; 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 ## Workflow
@@ -133,7 +147,7 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailsc
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue). - Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`. - Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge. - Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference. - DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`. - Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client. - Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present). - Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
@@ -169,4 +183,5 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailsc
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects. - Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports). - MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
- **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer. - **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
- **Docker build order for workspace deps**: the Dockerfile must `COPY` + `RUN pnpm build` the provider app BEFORE the consumer app. `apps/coder/Dockerfile` builds `apps/server` first, then `apps/coder`. - **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.

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 # 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 ## 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/server` — Fastify API + WebSocket + inference loop + file-read tools
- `apps/web` — React frontend; served by Fastify in production, Vite in dev - `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 ## Local dev
@@ -28,7 +32,7 @@ cp .env.example .env
docker compose up -d boocode_db docker compose up -d boocode_db
# run server (port 3000) and web (port 5173) in two shells # run server (port 3000) and web (port 5173) in two shells
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boocode \ DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat \
LLAMA_SWAP_URL=http://100.101.41.16:8401 \ LLAMA_SWAP_URL=http://100.101.41.16:8401 \
pnpm dev:server pnpm dev:server
@@ -49,11 +53,18 @@ docker compose up --build -d
Binds to `100.114.205.53:9500` (Tailscale). Authelia is expected to gate the Binds to `100.114.205.53:9500` (Tailscale). Authelia is expected to gate the
upstream and inject `Remote-User`. Postgres binds loopback only. upstream and inject `Remote-User`. Postgres binds loopback only.
## What v1 has ## Services
Project sidebar, sessions per project, chat with streaming responses over |Service|Port|Description|
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 |BooChat|`100.114.205.53:9500`|Read-only chat + SPA |
`/v1/models`. |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 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 WORKDIR /app
COPY --from=builder /out/coder ./ COPY --from=builder /out/coder ./

View File

@@ -8,19 +8,24 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"", "build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
"start": "node dist/index.js", "start": "node dist/index.js",
"cli": "tsx src/cli.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.22.1",
"@boocode/server": "workspace:*", "@boocode/server": "workspace:*",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@fastify/websocket": "^10.0.1", "@fastify/websocket": "^10.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"ws": "^8.18.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/ws": "^8.5.10",
"tsx": "^4.16.2", "tsx": "^4.16.2",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vitest": "^3.0.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_TOKEN: z.string().optional(),
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'), GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
MCP_CONFIG_PATH: z.string().optional(), 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>; export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -9,6 +9,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
import { loadConfig } from './config.js'; import { loadConfig } from './config.js';
import { getSql, applySchema, pingDb, closeDb } from './db.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 // v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
// inference loop, broker, and tool registry without duplication. // inference loop, broker, and tool registry without duplication.
import { createInferenceRunner } from '@boocode/server/inference'; import { createInferenceRunner } from '@boocode/server/inference';
@@ -22,14 +23,31 @@ import { adaptWriteTool } from './services/tools/adapter.js';
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js'; import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
// Routes // Routes
import { registerMessageRoutes } from './routes/messages.js'; import { registerMessageRoutes } from './routes/messages.js';
import { registerSkillRoutes } from './routes/skills.js';
import { registerPendingRoutes } from './routes/pending.js'; import { registerPendingRoutes } from './routes/pending.js';
import { registerTaskRoutes } from './routes/tasks.js'; import { registerTaskRoutes } from './routes/tasks.js';
import { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js';
import { registerWebSocket } from './routes/ws.js'; import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe // Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js'; import { createDispatcher } from './services/dispatcher.js';
import { probeAgents } from './services/agent-probe.js'; import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
import { setPermissionHooks } from './services/permission-waiter.js';
import { homedir } from 'node:os';
async function main() { async function main() {
// MCP mode: stdio transport, no HTTP server
if (process.argv.includes('--mcp')) {
const config = loadConfig();
const sql = getSql(config);
await applySchema(sql);
await startMcpServer(sql);
return;
}
const config = loadConfig(); const config = loadConfig();
const app = Fastify({ const app = Fastify({
@@ -58,6 +76,31 @@ async function main() {
// Broker: in-memory pub/sub for session + user channel streaming. // Broker: in-memory pub/sub for session + user channel streaming.
const broker = createBroker(app.log); const broker = createBroker(app.log);
setPermissionHooks({
onPrompt: async (prompt) => {
await sql`
UPDATE tasks SET state = 'blocked' WHERE id = ${prompt.taskId} AND state = 'running'
`;
broker.publishFrame(prompt.sessionId, {
type: 'permission_requested',
task_id: prompt.taskId,
session_id: prompt.sessionId,
tool_title: prompt.toolTitle,
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
} as WsFrame);
},
onResolved: async (taskId, sessionId) => {
await sql`
UPDATE tasks SET state = 'running' WHERE id = ${taskId} AND state = 'blocked'
`;
broker.publishFrame(sessionId, {
type: 'permission_resolved',
task_id: taskId,
session_id: sessionId,
} as WsFrame);
},
});
// --- Tool registry extension --- // --- Tool registry extension ---
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to // Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds // the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
@@ -120,6 +163,16 @@ async function main() {
// Phase 4: probe available agents on startup // Phase 4: probe available agents on startup
await probeAgents(sql, app.log); await probeAgents(sql, app.log);
// Warm provider snapshot in background (ACP cold probes + model merges)
void getProviderSnapshot(sql, config, homedir(), true)
.then((entries) => persistProbedModels(sql, entries, app.log))
.catch((err) => {
app.log.warn(
{ err: err instanceof Error ? err.message : String(err) },
'provider-snapshot: warm failed',
);
});
// Phase 4: dispatcher — polls tasks table and runs inference // Phase 4: dispatcher — polls tasks table and runs inference
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config }); const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
dispatcher.start(); dispatcher.start();
@@ -127,8 +180,13 @@ async function main() {
// Register routes // Register routes
registerMessageRoutes(app, sql, broker, inferenceApi); registerMessageRoutes(app, sql, broker, inferenceApi);
registerSkillRoutes(app, sql, broker, inferenceApi);
registerPendingRoutes(app, sql); registerPendingRoutes(app, sql);
registerTaskRoutes(app, sql, inferenceApi); registerTaskRoutes(app, sql, inferenceApi);
registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql);
registerProviderRoutes(app, sql, config);
registerWebSocket(app, sql, broker); registerWebSocket(app, sql, broker);
// Serve static frontend (built web app). In production, the dist/ is // Serve static frontend (built web app). In production, the dist/ is

View File

@@ -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 { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames'; import type { WsFrame } from '@boocode/server/ws-frames';
import { resolveChatId } from './chat-resolve.js';
const SendBody = z.object({ const SendBody = z.object({
content: z.string().min(1).max(64_000), content: z.string().min(1).max(64_000),
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 { interface InferenceApi {
@@ -15,12 +21,100 @@ interface InferenceApi {
hasActive: (chatId: string) => boolean; hasActive: (chatId: string) => boolean;
} }
interface MessageRow {
id: string;
role: string;
content: string | null;
status: string | null;
tool_calls: Array<{ id: string; name: string; args?: Record<string, unknown> }> | null;
tool_results: {
tool_call_id: string;
output: unknown;
truncated?: boolean;
error?: string;
} | null;
reasoning_parts: Array<{ text?: string }> | null;
}
function mapCoderMessageRow(row: MessageRow) {
if (row.role === 'tool') {
if (!row.tool_results?.tool_call_id) return null;
return {
id: row.id,
role: 'tool' as const,
tool_results: row.tool_results,
};
}
if (row.role !== 'user' && row.role !== 'assistant' && row.role !== 'system') {
return null;
}
const tool_calls = row.tool_calls?.map((tc) => ({
id: tc.id,
function: {
name: tc.name,
arguments: JSON.stringify(tc.args ?? {}),
},
}));
const reasoningText = row.reasoning_parts?.map((p) => p.text ?? '').join('') ?? '';
return {
id: row.id,
role: row.role as 'user' | 'assistant' | 'system',
content: row.content ?? '',
status: (row.status ?? 'complete') as 'streaming' | 'complete' | 'failed',
...(reasoningText ? { reasoning_text: reasoningText } : {}),
...(tool_calls?.length ? { tool_calls } : {}),
};
}
export function registerMessageRoutes( export function registerMessageRoutes(
app: FastifyInstance, app: FastifyInstance,
sql: Sql, sql: Sql,
broker: Broker, broker: Broker,
inference: InferenceApi, inference: InferenceApi,
): void { ): void {
// GET /api/sessions/:sessionId/messages — hydrate CoderPane on load / reconnect
app.get<{ Params: { sessionId: string }; Querystring: { chat_id?: string } }>(
'/api/sessions/:sessionId/messages',
async (req, reply) => {
const sessionId = req.params.sessionId;
const chatId = req.query.chat_id;
const sessionRows = await sql<{ id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
if (chatId) {
const chatRows = await sql<{ id: string }[]>`
SELECT id FROM chats
WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found or not open in this session' };
}
}
const rows = chatId
? await sql<MessageRow[]>`
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
FROM messages_with_parts
WHERE session_id = ${sessionId} AND chat_id = ${chatId}
ORDER BY created_at ASC, id ASC
`
: await sql<MessageRow[]>`
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
FROM messages_with_parts
WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC
`;
return rows.map(mapCoderMessageRow).filter((m) => m !== null);
},
);
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference // POST /api/sessions/:sessionId/messages — send a user message + kick off inference
app.post<{ Params: { sessionId: string } }>( app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/messages', '/api/sessions/:sessionId/messages',
@@ -32,73 +126,96 @@ export function registerMessageRoutes(
} }
const sessionId = req.params.sessionId; 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 // Validate session exists
const sessionRows = await sql<{ id: string }[]>` const sessionRows = await sql<{ id: string; project_id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId} SELECT id, project_id FROM sessions WHERE id = ${sessionId}
`; `;
if (sessionRows.length === 0) { if (sessionRows.length === 0) {
reply.code(404); reply.code(404);
return { error: 'session not found' }; return { error: 'session not found' };
} }
// Validate chat belongs to session and is open const resolved = await resolveChatId(sql, sessionId, pane_id);
const chatRows = await sql<{ id: string; session_id: string }[]>` if (!resolved) {
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404); 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 let chatId = resolved;
if (inference.hasActive(chatId)) { if (explicitChatId) {
reply.code(409); const chatRows = await sql<{ id: string }[]>`
return { error: 'inference already running on this chat' }; 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 if (!isExternal) {
const result = await sql.begin(async (tx) => { // Reject if inference is already running on this chat
const [userMsg] = await tx<{ id: string }[]>` if (inference.hasActive(chatId)) {
INSERT INTO messages (session_id, chat_id, role, content, status, created_at) reply.code(409);
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp()) return { error: 'inference already running on this chat' };
RETURNING id }
`; }
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
});
// Publish user message frames so WS subscribers see it immediately // 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, { broker.publishFrame(sessionId, {
type: 'message_started', type: 'message_started',
message_id: result.user_message_id, message_id: userMsg!.id,
chat_id: chatId, chat_id: chatId,
role: 'user', role: 'user',
} as unknown as WsFrame); } as unknown as WsFrame);
broker.publishFrame(sessionId, { broker.publishFrame(sessionId, {
type: 'delta', type: 'delta',
message_id: result.user_message_id, message_id: userMsg!.id,
chat_id: chatId, chat_id: chatId,
content, content,
} as unknown as WsFrame); } as unknown as WsFrame);
broker.publishFrame(sessionId, { broker.publishFrame(sessionId, {
type: 'message_complete', type: 'message_complete',
message_id: result.user_message_id, message_id: userMsg!.id,
chat_id: chatId, chat_id: chatId,
} as unknown as WsFrame); } as unknown as WsFrame);
// Enqueue inference — the runner will stream assistant deltas via broker if (isExternal) {
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default'); // External provider: create a task for the dispatcher
const projectId = sessionRows[0]!.project_id;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, 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); 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

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

View File

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

View File

@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS tasks (
ended_at TIMESTAMPTZ, ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')), 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 ( CREATE TABLE IF NOT EXISTS available_agents (
@@ -46,6 +46,28 @@ CREATE TABLE IF NOT EXISTS available_agents (
-- v2.0.0 Phase 4: link tasks to their inference sessions. -- 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); 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 -- Human inbox: tasks needing attention
CREATE OR REPLACE VIEW human_inbox AS CREATE OR REPLACE VIEW human_inbox AS
SELECT * FROM tasks WHERE state IN ('blocked', 'failed'); SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
-- v2.1.0: provider picker — extend available_agents with model discovery.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
-- 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

@@ -1,49 +1,113 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { exec as execCb } from 'node:child_process';
import { 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 execFileAsync = promisify(execFile); const exec = promisify(execCb);
const KNOWN_AGENTS: Array<{ name: string; supportsAcp: boolean }> = [ async function resolveInstallPath(agentName: string): Promise<string | null> {
{ name: 'opencode', supportsAcp: true }, const candidates = resolveAcpProbeBinaries(agentName);
{ name: 'goose', supportsAcp: true }, for (const bin of candidates) {
{ name: 'claude', supportsAcp: false },
{ name: 'pi', supportsAcp: false },
];
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
log.info('agent-probe: scanning PATH for known agents');
for (const agent of KNOWN_AGENTS) {
try { try {
// Check if the agent binary is on PATH const { stdout } = await exec(`which ${bin}`, { timeout: 10_000 });
const { stdout: whichOut } = await execFileAsync('which', [agent.name], { timeout: 5_000 }); const path = stdout.trim();
const installPath = whichOut.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; if (!installPath) continue;
// Get version
let version: string | null = null; let version: string | null = null;
try { try {
const { stdout: verOut } = await execFileAsync(agent.name, ['--version'], { timeout: 10_000 }); const { stdout: verOut } = await exec(`"${installPath}" --version`, { timeout: 15_000 });
version = verOut.trim().slice(0, 100); version = verOut.trim().slice(0, 100);
} catch { } catch {
// Some agents may not support --version — that's fine /* optional */
} }
// UPSERT into available_agents 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` await sql`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at) INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
VALUES (${agent.name}, ${installPath}, ${version}, ${agent.supportsAcp}, clock_timestamp()) VALUES (${agentName}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${label}, ${transport})
ON CONFLICT (name) DO UPDATE SET ON CONFLICT (name) DO UPDATE SET
install_path = EXCLUDED.install_path, install_path = EXCLUDED.install_path,
version = EXCLUDED.version, version = EXCLUDED.version,
supports_acp = EXCLUDED.supports_acp, supports_acp = EXCLUDED.supports_acp,
last_probed_at = EXCLUDED.last_probed_at last_probed_at = EXCLUDED.last_probed_at,
models = EXCLUDED.models,
label = EXCLUDED.label,
transport = EXCLUDED.transport
`; `;
log.info({ agent: agent.name, version, installPath }, 'agent-probe: found'); log.info({ agent: agentName, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
} catch { } catch (err) {
// Agent not found on PATH — skip silently const msg = err instanceof Error ? err.message : String(err);
log.debug({ agent: agentName, err: msg }, 'agent-probe: not found');
} }
} }

View File

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

View File

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

View File

@@ -1,7 +1,14 @@
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
import { 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 { interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
@@ -21,7 +28,7 @@ const POLL_INTERVAL_MS = 5_000;
const COMPLETION_POLL_MS = 2_000; const COMPLETION_POLL_MS = 2_000;
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } { export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
const { sql, inference, log, config } = deps; const { sql, inference, broker, log, config } = deps;
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
let running = false; let running = false;
let stopping = false; let stopping = false;
@@ -31,8 +38,17 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
if (running || stopping) return; if (running || stopping) return;
// Grab one pending task // Grab one pending task
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null }[]>` const rows = await sql<{
SELECT id, project_id, input, agent, model id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
}[]>`
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
FROM tasks FROM tasks
WHERE state = 'pending' WHERE state = 'pending'
ORDER BY created_at ORDER BY created_at
@@ -48,9 +64,40 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
}); });
} }
async function runTask(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> { async function runTask(task: {
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
}): Promise<void> {
const taskId = task.id; const taskId = task.id;
log.info({ taskId }, 'dispatcher: starting task');
// 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 { try {
// Mark running // Mark running
@@ -101,7 +148,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const finalStatus = await waitForCompletion(assistantId); const finalStatus = await waitForCompletion(assistantId);
if (stopping) { if (stopping) {
// Graceful shutdown — mark cancelled
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'cancelled', ended_at = clock_timestamp() SET state = 'cancelled', ended_at = clock_timestamp()
@@ -110,44 +156,294 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
return; 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') { if (finalStatus === 'complete') {
// Grab assistant content for output_summary
const [msg] = await sql<{ content: string | null }[]>` const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId} SELECT content FROM messages WHERE id = ${assistantId}
`; `;
const summary = (msg?.content ?? '').slice(0, 500); const summary = (msg?.content ?? '').slice(0, 500);
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary} SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.info({ taskId }, 'dispatcher: task completed'); log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
} else { } else {
// failed or cancelled
const [msg] = await sql<{ content: string | null }[]>` const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId} SELECT content FROM messages WHERE id = ${assistantId}
`; `;
const summary = (msg?.content ?? 'Inference failed').slice(0, 500); const summary = (msg?.content ?? 'Inference failed').slice(0, 500);
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary} SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.warn({ taskId, finalStatus }, 'dispatcher: task failed'); log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
} }
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
log.error({ taskId, err: errMsg }, 'dispatcher: task error'); log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)');
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)} SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId} WHERE id = ${taskId}
`.catch(() => {}); // best-effort `.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> { async function waitForCompletion(assistantId: string): Promise<string> {
// Poll until the assistant message is no longer streaming
for (;;) { for (;;) {
if (stopping) return 'cancelled'; if (stopping) return 'cancelled';

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 { deleteFileTool } from './delete_file.js';
import { applyPendingTool } from './apply_pending.js'; import { applyPendingTool } from './apply_pending.js';
import { rewindTool } from './rewind.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'; export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
@@ -16,6 +19,11 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
deleteFileTool, deleteFileTool,
editFileTool, editFileTool,
rewindTool, 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 // 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]), 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. * checks the result stays within projectRoot.
*/ */
export function resolveWritePath(projectRoot: string, filePath: string): string { 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'); 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 candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
const normalized = resolve(candidate); // normalizes ../ segments const normalized = resolve(candidate); // normalizes ../ segments

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,8 @@ export async function maybeAutoNameChat(
const sessionRows = await ctx.sql<{ model: string }[]>` const sessionRows = await ctx.sql<{ model: string }[]>`
SELECT model FROM sessions WHERE id = ${sessionId} 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; if (!model) return;
const assistantMsg = await ctx.sql<{ content: string }[]>` const assistantMsg = await ctx.sql<{ content: string }[]>`

View File

@@ -20,3 +20,5 @@ export type {
export type { ToolPhaseResult } from './tool-phase.js'; export type { ToolPhaseResult } from './tool-phase.js';
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js'; export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
export { buildMessagesPayload } from './payload.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 // omit it and exercise the byte-stability surface directly through
// buildSystemPromptWithFingerprint. The observer Map in system-prompt.ts // buildSystemPromptWithFingerprint. The observer Map in system-prompt.ts
// updates regardless of whether log is passed. // updates regardless of whether log is passed.
function toolResultIdsFollowing(history: Message[], assistantIdx: number): Set<string> {
const ids = new Set<string>();
for (let j = assistantIdx + 1; j < history.length; j++) {
const row = history[j]!;
if (row.role === 'user' || row.role === 'assistant') break;
if (row.role === 'tool' && row.tool_results?.tool_call_id) {
ids.add(row.tool_results.tool_call_id);
}
}
return ids;
}
function findAssistantOwnerForToolCall(history: Message[], toolIdx: number, callId: string): number | null {
for (let k = toolIdx - 1; k >= 0; k--) {
const row = history[k]!;
if (row.role === 'user') break;
if (row.role === 'assistant' && row.tool_calls?.some((tc) => tc.id === callId)) return k;
}
return null;
}
function assistantToolCallsArePayloadComplete(history: Message[], assistantIdx: number): boolean {
const assistant = history[assistantIdx]!;
if (!assistant.tool_calls?.length) return false;
const fulfilled = toolResultIdsFollowing(history, assistantIdx);
return assistant.tool_calls.every((tc) => fulfilled.has(tc.id));
}
export async function buildMessagesPayload( export async function buildMessagesPayload(
session: Session, session: Session,
project: Project, project: Project,
@@ -97,6 +125,10 @@ export async function buildMessagesPayload(
if (m.role === 'tool') { if (m.role === 'tool') {
const tr = m.tool_results; const tr = m.tool_results;
if (!tr) continue; if (!tr) continue;
const ownerIdx = findAssistantOwnerForToolCall(history, i, tr.tool_call_id);
if (ownerIdx == null || !assistantToolCallsArePayloadComplete(history, ownerIdx)) {
continue;
}
const outputText = tr.error const outputText = tr.error
? `error: ${tr.error}` ? `error: ${tr.error}`
: typeof tr.output === 'string' : typeof tr.output === 'string'
@@ -115,11 +147,15 @@ export async function buildMessagesPayload(
content: m.content && m.content.length > 0 ? m.content : null, content: m.content && m.content.length > 0 ? m.content : null,
}; };
if (m.tool_calls && m.tool_calls.length > 0) { if (m.tool_calls && m.tool_calls.length > 0) {
msg.tool_calls = m.tool_calls.map((tc) => ({ if (assistantToolCallsArePayloadComplete(history, i)) {
id: tc.id, msg.tool_calls = m.tool_calls.map((tc) => ({
type: 'function' as const, id: tc.id,
function: { name: tc.name, arguments: JSON.stringify(tc.args) }, 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 // v1.13.1-C: collapse reasoning_parts into a single string. The view
// returns them ordered by sequence; multiple reasoning parts on one // returns them ordered by sequence; multiple reasoning parts on one
@@ -127,6 +163,11 @@ export async function buildMessagesPayload(
if (m.reasoning_parts && m.reasoning_parts.length > 0) { if (m.reasoning_parts && m.reasoning_parts.length > 0) {
msg.reasoning = m.reasoning_parts.map((p) => p.text ?? '').join(''); msg.reasoning = m.reasoning_parts.map((p) => p.text ?? '').join('');
} }
const hasPayload =
(msg.content != null && msg.content.trim().length > 0) ||
(msg.tool_calls != null && msg.tool_calls.length > 0) ||
(msg.reasoning != null && msg.reasoning.length > 0);
if (!hasPayload) continue;
out.push(msg); out.push(msg);
continue; continue;
} }
@@ -142,7 +183,7 @@ export async function loadContext(
): Promise<{ session: Session; project: Project; history: Message[] } | null> { ): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>` const sessionRows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled agent_id, web_search_enabled, allowed_read_paths
FROM sessions WHERE id = ${sessionId} FROM sessions WHERE id = ${sessionId}
`; `;
if (sessionRows.length === 0) return null; if (sessionRows.length === 0) return null;

View File

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

View File

@@ -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 // new skills, per-entry mtime check on body access so a hot-edited SKILL.md
// is re-read without a restart. No watcher. // is re-read without a restart. No watcher.
const SKILLS_ROOT = '/data/skills'; const SKILLS_ROOT = process.env.SKILLS_ROOT ?? '/data/skills';
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024; const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
const LIST_CACHE_TTL_MS = 60_000; const LIST_CACHE_TTL_MS = 60_000;

View File

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

View File

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

View File

@@ -206,6 +206,100 @@ export interface ModelInfo {
[key: string]: unknown; [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 { export interface SidebarSession {
id: string; id: string;
name: string; name: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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